原因一:缓存导致的可见性问题
在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。如图所示:线程A和线程B读到的是一块缓存块.假设线程A更新了内存中的值,那么线程B读到的也一定是最新的A更改过的
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 的操作对于线程 B 而言就不具备可见性了。由于每个CPU上都有自己的缓存块,所以每个线程切换的时候读到的是都是不确定的.
我们再回到上一期的代码留下的问题,我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。
原因之二:代码重排序带来的问题
编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,编译器虽然调整了语句的顺序,但是不影响程序的最终结果。
在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例
假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null
,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null
时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。这个代码看似完美,但实际上并不是这样的,下面我们来分析下
我们假设线程 A 先执行 getInstance() 方法,当执行完时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null
,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
其实总结下来,主要就是缓存一致性,线程之间的切换,和代码重排序导致了代码的运行并不是我们想象的情况运行.但恰恰是这种情况提升了我们解决问题,思考问题的能力.