Java内存模型

重排序

  重排序是指编译器或处理器为了提高程序性能而对指令序列进行重新排序的一种手段。重排序可以导致操作延时或程序看似乱序执行,给程序运行的结果带来一定的不确定性。

  三类重排序:

    1)编译器的重排序:编译器在不改变单线程语义的前提下,生成的指令顺序可以与源代码不同。对Java来说,此处的编译器是指JIT即时编译器,即生成的机器指令与字节码指令顺序不一致。

    2)指令并行的重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。直接执行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待,可提高执行效率。

    3)内存系统的重排序:由于处理器使用缓存和读/写缓冲区,缓存可能会改变写入变量提交到主内存中的次序,使得加载和存储操作看上去是乱序执行。

  其中1)属于编译器级别的重排序,2)和3)属于处理器级别的重排序。

  as-if-seriaf语义

    数据依赖性:如果两个操作访问同一个变量,并且有一个操作为写操作(写后写、写后读、读后写),此时两个操作具有数据依赖性。

    数据依赖性是编译器和处理器判断能否进行重排序的重要依据。在单线程环境中,编译器和处理器不会对存在数据依赖关系的两个操作做重排序;但在多线程环境中,没有这种保证。

    单线程重排序示例:

1         int i = 1;            //A
2         int j = 2;            //B
3         int sum = i + j;        //C

    以A、B、C语句为例说明单线程重排序(实际应该是Java字节码或生成机器指令),A和C、B和C有数据依赖关系,所以A、B都不能和C重排序,但A和B可以进行重排序。有两种执行顺序:

      1)A->B->C  sum=3

      2)B->A->C  sum=3

    两种执行顺序的结果是相同的,即在单线程环境中,重排序不会影响程序的语义。

    as-if-seriaf语义:不管编译器和处理器怎么重排序,单线程中程序执行的结果不能被改变。由于执行结果不变,程序员感觉单线程程序好像是顺序执行的。

    Java编译器、处理器都会保证单线程下的as-if-serial语义。所以程序员不用关心在单线程中由于重排序导致的程序语义的不确定性,即在单个线程中的变量(局部变量肯定是单线程)不存在线程安全问题。

    注意:但当一个变量被多个线程共享时,需要通过JMM提供的同步手段来实现程序语义的正确性。

happens-before原则

    多线程重排序代码示例:

 1         public class Reordering {
 2             static int x = 0, y = 0;
 3             static int a = 0, b = 0;
 4             public static void main(String[] args) throws InterruptedException {
 5                 Thread one = new Thread(new Runnable() {
 6                     public void run() {
 7                         a = 1;    //A
 8                         x = b;    //B
 9                     }
10                 });
11                 Thread other = new Thread(new Runnable() {
12                     public void run() {
13                         b = 1;    //C
14                         y = a;    //D
15                     }
16                 });
17                 one.start();other.start();
18                 one.join();other.join();
19                 System.out.println("(" + x + "," + y + ")");
20             }  
21         }

     通过两个线程语句的交替执行很容易判断出可能的结果有(1,0)、(0,1)或(1,1)。实际上可能的结果还有(0,0),以上3种类型的重排序都有可能导致该结果。四条语句可能的执行次序为B->C->D-A,此时结果为(0,0),1)和2)的重排序都可以导致该结果。还有一种情况,执行次序A->B、C->D,A和C执行后,变量值存入本地缓存,并没有刷到主内存,B和D执行时读不到A和C存入的值,即A操作结果对D不可见、C操作结果对B不可见,此时结果也为(0,0),3)的重排序可以导致该情况。

    这种问题怎么解决呢?JMM为程序员提供了友好的方法(happens-before原则)来解决多线程环境中重排序引起的问题。

    happens-before原则

      1)程序顺序原则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。

      2)监视器锁原则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。

      3)volatile变量原则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。

      4)线程启动原则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。

      5)线程终结原则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。

      6)中断原则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。

      7)终结原则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。

      8)传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C。

    happens-before原则是面向开发人员的,每个happens-before原则对应于一个或多个编译器重排序和处理器重排序规则。这样开发人员只需要理解happens-before原则,而不用根据重排序规则实现内存可见性。

    JMM的设计基于两个方面的考虑:

      1)从使用角度考虑,开发人员基于happens-before规则提供的内存可见性保证进行编程,易于理解。JMM向开发人员的保证:如果 A happens-before B,那么A的操作将对B可见,且A的执行顺序排在B之前(但实际执行时,A可能在B之后)。

      2)从性能方面考虑,只要不改变程序结果的重排序(指单线程程序或正确同步的多线程程序),JMM允许编译器和处理器的任何优化。

    as-if-seriaf和happens-before的对比

      1)相同点:两者的目的都是在不改变程序语义的前提下,尽可能的提高程序执行的并行度。

      2)不同点:as-if-seriaf保证在单线程程序中执行的结果不变;happens-before保证在正确同步的多线程程序中执行结果不变。

     JMM是怎么实现以上规则的内存可见性的呢?JMM通过编译器重排序规则会禁止特定类型的编译器重排序;通过在指令序列的适当位置插入内存屏障指令(Memory Barriers)禁止特定类型的处理器重排序。

内存屏障(Memory Barriers)

  内存屏障是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。

  四种内存屏障指令:

    1)LoadLoad:load1 LoadLoad load2 确保load1读取的数据被读取完毕先于load2及后续所有读取指令的读取。

    2)StoreStore:store1 StoreStore store2 确保store1数据对其他处理器可见(刷新到内存)先于store2及所有后续存储指令的存储。

    3)LoadStore:load1 LoadStore store2 确保load1的数据读取完毕先于store2及所有后续存储指令刷新到内存。

    4)StoreLoad:store1 StoreLoad load2 确保store1数据对其他处理器可见(刷新到内存)先于load2及后续所有读取指令的读取,StoreLoad会使该屏障之前的所有内存访问指令(存储和读取指令)完成之后,才执行该屏障之后的内存访问指令。StoreLoad屏障同时具有其他三种屏障的效果。相对的,执行该屏障的开销是最大的,因为当前处理器通常要把写缓冲区的数据全部刷新到内存中。

volatile

  特性:

    1)可见性:对一个volatile变量的读,总能看到任意线程对这个volatile变量最后的写入;

    2)原子性:对任意单个volatile变量的读/写具有原子性(即使是64位的long/double型变量),但对复合操作(如count++)不具有原子性。

  内存语义:

    从内存语义的角度来讲,volatile的写-读与锁的释放-获取有相同的内存语义。

    当写一个volatile变量时,JMM会把线程对应的本地内存中的共享变量值刷新到主内存(本地内存中的所有共享变量的值都刷新到主内存)。

    当读一个volatile变量时,JMM会把线程对应的本地内存置为无效(即本地内存中的所有共享变量的值都将无效),从主内存中重新加载共享变量。

    代码示例:

 1         class Test {
 2             int count = 0;
 3             volatile boolean flag = false;    //用volatile关键词修饰
 4             public void write() {
 5                 count = 1;        //1
 6                 flag = true;        //2
 7             }
 8             public void read() {
 9                 if(flag) {        //3
10                     count++;    //4
11                 }
12             }
13         }

    根据happens-before原则中的程序顺序规则,有1 happens-before 2、3 happens-before 4。根据volatile规则,有2 happens-before 3。根据传递性,1 happens-before 4。注意此时是1和2是不能重排序的,3和4同样不能重排序。假设线程A先执行write方法,线程B后执行read方法,volatile的内存语义能保证线程B一定能看到线程A对count变量的更改。

  内存语义的实现原理:

    JMM针对编译器制定的volatile重排序规则表如下:

  后一个操作
  前一个操作   普通读/写 volatile读 volatile写
普通读/写     N
volatile读 N N N
volatile写   N N

    N表示不可重排序,可以看出:

      1)当后一个操作是volatile写时,不管前一个操作是什么,都不能重排序,确保在volatile写之前的操作不会被编译器重排序到volatile写之后。

      2)当前一个操作是volatile读时,不管第二个操作是什么,都不能重排序,确保volatile读之后的操作不会被编译器重排序到volatile读之前。

      3)当前一个操作是volatile写,后一个操作是volatile读时,不能重排序。

    编译器在指令序列中插入内存屏障来禁止特定类型的重排序。

    基于保守策略内存屏障的插入策略:

      1)在每个volatile写操作的前面插入StoreStore屏障:禁止前面的普通写与volatile写重排序。能保证volatile写之前的所有普通写操作已经对所有处理器可见了。

      2)在每个volatile写操作的后面插入StoreLoad屏障:禁止volatile写与后面可能的volatile读/写重排序。

      3)在每个volatile读操作的后面插入LoadLoad屏障:禁止后面的所有普通读操作与volatile读重排序。

      4)在每个volatile读操作的后面插入LoadStore屏障:禁止后面的所有普通写操作与volatile读重排序。

  什么情况下不能使用volatile关键字?

    对于volatile关键字,当且仅当满足以下所有条件时可使用:

      1)对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值,例如i++;

      2)该变量没有包含在具有其他变量的不变式中。

    第一个条件很好理解,对第二个条件怎么理解呢?

      举个例子,代码是一个非线程安全的数值范围类,它包含了一个不变式:下界总是小于或等于上界。

 1                 @NotThreadSafe 
 2                 public class NumberRange {
 3                     private int lower, upper;
 4 
 5                     public int getLower() { return lower; }
 6                     public int getUpper() { return upper; }
 7 
 8                     public void setLower(int value) { 
 9                         if (value > upper) 
10                             throw new IllegalArgumentException(...);
11                         lower = value;
12                     }
13 
14                     public void setUpper(int value) { 
15                         if (value < lower) 
16                             throw new IllegalArgumentException(...);
17                         upper = value;
18                     }
19                 }

 

 

 

      这种方式限制了范围的状态变量,因此将lower和upper字段定义为volatile类型不能够充分实现类的线程安全;从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行setLower和setUpper的话,则会使范围处于不一致的状态。例如,如果初始状态是(0,5),同一时间内,线程A调用setLower(4)并且线程B调用setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是(4,3)是个无效值。至于针对范围的其他操作,我们需要使setLower()和setUpper()操作原子化,而将字段定义为volatile类型是无法实现这一目的的。

  volatile 和 synchronized 的对比

    Java语言包含两种内在的同步机制:synchronized同步块(或方法)和 volatile 变量。

      1)synchronized有互斥和可见性两种特性,是一种“重量级”同步机制;volatile有可见性,是一种“轻量级”同步机制;

      2)synchronized可以用在变量、方法、类级别;volatile只能修饰变量;

      3)synchronized可能造成线程阻塞;volatile不会阻塞线程。

    在目前大多数的处理器架构上,volatile读操作开销非常低,几乎和非volatile读操作一样。而volatile写操作的开销要比非volatile写操作多很多,因为要保证可见性需要实现内存界定(Memory Fence),即便如此,volatile的总开销仍然要比锁获取低。volatile操作不会像锁一样造成阻塞,因此,在能够安全使用volatile的情况下,volatile可以提供一些优于锁的可伸缩特性。如果读操作的次数要远远超过写操作,与锁相比,volatile变量通常能够减少同步的性能开销。ConcurrentLinkedQueue的作者DougLea设计hops变量,就是通过增加对volatile变量的读来减少对volatile变量的写,以实现入队和出队效率的提升。

final域  

  内存语义:

    1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,两个操作不能重排序;

    2)初次读一个包含final域的对象的引用,与随后初次读这个对象的final域,两个操作不能重排序。

    3)当Final为引用类型时,增加如下限制:在构造函数内对一个final引用对象的成员域的写入,与随后在构造方法外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(数组示例)

  对象引用“逸出”问题:

    在构造函数内部,不能让被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。

  增强之后的final语义:只要对象是正确构造的(被构造的对象引用没有在构造函数中“逸出”),那么不需要使用同步(lock和volatile)就可以保证任意线程都能看到这个final域在构造函数中被初始化后的值。

 

  内存语义的实现原理:

    为了保证final字段的特殊语义,也会在下面的语句加入内存屏障。

    x.finalField = v; StoreStore; sharedRef = x;

双重检查锁定模型

  双重检查锁定代码:

 1     public class DoubleCheckLock {
 2         private static DoubleCheckLock instance;
 3         public static DoubleCheckLock getInstance() {
 4         if(instance == null) {                          //1、第一次检查
 5             synchronized (DoubleCheckLock.class) {      //2、加锁
 6             if(instance == null) {                  //3、第二次检查
 7                 instance = new DoubleCheckLock();   //4、新建实例
 8             }
 9             }
10         }
11         return instance;
12         }
13     }

 

  存在的问题:

    第4步新建实例可以分为以下三步:1)分配对象的内存空间、2)初始化实例、3)把instance指向内存空间。

    其中2)和3)可以重排序。注意这并不违反单线程执行结果不改变的原则,假设4)为使用instance,只要保证2)和4)不做重排序就能保证单线程执行结果不变。这样就有可能导致另一个线程在检查instance不为null时,使用一个未完成初始化的对象。

  解决方案一:禁止2)和3)重排序,只需将instance设为volatile变量。

  解决方案二:让其他线程无法看到2)和3)的重排序,利用类初始化实现延迟加载。

        原理:在Class被加载后,且被线程使用之前,JVM会执行类的初始化。JVM执行类的初始化时会去获取一个锁,这个锁同步多个线程同时对一个类初始化 。

        代码:

1             class Singleton {
2                 private class SingletonHolder {
3                     public static Singleton instance = new Singleton();
4                 }
5                 public Singleton getInstance() {
6                     return SingletonHolder.instance; //类SingletonHolder初始化,由类初始化时的同步机制保证不会创建多个实例。
7                 }
8             }

        类的初始化介绍(详细请查阅JVM相关书籍):

          类的初始化包括:类的静态初始化和类的静态字段的初始化。

          什么时候触发类的初始化?1)首次创建一个该类的实例时;2)首次调用该类中的静态方法时;3)首次为类或接口中的静态域赋值时;4)首次使用类或接口的静态域时(前提静态域不能由final修饰);

          多线程并发初始化一个类或接口时,怎么保证同步?初始化锁:每个类或接口都有一个对应的初始化锁LC,JVM执行类的初始化时会去获取这个初始化锁。

  两种解决方案的对比:

    1)基于类初始化的方法代码更简洁,但只能对静态域延迟初始化。

    2)基于volatile的双重检查锁的方法对静态域和实例域都可以。

  

参考资料

  《Java内存访问重排序的研究》https://tech.meituan.com/java-memory-reordering.html  

  《java并发编程的艺术》

  《就是要你懂Java中volatile关键字实现原理》https://www.cnblogs.com/xrq730/p/7048693.html

  

 

posted @ 2017-11-03 15:42  在周末  阅读(371)  评论(0编辑  收藏  举报