Java并发编程-并发编程的Bug源头:可见性、原子性和有序性问题

学习《Java并发编程实战》课程之余,结合自己的理解整理一部分笔记以巩固知识。

并发编程的起源

  • 1.硬件设备发展的核心矛盾:CPU、内存、I/O设备三者间存在的速度差异。根据木桶原理,程序整体性能最终受制于速度最慢的I/O设备。
  • 2.为了平和三者速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
  • (1)CPU增加了缓存,以均衡与内存的速度差异;
  • (2)操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
  • (3)编译程序优化指令执行顺序,使得缓存能够得到更加合理地利用。

编发编程出现问题的源头

一:缓存导致的可见性问题

单核时代,所有线程在同一CPU上云析,CPU缓存与内存的数据一致性容易解决。如下图,线程A与B操作同一个CPU里的缓存,故A修改过变量V后,B再访问变量V,得到的一定是最新值,即A修改过的值。

单核CPU缓存与内存的关系
一个线程对共享变量的修改,另一个线程可以立即看到,称之为 可见性

多核时代,每个CPU都有各自的缓存,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存,如下图所示,线程A所修改的CPU-1缓存中的变量V,这个操作对线程B则不具有可见性。

多核CPU的缓存与内存关系

二:线程切换带来的原子性问题

高级语言里一条语句往往需要多条 CPU 指令完成,例如要完成count += 1,至少需要三条CPU指令。

  • 指令1:把变量count从内存加载到CPU的寄存器中;
  • 指令2:在寄存器中执行 +1 操作;
  • 指令3:将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)

操作系统进行线程切换,可以发生在任何一条CPU指令执行完(不是高级语言中的一条语句)。如下图所示,假设在线程A执行第一条CPU指令后发生了线程切换,A与B会以图中顺序执行。得到的count不是我们期望的2,而是1.

非原子操作的执行路径示意图
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性成为原子性。CPU可以保证的原子操作是CPU指令级别,而高级语言层面保证操作的原子性。

三:编译优化带来的有序性问题

有序性指的是程序按照代码先后顺序执行,而编译器为了优化性能,有时候会改变程序中语句的先后顺序。 举一个Java中的一个经典案例,双重检查的单利模式。

pubic class Singleto {
    static Singleto instance;
    static Singleto getInstance(){
        if (instance == null) {
            synchronized(Singleto.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
    }
    
    return instance;
}
复制代码

假设线程A、B同时调用getInstance()方法,乍一看上去,线程发现instance == null 后,会对Singleto.class加锁,JVM保证只有一个线程可以获得该锁,则另一个线程会处于等待状态。最后只有一个线程创建实例成功,另一个线程在锁释放后获得锁,然后检查instance == null时,发现Singleto实例已经创建成功,所以不会再创建一个Singleto实例。 实际上,getInstance()方法是存在问题的,问题就在new操作上,我们默认任务new操作会以以下顺序执行:

  • 1.在堆上分配一块内存M;
  • 2.在内存M上初始化Singleto对象的实例;
  • 3.把M的地址赋值给instance变量。

但经过优化后的执行顺序可能是这样的:

  • 1.分配一块内存M;
  • 2.将M的地址赋值给instance变量;
  • 3.最后在内存M上初始化Singleto对象。

假如线程A执行完指令2之后恰好发生了线程切换,切换到了线程B,B也执行getInstance()方法,则B会判断instance != null,所以直接返回instance,而此时instance还没有经过初始化,访问该变量会触发空指针异常。如下图所示。

双重检查创建单利的异常执行路径

总结

并发程序经常出现的问题归根结底是直觉欺骗了我们,要诊断并发Bug,需要深刻理解可见性、原子性、有序性在并发场景下的原理。

并发编程Bug源头:缓存带来的可见性问题;线程切换带来的原子性问题;编译优化带来的有序性问题。

猜你喜欢

转载自juejin.im/post/5ca024c551882567d41ebc4b