从双重校验单例完全理解Java指令重排

96
楚云之南
0.5 2017.11.02 16:34* 字数 2775

指令重排你有听说过吗?我相信对于大部分Java程序员来说这个词都不陌生,很多人第一次接触到这个概念都是通过经典的双重校验单例来了解的,但是网上很对多关于重排序讲解通常过于混乱,既掺和了Java内存模型,同时又掺和了操作系统和硬件架构,看完之后只能给读者带来更多的困惑,所以我们今天来通俗的聊聊指令重排序,希望以正视听吧。

对于Java程序员来说,很多人对于内存模型的困惑也通常来自于混淆虚拟内存模型规范和硬件内存模型。

一 重排序的概念

首先来看一下一个Java程序的执行过程。Java源代码首先会被编译器进行编译,生成class字节码,然后被JVM执行引擎加载,读取class中的指令进行解析成本地指令,最终在CPU执行。在这个过程中,为了提高程序运行效率,至少会在两个阶段对指令做重排。

1.1 编译期重排序

编译期重排序的典型就是通过调整指令顺序,做到在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。
比如我们有如下代码:

int x = 10;
int y = 9;
x = x+10;

假设编译器直接对上面代码进行编译,不进行重排序的话,我们简单分析一下执行这段代码的过程,首先加载x变量的内存地址到地址寄存器,然后会加载10到数据寄存器,然后CPU通过mov指令把10写入到地址寄存器中指定的内存地址中。然后加载y变量的内存地址到地址寄存器,加载9到数据寄存器,把9写入到内存地址中。进行第三行执行时,我们发现CPU需要重新加载x的内存地址和数据到寄存器,但如果我把第三行和第二行换一下顺序,那么执行过程中对于寄存器的存取就可以少很多次,同时对于程序结果没有任何影响。

1.2 运行期重排序

现代CPU执行指令早已经过了一条一条执行的时期,原因是串行执行效率太低,
取指令1 -->> 执行指令1 -->> 取指令2 -->> 执行指令2 -->> 取指令3 -->> 执行指令3 -->> ......
为了改变这种情况,CPU把取值令、翻译指令和执行指令放到不同的元器件中执行,这样的话在一个CPU时钟里面,比如指令1在执行阶段,那么就可以开始进行指令2的取指令操作了,这就是典型的三级流水。由于指令之间有可能存在依赖,CPU为了进一步提高自己的吞吐率,提出了指令乱序执行的模式,具体的资料可以戳这里。总之,指令到达CPU之后,还有可能被CPU乱序执行。

这里还是不可避免的提到了CPU寄存器这种偏实际计算机架构的元素,它们的出现只是为了说明你的指令在各个阶段都有可能被乱序。

1.3 as-if-serial语义

读完前面之后,很多人都震精了,代码随随便便就被重新排序了,那我的代码还会正确么?在Java中存在一个as-if-serial语义:所有的动作都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的,Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。
举个栗子:

int x  = 1;    //1
int y   = 2;     //2
int c = x+y;     //3

这三行代码中,1、23存在依赖关系,但是12之间没有任何关系,所以12可以被重新排序。遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

注意as-if-serial的限定条件是单线程

1.4 时间顺序和语义顺序

很多同学理解指令重排序不明白通常只有一个原因,就是没有区分Java虚拟机层面的指令和物理机层面的指令。我们前面讨论的大部分指令顺序都是基于绝对时间顺序(物理机层面),而作为Java程序员,本文需要讨论的是Java内存语义顺序规则,俗称happen-before规则,它定义了Java中两项内存操作的偏序语义,即如果A happen-before B,那么A所能看到的内存操作对于B都是可见的,那么就操作A的顺序就在B的前面。Java内存模型中关于happen-before的规定有8条:

  • 程序次序规则:单线程中先执行的每个动作Ahappens-before于该线程后执行每一个动作B。【同一个线程的操作】

  • 监视器锁规则:对同一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。【可以多个线程】
    *volatile变量规则:同一个volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。【可以多个线程】

      //省略4个不常用的
    
  • 传递性:如果A happens-beforeB,且B happens-beforeC,则A happens-beforeC。【可以多个线程】

为了更好的说明问题,来个栗子来说明 时间顺序和语义顺序的区别:

栗子1:满足语义顺序,不满足时间顺序

int x  = 1;    //1
int y   = 2;     //2

同一个线程执行上面两个指令,根据happens-before第一条,1 happens-before 2,但是在时间上,12可以重排序,它们的执行顺序可以是2 ->1。
栗子2:满足时间顺序,不满足语义顺序

private int value = 0;

public int get(){
    return value;
}
public void set(int value){
    this.value = value;
}

线程1在时间上先调用了set方法,线程2后调用了get方法,那这两个操作之间并不满足任何语义顺序。

总结:在Java虚拟机层面所谓的指令重排序都是内存可见性的问题,所谓A在B前面,其实说的是A所做的内存操作对于B都可见,而并不在乎这两个操作的CPU执行顺序。ok,理解到这里,我觉得你对于指令重排的理解已经升级了。

二 双重校验分析

下面直奔主题,先来一个没有修正的DCL(double-checked locking)的代码如下:

public class DoubleCheckedLocking {                  //1
  private static DoubleCheckedLocking instance;                  //2
  public static DoubleCheckedLocking getInstance() {             //3
    if (instance == null) {                          //4:第一次检查
        synchronized (DoubleCheckedLocking.class) {  //5:加锁
            if (instance == null)                    //6:第二次检查
                instance = new DoubleCheckedLocking();           //7:问题的根源出在这里
        }                                            //8
    }                                                //9
    return instance;                                 //10
}                                                    //11
}                                                    //12

当然,如果你之前有阅读过这方面的博客,很容易就能知道,在//2的位置没有使用volatileinstance进行声明,那么就有可能导致DoubleCheckedLocking未构建好的对象逸出(注意不是溢出...)。

通常来说,很多博客就会这样来分析,由于instance = new DoubleCheckedLocking(),这段代码会被分成多个CPU指令,我们简化成三大步骤:

(1) 分配`DoubleCheckedLocking`类实例需要的内存空间
(2) 通过构造函数对内存空间进行初始化
(3) 将内存空间地址赋值给instance对象

由于CPU和编译器在执行指令时有可能乱序执行(2)(3)如果乱序了,那么有可能会产生一个没有构建完成的对象溢出了,然后你就会问他了,为什么加了volatile就好了呢?他一定会告诉你,加了volatile,对于变量的操作就不可以重排序,(2)操作一定会在(3)之前执行(时间上)。

然而,这种说法其实并不准确,volatile重排序指的是Java内存语义上的顺序,并不是时间上的执行顺序,我们把这个栗子补充一下:

public class DoubleCheckedLocking {                  

  public boolean inited;  //(1)
  private DoubleCheckedLocking() {
    this.inited = true;  //(2)
  }
  private static volatile DoubleCheckedLocking instance;            
  public static DoubleCheckedLocking getInstance() {     
    if (instance == null) {                          //(3)
        synchronized (DoubleCheckedLocking.class) {  
            if (instance == null)        
                //分配内存空间(4)
                //初始化内存空间(5)
                //给instance赋值   //(6)
        }                             
    }                              
    return instance;                            
}                                                   
}      

你可以看到,我往单例类定义中加入了一个属性代码类是否已经实例化完毕,instance也已经使用了volatile来修饰,创建类实例的位置已经用描述分成了三个步骤。

现在再来分析对象溢出的场景,即一个线程A执行了(6),线程B(3)处判断instance不为空,然后它就获取对象去使用了。那么这个时候是不是还会存在之前的类没有被初始化就溢出了呢?

从内存模型角度来说,线程B拿到了一个没有初始化成功的类实例引用,其实说的是线程B没有看到构造函数里面对于类实例构造的内存操作,比如这个栗子里面的inited域。

根据happen-before第一条,线程A执行了(6),(6) happen-before(4)(5),即在A线程执行(6)操作的时候肯定能看到(4)和(5)的内存操作,这里其实就是类构造器的初始化。由于线程B(3)处判断instance不为空,说明(6)已经执行了写操作,根据heppen-before第三条,(6) heppen-before (3),那么线程B在执行(3)后,必然能看到(6)的所有内存操作,那么肯定就可以看到(4)(5)对内存的操作,所以此时B获得的对象中域inited的值肯定是true;

你可以试着再去分析一下没有volatile加持时,这个对象就不能保证能够看到构造函数中的内存操作。

总结

JVM内存模型中,指令顺序其实值得就是内存是否可见的顺序,而并不是真正指令执行的时间顺序,本文尽量去避免列举过多涉及到操作系统、硬件的概念以免混淆视听,比如有很多人喜欢将内存屏障和重排序放到一块讲,其实内存屏障仅仅是某些JVM在某些系统上的实现,而并不是JVM规范层的东西,很容易对读者的理解带来偏差。

Java基础