理解Java内存模型
2016 年 01 月 10 日
java

    Java Memory Model(Java内存模型,简称JMM),作为Java语言规范的一部分(主要在JLS的第17章节介绍),其定义了多线程之间如何通过内存进行交互,在旧的JMM中,存在一些不够明确过于限制的问题,比如对finalvolatile等关键字的语义约束问题,例如,有可能出现final字段的值会发生变化,或者阻止编译器的优化操作,还有比较熟知的double-checked问题,于是在新的JMM中,针对这一系列问题作出了修订,最终在JSR133中进行了详细描述,本文将对Java内存模型作一番理解。

  • 什么是Java内存模型(Java Memory Model)

  • 在多核系统中,处理器通常会有一层或多层缓存,通过这些缓存可以加快数据访问(缓存数据距处理器更近)和降低共享内存总线上的通讯(因为本地缓存能够满足大多数内存操作)来提高CPU性能。缓存能够大大提升性能,但这同时也带来了许多挑战。例如,当两个CPU同时检查相同的内存地址时会发生什么?在什么样的条件下它们会看到相同的值?

    在处理器层面上,JMM定义了一个充要条件: 让当前的处理器可以看到其他处理器写入到内存的数据以及其他处理器可以看到当前处理器写入到内存的数据。有些处理器具有强一致性内存模型,能够让所有的处理器在任何时候任何指定的内存地址上都可以看到完全相同的值。而另外一些处理器则具有弱一致性内存模型,在这种处理器中,必须使用内存屏障(一种特殊的硬件级别指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作或者让其他处理器能看到当前处理器的写操作。这些内存屏障通常在机器指令中的lockunlock操作的时候完成。内存屏障在高级语言中对程序员是不可见的。

    JMM描述了在多线程中哪些行为是合法的,以及线程间如何通过内存进行交互。它描述了程序中的变量从内存或者寄存器加载或存储它们的底层细节之间的关系,并通过使用各种硬件和编译器的优化(如重排序高速缓存机器指令交错执行等)来正确实现以上事情,比如从Java语言层面上,我们可以通过volatile, final以及synchronized关键字,Happens-Before关系等保证同步的java程序在所有的处理器架构下面都能正确的运行。

    JMM规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见JMM在设计时就在可预测性和程序的易于开发性之间进行了权衡,从而在各种主流的处理器体系架构上能实现高性能的JMM

  • JMM中并发程序面临的问题

  • 重排序

  • 在很多情况下,访问一个程序变量(对象实例字段类静态字段数组元素)可能会使用不同的顺序执行,而不是程序语义所指定的顺序执行。编译器能够自由的以优化的名义去改变指令顺序。在特定的环境下,处理器可能会次序颠倒的执行指令。数据可能在寄存器处理器缓冲区主内存中以不同的次序移动,而不是按照程序指定的顺序。

    例如,如果一个线程写入值到字段a,然后写入值到字段b,而且b的值不依赖于a的值,那么,处理器就能够自由的调整它们的执行顺序,而且缓冲区能够在a之前刷新b的值到主内存。有许多潜在的重排序的来源,例如编译器JIT以及缓冲区

    Class Reordering {
    	
    	int x = 0, y = 0;
    
    	public void writer() {
    		x = 1;
    		y = 2;
    	}
    
    	public void reader() {
    		int r1 = y;
    		int r2 = x;
    	}
    }
        

    如上面的代码片段,读取y变量将会得到2这个值,从程序顺序直观上看,因为这个写入比写到x变量更晚一些,程序员可能认为读取x变量将肯定会得到1。但是,写入操作可能被重排序过。如果重排序发生了,那么,就能发生对y变量的写入操作,读取两个变量的操作紧随其后,然后写入x,程序的结果可能是r1=2,r2=0

    重排序大致可以分为三种类型:
        1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
        2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
        3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

  • 内存可见性

  • 内存可见性指的是: 一个线程执行的结果对另一个线程是可见的,这里需要关心的结果有,写入的字段以及读取这个字段所看到的值。Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

    从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存写缓冲区寄存器以及其他的硬件和编译器优化

  • 原子性

  • JMM中,除了long型字段和double型字段外,访问任意类型字段所对应的内存单元都是原子的(包括引用类型的字段),这些规则不仅适用于实例变量静态变量,也包括数组元素,但不包括方法中的局部变量的内存单元的简单读写操作。此外,volatile longvolatile double也具有原子性。(虽然JMM不保证non-volatile longnon-volatile double的原子性,当然它们在某些场合也具有原子性,如non-volatile long在64位JVM,OS,CPU下具有原子性)。

    原子性可以确保你将获得这个字段的初始值或者某个线程对这个字段写入之后的值;但不会是两个或更多线程在同一时间对这个字段写入之后产生混乱的结果值(即原子性可以确保,获取到的结果值所对应的所有bit位,全部都是由单个线程写入的)。原子性不能确保你获得的是任意线程写入之后的最新值。 因此,原子性保证通常对并发程序设计的影响很小。

  • 如何解决JMM中并发程序所面临的问题

  • 内存屏障(Memory Barrier)

  • 内存屏障,也称内存栅栏内存栅障屏障指令等, 是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作

  • 内存屏障指令类型

  • 为了保证内存可见性,java编译器在生成指令序列的适当位置会插入内存屏障指令禁止特定类型的处理器重排序JMM内存屏障指令分为下列四类:

    屏障类型 指令示例 描述
    LoadLoad Load1; LoadLoad; Load2 确保Load1数据的加载,先于Load2及所有后续加载指令的加载。
    StoreStore Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(会刷新到主内存,并通知其他CPU缓存对应缓存行失效),先于Store2及所有后续存储指令的存储。
    LoadStore Load1; LoadStore; Store2 确保Load1数据加载,先于Store2及所有后续的存储指令刷新到内存。
    StoreLoad Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(会刷新到主内存,并通知其他CPU缓存对应缓存行失效),先于Load2及所有后续加载指令的加载。StoreLoad屏障会使该屏障之前的所有内存访问指令(存储和加载指令)完成之后,才执行该屏障之后的内存访问指令。

    StoreLoad是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到主内存中

  • 常见处理器针对不同内存屏障是否允许重排序

  • 处理器/屏障类型 Load-Load Load-Store Store-Store Store-Load 数据依赖
    sparc-TSO N N N Y N
    x86 N N N Y N
    ia64 Y Y Y Y N
    PowerPC Y Y Y Y N

    上表单元格中的“N”表示处理器不允许两个操作重排序"Y"表示允许重排序。常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO和x86拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。

  • Happens-Before

  • JMM为程序中所有的操作定义了一个偏序关系,叫做Happens-Before,简称hb。要保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行,这里看到结果,即内存可见),那么在A和B之间必须满足Happens-Before关系,那么JVM可以对它们任意地重排序JMM也规范了一些Happens-Before规则:

    1. 程序顺序规则: 同一线程内,前面的动作hb后面的动作。
    2. 管程锁定规则: 对于同一个锁,unlock()操作hb下一次lock()操作。
    3. volatile变量规则: volatile变量的写操作hb之后的读操作。
    4. 线程启动规则: 同一线程的start()方法hb其他方法。
    5. 线程终止规则: 同一线程的任何方法都hb对此线程的终止检测。如Thread.join()Thread.isAlive()等。
    6. 线程中断规则: 对线程interrupt()方法的调用hb 发生于被中断线程的代码检测到中断事件发生。
    7. 对象终结规则: 一个对象的初始化完成hb该对象的finalize()方法。
    8. 传递性: A操作hb操作B,操作Bhb操作C,那么A操作hb操作C。

  • 数据依赖性

  • 如果两个操作访问同一个变量,且其中一个为写操作,此时这两个操作之间就存在数据依赖性,存在数据依赖性的操作是不能被重排序的(针对单个处理器中执行的指令序列和单个线程中执行的操作),数据依赖分下列三种类型:

    操作顺序 代码示例 描述
    写后读 a = 1;b = a; 写一个变量之后,再读这个变量
    写后写 a = 1;a = 2; 写一个变量之后,再写这个变量
    读后写 a = b;b = 1; 读一个变量之后,再写这个变量
  • as-if-serial语义

  • as-if-serial语义是指:不管如何重排序,(单线程)程序的执行结果不能被改变。编译器运行时处理器都必须遵守as-if-serial语义

    double pi  = 3.14;    		//A
    double r   = 1.0;     		//B
    double area = pi * r * r; 	//C
        

    如上述代码片段,A,B操作并没有数据依赖,可以被重排序,C与A和B均存在数据依赖,因此不能被重排序

    as-if-serial语义单线程程序保护了起来,遵守as-if-serial语义编译器运行时处理器共同为编写单线程程序的程序员一种错觉:单线程程序是按程序的顺序来执行的。但实际执行时却有可能发生了重排序,正是as-if-serial语义使程序员无需担心单线程重排序的问题,也无需担心单线程的内存可见性问题

  • 程序顺序规则

  • 程序顺序规则属于上述Happens-Before规则中的第一条,即同一线程内,前面的动作hb后面的动作,那这是否说明这些动作就不能被重排序呢?不见得,如上面的代码片段,A hb B,但JMM并不要求A一定要在B之前执行,JMM仅仅要求前一个操作对后一个操作内存可见(即如果B操作执行完,Happens-Before能保证A的值已经写入了主内存),但是这里操作A的执行结果不需要对操作B可见,而且重排序操作A和操作B后的执行结果,与操作A和操作B按Happens-Before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法,JMM允许这种重排序

  • 同步(synchronized)

  • 同步最广为人知的作用就是互斥 —— 一次只有一个线程能够获得一个监视器(monitor),因此,在一个监视器上面同步意味着一旦一个线程进入到监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。

    同步保证了一个线程在同步块之前或者在同步块中的一个内存写入操作对其他具有相同监视器的线程内存可见。当我们退出了同步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程可见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主内存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了。

    synchronized(this) {
      i = a;
      a = i;
    }
        

    像上面的代码片段,在处理器执行时,在边界处放置一些额外的指令来保证synchronized的正确性,如:

    enter
       EnterLoad
       EnterStore
    load a
    store a
       LoadExit
       StoreExit
    exit
        
  • final变量

  • final变量内存语义

  • final变量的值不会变化。编译器不应该因为获得了一个锁,读取了一个volatile变量或调用了一个未知方法,而重新加载一个final变量。事实上,编译器可以将线程t中对对象X的final变量f的读取提前到紧跟在读取对象X的引用之后,该线程永远也不需要重新加载那个字段。

    一个对象包含final变量,且被安全发布(意味着这个正在构造的对象的引用在构造期间没有逸出),那么该对象也应当视作是不可变的,即使这类对象的引用在线程间传递时存在数据争用

    一个对象的final变量值是在它的构造方法里面设置的。假设对象被正确的构造了(意味着这个正在构造的对象的引用在构造期间没有被允许逸出),在构造方法里面设置给final变量的的值在没有同步的情况下对所有其他的线程都会可见。另外,引用这些final变量的对象或数组都将会看到final字段的最新值。

    将变量f设为final,在读取f时应当利用最小的编译器/架构代价,将对程序性能有所提升。

  • final变量更新

  • 尽管一旦对象的final变量被正确初始化后就不能改变,但在某些特殊场景下(反射反序列化),对象的final变量仍然可以发生被更新,但需保证在该对象的final字段的所有更新完成之前,该对象不应该对其他线程可见,且 final变量也不应该被读取。

    静态final变量仅能在类初始化时赋值,不能通过反射修改静态final变量只能在定义字段的类的初始化器中修改,java.lang.System.injava.lang.System.out以及java.lang.System.err是例外,他们可以分别在java.lang.System.setInjava.lang.System.setOut以及java.lang.System.setErr方法中修改。

  • final变量的重排序限制

  • JMM中,final变量加强了一些重排序限制:

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

    构造函数内,对象的final变量的写操作不能与该对象后续的写操作(构造函数之外)重排序,其中...代表的是构造函数的逻辑终点边界(处理器会在该处放置合适的内存屏障指令)。同时,也不能将具有final字段的对象的引用的赋值操作该对象的final字段的写操作进行重排序,如:

    v.afield = 1; x.finalField = v; ... ; sharedRef = x;
        

    final变量的初始化加载操作包含该final变量的对象的初始化加载操作不能被重排序,如:

     x = sharedRef; ... ; i = x.finalField;
        
  • volatile变量

  • volatile变量内存语义

  • volatile变量是用于多线程间通信的特殊字段,volatile变量能保证读线程都会看到其它线程写入该字段的最新值,编译器运行时禁止在寄存器里面分配它们。它们还必须保证,在它们写好之后,它们被从缓冲区刷新到主存中,因此,它们立即能够对其他线程可见。相同地,在读取一个volatile变量之前,缓冲区必须失效,因为值是存在于主存中而不是本地处理器缓冲区,有了这样的内存语义,常用volatile变量用作一种信号条件,如:

    class VolatileExample {
    	int x = 0;
    	volatile boolean v = false;
    	
    	public void writer() {
    		x = 42;		// A
    		v = true;	// B
    	}
    
    	public void reader() {
    		if (v == true) {
    		  	// 通过volatile内存语义保证x的值能被实时读取
    		}
    	}
    }
        

    但若要上面的代码能正确工作,还需要的是volatile变量重排序限制,若操作A操作B重排序了,该代码仍不能正确工作。

  • volatile变量的重排序限制

  • 旧的内存模型下,访问volatile变量不能被重排序,但是,它们可能和访问非volatile变量一起被重排序,这就破坏了volatile变量从一个线程到另外一个线程作为一个信号条件的手段(如上面的代码)。

    新的内存模型下,volatile变量仍然不能彼此重排序,和旧模型不同的时候,volatile变量周围的普通字段的也不再能够随便的重排序了。写入一个volatile变量释放监视器有相同的内存语义,而且读取volatile变量获取监视器也有相同的内存语义。事实上,因为新的内存模型在重排序volatile变量访问上面和其他字段(volatile或者非volatile)访问上面有了更严格的约束。当线程A写入一个volatile变量f的时候,如果线程B读取f的话 ,那么对线程A可见的任何东西都变得对线程B可见了。下表则是volatile变量释放监视器重排序限制:

    是否允许重排序? 第二项操作
    第一项操作 NormalLoad/NormalStore VolatileLoad/MonitorEnter VolatileStore/MonitorExit
    NormalLoad/NormalStore N
    VolatileLoad/MonitorEnter N N N
    VolatileStore/MonitorExit N N

    有了表中的重排序限制,上面的VolatileExample才得以正确工作。在新的JMM中,比较熟悉的DCL问题也因volatile变量能正确工作。那是否就是说volatile变量就线程安全呢?并不见得。

    // 实例变量
    volatile a = 1;
    // ...
    // +1操作
    a++;
        

    上述中变量a声明为volatile,在多线程环境下执行a++,众所周知,即便a++这样的操作至少也隐含了几个操作:

     load a;    // A
     a = a + 1; // B
     store a;   // C
        

    volatile变量的内存语义仅保证了操作C的值可以被后续读变量a的线程所看见,但却没有保证操作A,B,C的并发执行,因此上述的代码片段也存在线程安全问题

  • 总结

  • 上文则是对Java内存模型的一些总结描述,新的Java内存模型通过进一步加强内存语义,对finalvolatile等变量的语义进行修复,为Java并发编程提供了更稳定的运行时保障,同时,也使得开发人员能稍微轻松些进行并发程序开发。

  • 参考文献

  • http://www.cs.umd.edu/~pugh/java/memoryModel
    https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
    http://gee.cs.oswego.edu/dl/jmm/cookbook.html
    http://ifeve.com/jmm-faq/
    https://en.wikipedia.org/wiki/Java_memory_model
    http://tutorials.jenkov.com/java-concurrency/java-memory-model.html
    https://dzone.com/articles/java-memory-model-programer's
    http://www.ibm.com/developerworks/java/library/j-jtp02244/index.html>
    http://www.ibm.com/developerworks/library/j-jtp03304
    http://www.infoq.com/cn/articles/java-memory-model-1
    http://www.infoq.com/cn/articles/java-memory-model-2
    https://zh.wikipedia.org/wiki/%E5%86%85%E5%AD%98%E5%B1%8F%E9%9A%9C

好人,一生平安。