概述

项目中用到了双重检查锁定单例模式(DCL),同组的同学看到后给提交了新的merge request,去除了双重检查锁定,原因是DCL并不是安全的。从这个引子开始,我接触到了一系列并发相关的知识,并有了自己的理解,这里用文字整理出来,以巩固知识。先留下最初的问题:DCL为什么是不安全的,如何保证DCL的并发安全?接下去的内容会逐步解释这个问题。

并发不安全是怎么产生的?

现代计算机,为了优化计算性能,在底层实现上采用了高速缓存,每个CPU前都有一级或多级高速缓存,从而减少了CPU直接同主内存交互的开销。这样的策略在单线程的环境下是没有问题的,但是当多个线程同时对主内存中的一个地址进行读写操作时,问题就产生了。因为CPU的缓存机制,CPU往往不能读取到最新写入主内存的数据,从而产生不可预期的结果。暂且,可以将之称之为内存不可见问题。

除此之外,现在计算机还会对指令进行优化排序,避免一些指令访问内存资源的同时占用CPU时间。这一行为导致很难预测代码真正的执行顺序。同时,现代计算机往往为多核CPU架构,多核可并行处理多条指令,这也让指令的执行顺序难以预测。我将其归结为指令乱序问题。

复杂的物理环境已经让指令的执行情况扑所迷离,然而还有更让人抓狂的JAVA的运行时环境。JAVA的内存模型,为了提高线程执行效率,每个线程都有自己的工作内存,在线程开始执行时从主内存提取需要的数据做备份,在线程执行结束时再回写主内存。这同样会引发内存不可见问题。而且,JIT编译器也会对指令进行重排序操作(即生成的机器指令和字节码指令顺序不一致),仅仅保证重排序的结果和代码本身的应有结果在单线程环境下是保持一致的,即同样会引发指令乱序的问题。

在内存不可见和指令乱序的情况下,并发执行的两个线程只要同时依赖一个内存区域的数据,对这一个内存区域进行读写操作,那么即会产生并发不安全的现象。一个值,可能在A线程中看到的是a,但可能在B线程中看到的就是b。

在JAVA环境下如何保证并发安全?

JAVA为工程师提供了便捷的底层同步操作工具,开发人员可以在代码层级进行同步控制,以保证运行时环境能够按照预期的方式执行多线程操作。例如Synchronized修饰符保证并发的两个线程之间指令不乱序执,同时保证Synchronized块中的读写操作不溢出(即保证在同步块出口之前,读写一定刷入主内存);volatile关键字,可以保证内存可见性(即读写操作都及时同步主内存),同时保证写volatile关键字之前的读写操作不溢出,读volatile关键字之后的读写操作不溢出;final关键字,可以保证final关键字的写操作,不溢出构造方法。这些便捷的同步工具,解决了JAVA运行时环境的内存不可见和指令重排序带来的并发安全问题。

另外,为了便于工程师分析java代码的并发问题,Java为工程师总结了关于Java内存模型规范的8条happens-before法则,虽然不能非常准确的描述实际微处理器等底层设备的工作情况,但从宏观层面可以视为Java程序一定会以happens-before原则执行,这样就可以依据这一偏序关系对一段java代码的并发安全性进行推导。下面是这8条法则的具体描述(happens-before法则是一种偏序关系,理解偏序关系对理解happens-before法则有一定的帮助):

  • 程序次序法则:在一个线程里,程序代码中每一个操作happens-before于程序代码中的后续操作。
  • 监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读操作。
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C

接下来,我们就里利用happens-before法则分析一下DCL为什么是不安全的。

DCL为什么是不安全的?

我们解释一开始DCL的问题。先看一段DCL的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Test {

private static Test a;

private int num = 0;

private Test(){
num = 6;
}

public static Test getInstance() {

if(a == null) {
synchronized(Test.class) {
if(null == a) {
a = new Test();
}
}
}

return a;
}

public int getNum(){
return num;
}

}

通过happens-before法则分析一下这段代码。

假设,去掉最外层的 if(a == null) , A线程优先获得锁资源,B线程在执行完Test.getInstance() 之后立刻执行了 getNum() 。我们用 “>” 号来表示 happens-before ,则:

  • 由程序次序法则,线程A:监视器加锁操作 > num = 6 > 监视器解锁操作。
  • 由监视器法则,线程A:监视器解锁操作 > 线程B:监视器加锁操作。
  • 由程序次序法则,线程B:监视器加锁操作 > getNum()
  • 由传递性,线程A: num = 6 > 线程B: getNum()

即如果没有外层的 if(a == null) ,则线程A创建的Test对象的属性 num = 6 可以被线程B正常读取。

问题出在 if(a == null) 外层的这行if判断上。当线程B在执行 if(a == null) 时 , 无法通过任何happens-before法则确定线程A的 a = new Test() 以及 num = 6 和线程B的 if(a == null) 以及 getNum() 具有happens-before关系,即有可能乱序执行。则当出现 a = new Test() , if(a == null) , getNum() , num = 6 的执行顺序时, getNum() 可能获得 num = 0 的结果。这个证明思路曾经很困扰我,因为事实上问题是由于a的赋值指令和 num = 6 可能出现乱序执行而引起的,而依照程序次序法则,貌似 num = 6 > a = new Test() 是必然成立的,也就不可能出现上面描述的次序。在仔细推敲了程序次序法则后,我给出的解释是: num = 6 > a = new Test() 在单个线程里是完全成立的,然后当这个运行时世界演变到两个线程时,情况有所不同。线程A可以观测到自己的 num = 6 > a = new Test() 是顺序的。但是对于线程A外部的线程B来说A的内部是混沌的。即只有线程内可以观测到程序次序法则,线程外观测不到。所以就有了 a = new Test() , if(a == null) , getNum() , num = 6 这样的执行顺序。这样的执行顺序在微观层面(现实运行时)是完全可能出现的,我们刚才的解释,只是为了让大家了解程序次序法则的分析原则——只有在单个线程内部可以做局部推导,而当涉及到两个线程之间的推论时,只有依赖监视器锁法则,volatile法则和传递性法则,才能最终确定A,B两个相互黑盒的线程之间指令的happens-before关系。

如何解决这个问题呢,我们给a属性加上volatile关键字,再分析一下看看。

  • 由程序次序法则,线程A: num = 6 > a = new Test()
  • 由程序次序法则,线程B: if(a == null) > getNum()
  • volatile变量法则,线程A: a = new Test() > 线程B:if(a == null)
  • 由传递性,线程A: num = 6 > a = new Test() > 线程B: if(null == a) > getNum()
  • 由以上的推到不能得出线程A的 if(a == null) 和线程B的 a = new Test() 之前的代码语句具备happens-before关系,所以,可能出现线程A的 if(a == null) ,线程B的 if(a == null) 同时执行的情况,这种情况下:
  • 由程序次序法则,线程A:监视器加锁操作 > num = 6 > 监视器解锁操作。
  • 由监视器法则,线程A:监视器解锁操作 > 线程B:监视器加锁操作。
  • 由程序次序法则,线程B:监视器加锁操作 > getNum()
  • 由传递性,线程A: num = 6 > 线程B: getNum()
  • 即线程A创建的Test对象的属性 num = 6 可以被线程B正常读取。

所以,结论即是,我们可以通过DCL和volatile的搭配使用,来保证DCL的并发安全。这里完全是通过happens-before原则进行的推导,实际上从程序指令执行的角度去进行分析也是完全可行的,这里有一篇blog对这个细节进行了细致的分析,这里引用一下,可供大家作为参考——Java内存访问重排序的研究