并发编程的起源
硬件设备发展的核心矛盾:CPU、内存、I/O设备三者间存在的速度差异。根据木桶原理,程序整体性能最终受制于速度最慢的I/O设备。
为了平和三者速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU增加了缓存,以均衡与内存的速度差异;
- 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
- 编译程序优化指令执行顺序,使得缓存能够得到更加合理地利用。
编发编程出现问题的源头
一:缓存导致的可见性问题
单核时代,所有线程在同一CPU上云析,CPU缓存与内存的数据一致性容易解决。如下图,线程A与B操作同一个CPU里的缓存,故A修改过变量V后,B再访问变量V,得到的一定是最新值,即A修改过的值。
一个线程对共享变量的修改,另一个线程可以立即看到,称之为 可见性 。
多核时代,每个CPU都有各自的缓存,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存,如下图所示,线程A所修改的CPU-1缓存中的变量V,这个操作对线程B则不具有可见性。
二:线程切换带来的原子性问题
高级语言里一条语句往往需要多条 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源头: 缓存 带来的可见性问题; 线程 切换带来的原子性问题; 编译 优化带来的有序性问题。