0%

Java内存模型

Java内存模型(Java Memory Model)是一组类似硬件体系结构内存模型的规范,这些规范描述了Java语言编写多线程程序的语义,这些语义可以解决多线程对共享变量读写时的可见性、原子性和有序性问题。

背景

  1. 在Java之前的编程语言(例如C,C++)直接使用操作系统的内存模型,不同平台的差异性会导致程序出现运行结果不一致或者移植性问题。为了屏蔽不同平台的底层差异,实现“一次编写,到处运行(WORA,Write once, run anywhere)”,Java定义了一个内存模型(JMM,Java Memory Model)。
  2. 在命令式编程中,有两种并发编程模型:共享内存和消息传递,Java中采用了共享内存的方式。Java内存模型将内存抽象成主内存和工作内存,内存模型规定了一个变量何时对一个变量可见,实例字段、静态字段这些线程间共享的变量存放在主内存中,被线程使用的共享变量副本、局部变量和方法参数存放在线程各自的本地工作内存中。对变量的修改都是在各线程内部的工作内存中进行的,各线程通过从主内存中读写共享变量实现线程间通信,Java内存模型通过控制主内存与各线程之间的交互提供变量可见性的保证。

重排序

为了提高程序的并行度,编译器和处理器会对指令进行重排序,重排序主要分为如下三种:

  1. 编译器重排序:在不影响单线程语义的情况下重新安排语句的执行顺序
  2. 指令级并行重排序(Instruction-Level Paralellism, ILP):在指令不存在依赖的情况下,重新安排指令的执行顺序,以提高流水线效率。
  3. 内存系统重排序:由于处理器使用缓存和读写缓冲区,为了提高命中率会重新对指令排序。

这些重排序可能导致一些内存可见性问题,JMM禁用一些特定的编译器重排序规则,并通过编译器编译时插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令来禁止特定的处理器重排序。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。

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

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。

顺序一致性

JMM对正确同步的多线程内存一致性做出以下保证:如果程序是正确同步的,程序的执行结果与在顺序一致性内存模型中执行的结果一致。
顺序一致性模型是一个理论的内存模型,在顺序一致性模型中程序执行的顺序与程序语义一致,该内存模型可以提供以下保证:

  1. 一个线程内的所有操作按照程序顺序执行
  2. 每个操作都是原子的,并且立即对所有线程可见

顺序一致性模型是一个很强的内存模型,不利于在多线程环境下进行优化。Java内存模型定义了程序中各个变量的访问规则,这些规则可以用Happens-before order描述。Java内存模型的实现由JVM运行时数据结构提供支持,底层由一系列重排序规则实现

Happens-Befors

  • 程序顺序规则(Program Order Rule):同一个线程内部,按照代码的语义顺序执行。
  • 对象终结规则(Finalizer Rule):对象的构造先于finalizer()方法的开始。
  • 管程锁定规则(Monitor Lock Rule):同一个对象的解锁先于上锁(也就是如果在一个对象上加锁,下一次获取这个对象的锁必须在该对象上的锁释放之后)。
  • volatile变量规则(Volatile Variable Rule):对一个volatile对象的写操作先于对该对象的读操作。
  • 线程启动规则(Thread StartRule): Thread对象的start()方法先行发生于改线程中的每一个操作。
  • 线程终止规则(Thread Termination Rule):Thread中的每一个操作先行发生于该线程被检测终止(其它线程可以通过Thread.join(),Thread.isAlive()检测该线程是否终止)。
  • 默认值规则(Default Value Rule):每个线程中变量赋予默认值(0, false或null)的操作先行发生于该线程的第一个操作。
  • 传递性规则(Transitivity Rule):如果A先行发生于B,B先行发生于C,那么A先行发生于C。

注意,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。
happens-befor规则与重排序的关系
如上图所示,一个happens-before规则通常对应于多个编译器和处理器重排序规则。对于java程序员来说,happens-before规则简单易懂,它避免java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

原子性

为了保证原子性,Java提供了两个高级的字节码指令:monitorenter和monitorexit,对应关键字synchronized

可见性

volatilefinalsynchronized三个关键字都可以提供可见性保证,只是实现方式不一样。

有序性

volatilesynchronized可以用来保证多个线程之间操作的有序性,其中,volatile会禁止指令重排序,synchronized保证同一时刻只有一个线程执行。

volatile

被volatile变量修饰的变量具有如下特性:

  • 线程读一个volatile变量时,线程先把本地内存中的变量置为无效,从主内存中读取最新值。
  • 线程对一个volatile变量执行写操作之后会把本地内存中的值刷新到主内存中。
  • 可见性:当一个线程执行完volatile变量的写操作后,对其他所有线程可见
  • 原子性:可以防止64位的long/double出现字撕裂;对于不依赖于自身的多线程读写操作具有原子性,类似++i这种依赖自身值得不具有原子性

Final

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

锁是java并发编程中最重要的同步机制,后面的文章会对Java中的各种锁做更加具体的分析。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。锁的内存语义如下:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。

  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。

  • 程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

参考文献

  1. JSR133
  2. 深入理解Java内存模型(一)——基础
  3. 深入理解Java内存模型(六)——final
  4. 周志明:《JVM高级特性与最佳实践》(第二版)