即时编译器的重排序、处理器的乱序执行、内存系统的重排序,在多线程环境下,导致共享变量值的不确定性。
即时编译器(和处理器)需要保证程序能够遵守as-if-serial属性。在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。如果两个操作之间存在数据依赖,那么及时编译器(和处理器)不能调整他们的顺序,否则将会造成程序语义的改变。
Java内存模型与happens-before关系
为了让应用程序能够免于数据竞争(data race)的干扰,Java5引入了明确定义的Java内存模型。其中最为重要的一个概念便是happens-before关系。happens-before关系是用来描述两个操作的内存可见性。如果操作X happens-before 操作Y,那么X的结果对于Y可见。
内存模型:1.运行时数据区;2.内存可见性(访问)规则。
JMM中的工作内存,实际上映射到的是CPU缓存。工作线程会将用到的内存加载至CPU缓存中;但不会另外再开一片内存来存储这部分用到的堆内存。
线程内的happens-before关系
在同一个线程中,字节码的先后顺序(program order)也暗含了happens-before关系:在程序控制流路径中靠前的字节码happens-before靠后的字节码。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。
线程间的happens-before关系
- 解锁操作happens-before之后(时钟顺序先后)对同一把锁的加锁操作。
- volatile字段的写操作happens-before之后(时钟顺序先后)对同一字段的读操作。
- 线程的启动操作(Thread.starts())happens-before该线程的第一个操作。
- 线程的最后一个操作happens-before它的终止事件(其他线程通过Thread.isAlive()或Thread.join()判断该线程是否中止)。
- 线程对其他线程的中断操作happens-before被中断线程所收到的中断事件(被中断线程的InterruptedException异常,或者第三个线程针对被中断线程的Thread.interrupted或者Thread.isInterrupted调用)。
- 构造器中的最后一个操作happens-before析构器的第一个操作。
happens-before关系还具备传递性。如果操作X happens-before 操作Y,而操作Y happens-before 操作Z,那么操作X happens-before 操作Z。
解决数据竞争问题的关键在于构造一个跨线程的happens-before关系:操作X happens-before 操作Y,使得操作X之前的字节码的结果对操作Y之后的字节码可见。
Java内存模型的底层实现
内存屏障
Java内存模型是通过内存屏障(memory barrier)来禁止重排序的。
对于即时编译器来说,它会针对前面提到的每一个happens-before关系,向正在编译的目标方法中插入相应的读读、读写、写读、写写内存屏障(一个特殊的编译器中间表达形式节点)。
在解释执行时,字节码之间也有内存屏障
这些内存屏障会限制即时编译器的重排序操作。以volatile字段访问为例,所插入的内存屏障将不允许volatile字段写操作之前的内存访问被重排序至其之后;也不允许volatile字段读操作之后的内存访问被重排序至其之前。
即时编译器将根据具体的底层体系架构,将这些内存屏障替换成具体的CPU指令。以X86_64架构来说,读读、读写、写写内存屏障是空操作(no-op),只有写读内存屏障会被替换成具体指令。
X86_64架构的处理器并不能将读操作重排序至写操作之后,重排序必然是即时编译器造成的。
写缓存
对于volatile字段,即时编译器将在volatile字段的读写操作前后各插入一些内存屏障。然而在X86_64架构上,只要volatile字段写操作之后的写读内存屏障需要用具体指令来替代。(HotSpot所选取的具体指令是lock add DWORD PTR[rep],0x0,而非mfence)。该指令的具体效果可以简单理解为强制刷新处理器的写缓存。写缓存是处理器用来加速内存存储效率的一项技术。
在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存(main memory)之中。强制刷新写缓存,将使得当前线程写入volatile字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。
由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该volatile字段的最新值。
锁、volatile字段、final字段及安全发布
锁
锁操作同样具备happens-before关系。
具体来说,解锁操作happens-before之后对同一把锁的加锁操作。
实际上,在解锁时,JVM同样需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
锁操作的happens-before规则的关键字是同一把锁。意味着,如果编译器能够(通过逃逸分析)证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁操作。因此也就不再强制刷新缓存。如,即时编译后的synchronized(new Object()) {},可能等同于空操作,而不会强制刷新缓存。
volatile字段
volatile字段可以看成一种轻量级的、不保证原子性的同步,其性能旺旺优于(至少不亚于)锁操作。但频繁的访问volatile字段也会因为不断的强制刷新缓存而严重影响程序的性能。
在X86_64平台上,只有volatile字段的写操作会强制刷新缓存。因此理想情况下对volatile字段的使用应当多读少写,并且应当只有一个线程进行写操作。
volatile字段的另一个特性是即时编译器无法将其分配到寄存器里(缓存)。volatile字段的每次访问均需要直接从内存中读写。
final字段
final实例字段则涉及新建对象的发布问题。当一个对象包含final实例字段时,希望其他线程只能看到已经初始化的final实例字段。
即时编译器会在final字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(即将实例对象写入一个共享引用中)重排序至final字段的写操作之前。在X86_64平台上,写写屏障是空操作。
新建对象的安全发布(safe publication)问题不仅仅包括final实例字段的可见性,还包括其他实例字段的可见性。
当发布一个已初始化的对象时,希望所有已初始化的实例字段对其他线程可见。否则,其他线程可能见到一个仅部分初始化的新建对象,从而造成程序错误。
总结
Java内存模型通过定义了一系列的happens-before操作,让应用程序开发者能够轻易的表达不同线程的操作之间的内存可见性。
在遵守Java内存模型的前提下,即时编译器以及底层体系架构能够调整内存访问操作,以达到性能优化的效果。如果开发者没有正确的利用happens-before规则,那么将可能导致数据竞争。
Java内存模型是通过内存屏障来禁止重排序的。对于即时编译器来说,内存屏障将限制它所能做的重排序优化。对于处理器来说,内存屏障会导致缓存的刷新操作。