总结线程安全问题及其解决方案

1.线程安全(重点)

线程不安全:在随机调度之下,程序执行有多种可能,其中的某些可能导致代码出现了bug

一个典型的例子:两个线程对同一个变量进行并发的自增,此时运行的结果是跟预期不一样的

// 创建两个线程, 让这俩线程同时并发的对一个变量, 自增 5w 次. 最终预期能够一共自增 10w 次.
class Counter {
    
    
    // 用来保存计数的变量
    public int count;

     public void increase() {
    
    
        count++;
    }

}
public class Demo13 {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Counter counter=new Counter();
        Thread t1=new Thread(()->{
    
    
            for(int i=0;i<50000;i++){
    
    
                counter.increase();
            }
        });
        t1.start();
        Thread t2=new Thread(()->{
    
    
            for(int i=0;i<50000;i++){
    
    
                counter.increase();
            }
        });

        t2.start();
          try {
    
    
            t1.join();
            t2.join();//保证t1,t2先执行完
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        System.out.println("count:"+counter.count);

    }
}

线程不安全,随机调度的顺序不一样,就导致程序的运行结果就不一样

在这里插入图片描述

像count++其实一行代码,对应三个机器指令

1)从内存读取数据到CPU (load)

2)在CPU寄存器中,完成加法运算(add)

3)把寄存器的数据写回到内存中(save)

在这里插入图片描述

上面列举的情况只是其中一种

由于调度器的调度,count最后结果不确定


造成线程不安全的原因

  1. 操作系统的随机调度/抢占式执行

  2. 多个线程修改同一个变量(在写代码的时候,就可以针对这个要点进行控制了,可以通过调整程序设计进行控制,这个方法适用范围有限,不是所有的场景都能规避)

  3. 有些修改操作不是原子的(就比如++,++对应三条机器指令,则不是原子的)(可以通过加锁操作来将操作打包成原子)

  4. 内存可见性,引起的线程安全问题

比如说线程1进行反复的读和判断,如果正常的情况下,线程1在读和判断,线程2突然写了一下都正常,在线程2写完之后,线程1就能立即读到内存的变化,从而让判断出现变化,但是,在程序运行过程中,可能会涉及到一个操作“优化”(这个“优化”可能是编译器javac,也可能是JVM,也可能是操作系统来进行的)

在这里插入图片描述

就比如线程1要反复的load,也就是读操作,在线程2修改之前,线程1反复读,每次读到的数据都是一样的,JVM就作出了这样的“优化”,就不再重复的从内存中读了,直接就复用第一次从内存读到寄存器的数据就好了(因为从寄存器中读快),在此时优化后,线程2突然写了一个数据,由于线程1已经优化读成寄存器了,因此线程2的修改线程1就感知不到。

内存可见性问题:内存改了,但是在优化的背景下,读不到,看不见

针对这个问题,Java引入了volatile关键字,让程序员手动的禁止编译器对某个变量进行上述优化

  1. 指令重排序,也可能引起线程不安全

指令重排序,也是操作系统/编译器/JVM优化操作

比如:语句1,语句2,语句3,指令重排序就是把这里的顺序给调整达到加快速度的效果

就比如:小明同学去买菜,要买1.西红柿,2.鸡蛋,3.茄子,4.小芹菜,但是在超市菜的摆放位置是1.茄子,2.西红柿,3.鸡蛋,4.小芹菜;所以我们调整顺序,先买茄子,西红柿,鸡蛋,小芹菜,这样效率就高了

优化不管怎么进行,大前提是要保证程序的逻辑不变

1)如果是单线程下,保证逻辑不变容易做到

2)在多线程下,保证逻辑不变就不容易了

比如:Test t=new Test();

创建这样的对象有三个步骤1.创建内存空间 2.往这个内存空间上构造一个对象 3.把这个内存的引用赋值给t;

此时另一个线程尝试读取t的引用,如果是2,3第二个线程读到t为非null的时候,此时t就一定是一个有效的对象;如果按照3,2,第二个线程读到t为非null时,仍然可能是一个无效对象。

此处就容易出现指令重排序引入的问题,2和3的顺序是可以调换的,在单线程下,调换这两的顺序没影响。

解决原子性(加锁操作)

修改操作不是原子的是线程不安全的原因之一,但我们可以通过加锁操作将修改操作在三个指令打包成“原子”的操作。

synchronized:此处的synchronized从字面上翻译叫做“同步”,这里的“同步”指的是“互斥”

另外同步,还有“同步”和“异步”的用法(IO的场景或上下级调用的场景)

比如:我去餐馆吃饭,点了蛋炒饭

同步:调用者自己来负责获取到的调用结果(我发起请求后,就在吧台这里盯着,等后厨把饭做出来,我就直接端走了)

异步:这个是调用者自己不负责获取调用结果,是由调用者把算好的结果主动推送过来(我发起请求后,我就不管了,我就找个位置做下玩手机,等后厨做好了,有服务员给我送来)

在这里插入图片描述

class Counter {
    
    
    // 用来保存计数的变量
    public int count;

   synronized  public void increase() {
    
    //与上面不同的是加锁了
        count++;
    }

}
public class Demo13 {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Counter counter=new Counter();
        Thread t1=new Thread(()->{
    
    
            for(int i=0;i<50000;i++){
    
    
                counter.increase();
            }
        });
        t1.start();
        Thread t2=new Thread(()->{
    
    
            for(int i=0;i<50000;i++){
    
    
                counter.increase();
            }
        });

        t2.start();
          try {
    
    
            t1.join();
            t2.join();//保证t1,t2先执行完
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
        System.out.println("count:"+counter.count);
		
    }
}

.解决内存可见性(volatile关键字)

内存可见性也是线程不安全的原因之一

内存可见性针对的场景是:一个线程写,一个线程读

public class Demo15 {
    
    
    public static int test = 0;  //定义一个整形变量

    public static void main(String[] args) {
    
    
        Thread t1 = new Thread(() -> {
    
    
            while (test == 0) {
    
    
                //啥都不干
            }
            System.out.println("线程1执行完了");  //循环结束, 则打印这个语句
        });

        Thread t2 = new Thread(() -> {
    
    
            Scanner in = new Scanner(System.in);
            System.out.println("输入 ->"); //在线程2中偷偷改变 test 的值
            test = in.nextInt();
        });
        t1.start();
        t2.start();

        try {
    
    
            t1.join(); //线程等待
            t2.join();
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }

}

预期效果是,t2线程这里输入了一个非零的数字,此时t1线程循环结束,随之进程结束

实际的现象是:当我们输入非0的数字时,t1线程并没有结束

原因:

  • t1做的工作,LOAD:读内存的数据到CPU,TEST:检测CPU寄存器的值是否和预期的一样;
  • t1这样的工作反复进行多次,由于读内存比读CPU寄存器慢几千倍,上万倍,意味着当前的t1操作主要就是慢在LOAD上,编译一看,每次LOAD的结果又没啥变化,就直接进行了“优化”,
  • 那就相当于只从内存中读取一次数据然后后续就直接从寄存器里进行反复TEST就好了

所以要想解决上面出现的内存可见性问题,可以加volatile关键字,也就是在将public static int test=0;改为

public static volatile int test=0;

volatile操作相当于显式禁止了编译器进行上述优化,是给这个对应的变量加上了“内存屏障”(特殊的二进制指令),JVM在读取这个变量的时候,因为有内存屏障的存在,就知道要每次都重新读取这个内存的内容,而不是进行草率的优化

总结volatile作用

  • 通过特殊的二进制指令为这个变量增加了一个内存屏障
  • 能够让JVM在读取这个变量的时候, 知道这个变量"身份特殊", 就强制每次都要从内存中读取这个变量的值
  • 因此提升了线程安全性, 还能禁止指令重排序

猜你喜欢

转载自blog.csdn.net/HBINen/article/details/126593368