Java 内存模型

基础

并发编程模型的分类

在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存 & 消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,通过「读-写内存中的共享状态」来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,必须通过明确的「发送消息」来显式进行通信

同步是指程序中用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显示指定某个方法或某段代码需要在线程之间互斥进行。在消息传递的并发模型里,由于消息的发送必须在消息的接受之前,因此同步是隐式进行的

Java 采用的是共享内存的并发模型,Java 线程之间的通信总是隐式进行的,整个通信过程对程序员完全透明。如果编写多线程的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种各样奇怪的内存可见性问题

Java 内存模型的抽象

在 Java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响

Java 线程之间的通信由 Java 内存模型 (JMM) 控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存 (Main Memory) 中,每个线程都有一个私有的本地内存 (Local Memory),本地内存中存储了该线程读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化

Java 内存模型的抽象示意图如下:

1

从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

假设:本地内存 A 和 B 有主内存中共享变量 x 的副本,初始时,这三个内存中的 x 值都为 0

从整体来看,线程之间的通信必须要经过主内存!!!

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

2

上图中,1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题

对于编译器重排序,JMM 的编译器重排序规则会禁止特定类型的编译器重排序 (不是所有的编译器重排序都要禁止)

对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障 (Memory Barriers) 指令来禁止特定类型的处理器重排序 (不是所有的处理器重排序都要禁止)

JMM 属于语言级别的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

处理器重排序

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用

虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!为了具体说明,请看下面示例:

3

假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:

4

这里线程 A 和线程 B 可以同时把共享变量写入自己的写缓冲区 (A1,B1),然后从内存中读取另一个共享变量 (A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中 (A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果

从内存操作实际发生的顺序来看,直到线程 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然线程 A 执行内存操作的顺序为:A1 -> A2,但内存操作实际发生的顺序却是:A2 -> A1。此时,线程 A 的内存操作顺序被重排序了

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

下面是常见处理器允许的指令重排序类型的列表:

 Load-LoadLoad-StoreStore-StoreStore-Load数据依赖
Spare-TSONNNYN
x86NNNYN
ia64YYYYN
PowerPCYYYYN

上表单元格中的 N 表示处理器不允许两个操作重排序,Y 表示允许重排序

从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序 (因为它们都使用了写缓冲区)

内存屏障指令

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

屏障类型指令示例说明
LoadLoad BarriersLoad1; LoadLoad; Load2ensures that Load1's data are loaded
before data accessed by Load2
and all subsequent load instructions are loaded.
StoreStore BarriersStore1; StoreStore; Store2ensures that Store1's data are visible to
other processors (i.e., flushed to memory)
before the data associated with Store2
and all subsequent store instructions.
LoadStore BarriersLoad1; LoadStore; Store2ensures that Load1's data are loaded
before all data associated with Store2
and subsequent store instructions are flushed.
StoreLoad BarriersStore1; StoreLoad; Load2ensures that Store1's data are made visible
to other processors (i.e., flushed to main memory)
before data accessed by Load2
and all subsequent load instructions are loaded.

注意:

happens-before

从 JDK5 开始,Java 使用新的 JSR-133 内存模型。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间

更具体的,happens-before 是 Java 内存模型中定义的两项操作之间的偏序关系,比如说操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,「影响」包括修改内存中的共享变量的值、发送了消息、调用了方法等

happens-before 原则一共有 8 条规则:

注意:一个操作「时间上的先发生」不代表这个操作会是「先行发生」,反之也不成立!

happens-before 与 JMM 的关系如下图所示:

5

如上图所示,一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则

对于 Java 程序员来说,happens-before 规则简单易懂,它避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现

小结

为了最大程度的利用 CPU,处理器往往会对指令重排序,原则是不会改变在单线程下执行的结果,但不能保证多线程下结果的一致性

往往不同处理器会有不同的指令重排序规则,可能一个程序在某处理器下运行完全正常,但在另一类型的处理器下运行就会出现问题,这都是因为不同处理器中硬件和操作系统差异导致

《Java 虚拟机规范》定义了 Java 内存模型,专门用来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 在各种平台下都能达到一致的内存访问效果。更具体的,JMM 做了这些事:

说了这么多,联想到 Java 具有跨平台性的原因:Java 编译后的字节码文件并不是直接运行在操作系统,而是运行在 JVM 中,JVM 将字节码转换成计算机底层可以识别的机器指令才可以真正的执行

不同操作系统有不同版本的 JVM,它类似于是一个中间媒介,从软件层面屏蔽了不同操作系统硬件和底层指令上的差异。所以严格来说是编译后的字节码文件具有跨平台性!!更多可见 JVM 组成部分

回到 JMM,它也类似于是一个中间媒介,不同在于 JVM 是实实在在存在的一个软件,而 JMM 是一种模型,没有实体,但它却无处不在!!

重排序

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列三种类型:

名称代码示例说明
写后读a = 1;
b = a;
写一个变量之后,再读这个位置
写后写a = 1;
a = 2;
写一个变量之后,再写这个变量
读后写a = b;
b = 1;
读一个变量之后,再写这个变量

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

注意:这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑

as-if-serial 语义

as-if-serial 语义是指:不管怎么重排序 (编译器和处理器为了提高并行度),(单线程) 程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

上面三个操作的数据依赖关系如下图所示:

6

A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面 (C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种执行顺序:

7

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

程序顺序规则

根据 happens-before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens-before 关系:

第三个 happens-before 关系,是根据 happens-before 的传递性推导出来的

这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行。前文提到过:一个操作「时间上的先发生」不代表这个操作会是「先行发生」,反之也不成立!这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens-before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法,JMM 允许这种重排序

重排序对多线程的影响

「数据依赖性」「as-if-serial 语义」「程序顺序规则」都是在单线程情况下讨论的

现在我们来看看重排序在多线程中是否会影响执行结果,先看下面代码:

flag变量是个标记,用来标识变量a是否已被写入

这里假设有两个线程 A 和 B,A 首先执行writer()方法,随后 B 线程接着执行reader()方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量a的写入?

答案:不一定能看到!!

由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么效果?请看下面的程序执行时序图:

8

如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!

注:统一用红色的虚箭线标识错误的读操作,用绿色的虚箭线标识正确的读操作

下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果 (借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:

9

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测 (Speculation) 执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算a * a,然后把计算结果临时保存到一个名为重排序缓冲 (Reorder Buffer,ROB) 的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量i

从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果 (这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中, 对存在控制依赖的操作重排序,可能会改变程序的执行结果

顺序一致性

数据竞争与顺序一致性保证

当程序未正确同步时,就可能会存在数据竞争。Java 内存模型规范对数据竞争的定义如下:

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序

JMM 对正确同步的多线程程序的内存一致性做了如下保证:

顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

顺序一致性内存模型为程序员提供的视图如下:

10

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作

从上面的示意图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化 (即在顺序一致性模型中,所有操作之间具有全序关系)

为了更好的理解,下面通过两个示意图来对顺序一致性模型的特性做进一步的说明

假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:A1 -> A2 -> A3。B 线程也有三个操作,它们在程序中的顺序是:B1 -> B2 -> B3

假设这两个线程使用监视器锁来正确同步:A 线程的三个操作执行后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果将如下图所示:

11

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

12

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序

以上图为例,线程 A 和 B 看到的执行顺序都是:B1 -> A1 -> A2 -> B2 -> A3 -> B3

之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见

但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致

比如,在当前线程把写过的数据缓存在本地内存中,在还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致

同步程序的顺序一致性效果

下面我们对前面的示例程序ReorderExample用锁来同步,看看正确同步的程序如何具有顺序一致性

上面示例代码中,假设 A 线程执行writer()方法后,B 线程执行reader()方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:

13

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序 (但 JMM 不允许临界区内的代码「逸出」到临界区之外, 那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图

虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法「观察」到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果

从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变 (正确同步的) 程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值 (0,null,false)。JMM 保证线程读操作读取到的值不会无中生有 (out of thin air) 的冒出来

为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象 (JVM 内部会同步这两个操作)。因此,在已清零的内存空间 (prezeroed memory) 分配对象时,域的默认初始化已经完成了

JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM 需要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。保证未同步程序在这两个模型中的执行结果一致没什么意义

未同步程序在 JMM 中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有下面几个差异:

第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务 (bus transaction)

总线事务包括读事务 (read transaction) 和写事务 (write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字

这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读/写

下面让我们通过一个示意图来说明总线的工作机制:

14

如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁 (bus arbitration) 会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜 (总线仲裁会确保所有处理器都能公平的访问内存)

此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间 (不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性

long/double 非原子性

在一些 32 位的处理器上,如果要求对 64 位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行。这两个 32 位的写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的写将不具有原子性

当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:

15

如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量

处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行

同时处理器 B 中 64 位的读操作被分配到单个的读事务中执行

当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A「写了一半」的无效值

注意:在 JSR-133 之前的旧内存模型中,一个 64 位 long/double 型变量的读/写操作可以被拆分为两个 32 位的读/写操作来执行。从 JSR-133 内存模型开始 (即从 JDK5 开始),仅仅只允许把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR-133 中都必须具有原子性 (即任意读操作必须要在单个读事务中执行)