学习《Java并发编程实战》课程之余,结合自己的理解整理一部分笔记以巩固知识。
单核时代,所有线程在同一CPU上云析,CPU缓存与内存的数据一致性容易解决。如下图,线程A与B操作同一个CPU里的缓存,故A修改过变量V后,B再访问变量V,得到的一定是最新值,即A修改过的值。
一个线程对共享变量的修改,另一个线程可以立即看到,称之为 可见性。
多核时代,每个CPU都有各自的缓存,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存,如下图所示,线程A所修改的CPU-1缓存中的变量V,这个操作对线程B则不具有可见性。
高级语言里一条语句往往需要多条 CPU 指令完成,例如要完成count += 1,至少需要三条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操作会以以下顺序执行:
但经过优化后的执行顺序可能是这样的:
假如线程A执行完指令2之后恰好发生了线程切换,切换到了线程B,B也执行getInstance()方法,则B会判断instance != null,所以直接返回instance,而此时instance还没有经过初始化,访问该变量会触发空指针异常。如下图所示。
并发程序经常出现的问题归根结底是直觉欺骗了我们,要诊断并发Bug,需要深刻理解可见性、原子性、有序性在并发场景下的原理。
并发编程Bug源头: 缓存 带来的可见性问题; 线程 切换带来的原子性问题; 编译 优化带来的有序性问题。