引起并发bug的3个主因
缓存导致的可见性
由于cpu拥有自己独立的缓存空间,多线程+多核CPU并发修改同一个变量的流程是:
- 每个线程对应的CPU都加载内存中的变量到自己的缓存中;
- 修改变量,存入到CPU中缓存中;
- 数据从CPU的缓存输入到,内存中;
由于cpu的缓存是独立的对其他线程不可见,所以就会出现在并发情况下内存中的变量相互覆盖。如图:
线程切换的原子性
1个cpu可以“同时”执行多个进程,这里的“指的是,cpu在执行进程时候是通过在进程直接切换,每个进程执行一小段时候实现的。但是进程之间内存不共享,它们的切换需要修改内存地址,开销比较大。所以操作系统一般改为在一个进程替换切换线程来实现并发,因为线程共享内存,切换不需要修改内存地址。但是线程之间的切换往往会引起并发上的bug。
如:我们在一个进程中的俩个线程都对一个变量做累加1000,往往结果不会等于2000,这就是在一个cpu下,俩个线程在执行上来回切换执行导致。如流程如下图下图:
编译优化的指令顺序
现代的编译器为了性能往往会对指令重排序,这就会导致一些奇怪的bug,如下双重检查的单例模式:
1 | public class Singleton { |
线程A在执行这段代码的instance = new Singleton();在cpu的指令是3条命令,我们期望的步骤是:
- 初始化地址&M
- 在&M地址上new Singleton()
- &M赋值给instance
而实际上是
- 初始化地址&M
- &M赋值给instance
- 在&M地址上new Singleton()
这样线程B和A同时调用getInstance()的时候,有可能线程B执行第一个判断时候,由于编译器指令优化A已经instance=&M,所以B判断instance不为空直接返回,但是这时候&M还没有new Singleton()导致这空指针异常。
java的内存模型
由于java线程间通信采用的是共享内存的方式,所以遇到上述的指令重排序往往会引起一些bug,在此基础上,java采用了synchronized、volatile、final关键字以及6个Happens-Before规则来约束指令重排序。
happens-before原则
同一线程,前面的操作happens-before于后面的操作
参考代码1,x=42一定happens-before v=true
1 | // 以下代码来源于【参考 1】 |
volatile关键字修饰的变量,写happens-before读操作
结合代码1:如果线程a、b同时执行,线程a调用writer,线程b调用reader,那么v一定等于true
happens-before传递性 a–>b b–>c 则 a–>c
结合这条规则和1、2看,x一定=42。因为x=42–>v=true v=true–>v==true 所以 x=42
synchronized关键字,不同线程 解锁happens-before加锁
线程b,和线程a同时执行下面代码,线程a获取锁之后执行完成并且释放锁,这时候b执行,x的值一定是12。即线程a操作hanppens-before b
1 | synchronized (this) { // 此处自动加锁 |
线程strat()
线程a调用了线程b.start(),那么a的变量和操作,线程b可见
线程的join()
线程b在线程a中调用了b.join(),那么b里的共享变量和共享操作,a可见
1 | Thread B = new Thread(()->{ |
final的应用
说明该变量初始化后即不变,其他线程访问时候也一定是一个完全初始化的变量,这就可以解决前面提到的双重检查单例线程不安全的问题。但是要小心溢出问题。
1 | // 以下代码来源于【参考 1】 |