深入理解 java 内存模型

深入理解 Java 内存模型一:基础

Doug Lea 关于JSR-133内存模型的说明 The JSR-133 Cookbook for Compiler Writers

并发编程模型的分类

在并发编程中,我们需要处理两个关键问题:

线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。

通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:

共享内存和消息传递。

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

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

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

Java 内存模型的抽象

在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用共享变量这个术语代指实例域静态域数组元素)。

局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:

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

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

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

  1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
  2. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:

如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

重排序

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

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

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

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

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

处理器重排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:

处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

为了具体说明,请看下面示例:

Processor A Processor B
a = 1; //A1 b = 2; //B1
x = b; //A2 y = a; //B2

初始状态:a = b = 0
处理器允许执行后得到结果:x = y = 0

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

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

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:

A1->A2

但内存操作实际发生的顺序却是:

A2->A1

此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。

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

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

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 拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。

※ 注 1:sparc-TSO 是指以 TSO(Total Store Order)内存模型运行时,sparc 处理器的特性。
※ 注 2:上表中的 x86 包括 x64 及 AMD64。
※ 注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
※ 注 4:数据依赖性后文会专门说明。

为了保证内存可见性,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 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

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

happens-before

从 JDK5 开始,java 使用新的JSR-133内存模型(本文除非特别说明,针对的都是JSR-133内存模型)。JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的happens-before规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。
  3. volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  4. 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

注意,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens-before的定义很微妙,后文会具体说明happens-before为什么要这么定义。

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

如上图所示,一个happens-before规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

参考文献

  1. Programming Language Pragmatics, Third Edition
  2. The Java Language Specification, Third Edition
  3. JSR-133: Java Memory Model and Thread Specification
  4. Java theory and practice: Fixing the Java Memory Model, Part 2
  5. Understanding POWER Multiprocessors
  6. Concurrent Programming on Windows
  7. The Art of Multiprocessor Programming
  8. Intel® 64 and IA-32 ArchitecturesvSoftware Developer’s Manual Volume 3A: System Programming Guide, Part 1
  9. Java Concurrency in Practice
  10. The JSR-133 Cookbook for Compiler Writers

深入理解 Java 内存模型二:重排序

数据依赖性

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

数据依赖分下列三种类型:

名称 代码示例 说明
写后读 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语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    // Adouble r   = 1.0;     // Bdouble area = pi * r * r; // C

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

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

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:

单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

程序顺序规则

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

  1. A happens-before B;
  2. B happens-before C;
  3. A happens-before C;

这里的第 3 个happens-before关系,是根据happens-before的传递性推导出来的。

这里 A happens-before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。

在第一章提到过,如果 A happens-before B,JMM 并不要求 A 一定要在 B 之前执行。

JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按happens-before顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:

在不改变程序执行结果的前提下,尽可能的开发并行度。

编译器和处理器遵从这一目标,从happens-before的定义我们可以看出,JMM 同样遵从这一目标。

重排序对多线程的影响

现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码:

class ReorderExample {    int a = 0;    boolean flag = false;    public void writer() {        a = 1;              // 1        flag = true;        // 2    }    Public void reader() {        if (flag) {         // 3            int i =  a * a; // 4            // ......        }    }}

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

答案是:不一定能看到。

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

请看下面的程序执行时序图:

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

※ 注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。

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

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

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

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

Out-Of-Order Execution Core

The out-of-order execution core’s ability to execute instructions out of order is a key factor in enabling parallelism. This feature enables the processor to reorder instructions so that if one micro-op is delayed, other micro-ops may proceed around it. The processor employs several buffers to smooth the flow of micro-ops.

The core is designed to facilitate parallel execution. It can dispatch up to six micro-ops per cycle (this exceeds trace cache and retirement micro-op bandwidth). Most pipelines can start executing a new micro-op every cycle, so several instructions can be in flight at a time for each pipeline. A number of arithmetic logical unit (ALU) instructions can start at two per cycle; many floating-point instructions can start once every two cycles.

Retirement Unit

The retirement unit receives the results of the executed micro-ops from the out-of-order execution core and processes the results so that the architectural state updates according to the original program order.

When a micro-op completes and writes its result, it is retired. Up to three micro-ops may be retired per cycle. The Reorder Buffer (ROB) is the unit in the processor which buffers completed micro-ops, updates the architectural state in order, and manages the ordering of exceptions. The retirement section also keeps track of branches and sends updated branch target information to the BTB. The BTB then purges pre-fetched traces that are no longer needed.

RISC 机器的五层流水线示意图(IF:读取指令,ID:指令解码,EX:运行,MEM:存储器访问,WB:写回寄存器)

CPU 乱序执行示例

public class OutOfOrderExecution {    private static int x = 0, y = 0;    private static int a = 0, b = 0;    public static void main(String[] args) throws InterruptedException {        boolean z = false;        boolean t = false;        while (true) {            x = y = a = b = 0;            CountDownLatch countDownLatch = new CountDownLatch(1);            Thread t1 = new Thread(() -> {                try { countDownLatch.await(); } catch (InterruptedException e) { }                a = 1;                x = b;            });            Thread t2 = new Thread(() -> {                try { countDownLatch.await(); } catch (InterruptedException e) { }                b = 1;                y = a;            });            t1.start();            t2.start();            countDownLatch.countDown();            t1.join();            t2.join();            int s = x + y;            if (s == 0) {                z = true;                System.out.println("(" + x + "," + y + ")");            } else if (s == 2) {                t = true;                System.out.println("(" + x + "," + y + ")");            }            if (z && t) {                return;            }        }    }}

上述程序运行可能输出的结果如下,并且因为 CPU 指令重排,(1,1) 出现的概率极小,如上示例用CyclicBarrier来替代CountDownLatch控制并发执行后(1,1)出现次数会稍多点:

(0,0)
(0,0)
...
(0,0)
(0,0)
(0,0)
(1,1)

JCStress 并发压力测试

下面的代码用 JCStress 压力测试来输出 CPU 指令重排序的输出结果。

@JCStressTest@Outcome(id = {"0, 0", "0, 2", "1, 0"}, expect = Expect.ACCEPTABLE, desc = "Normal outcome")@Outcome(id = {"1, 2"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "Abnormal outcome")@Statepublic class JavaConcurrentStressExample1 {    int a = 0;    int b = 0;    @Actor    public void method1(IntResult2 r) {        r.r2 = a; // 读写        b = 1;    }    @Actor    public void method2(IntResult2 r) {        r.r1 = b; // 读写        a = 2;    }}

输出内容如下:

JVM options: [-server] Iterations: 5 Time: 1000Observed state Occurrence Expectation            Interpretation0,0            210351     ACCEPTABLE             Normal   outcome0,2            11442460   ACCEPTABLE             Normal   outcome1,0            11560580   ACCEPTABLE             Normal   outcome1,2            129        ACCEPTABLE_INTERESTING Abnormal outcomeJVM options: [-server, -XX:-TieredCompilation] Iterations: 5 Time: 1000Observed state Occurrence Expectation            Interpretation0,0            348152     ACCEPTABLE             Normal   outcome0,2            11014622   ACCEPTABLE             Normal   outcome1,0            14775737   ACCEPTABLE             Normal   outcome1,2            259        ACCEPTABLE_INTERESTING Abnormal outcome

参考文献

  1. Computer Architecture: A Quantitative Approach, 4th Edition
  2. Concurrent Programming on Windows
  3. Concurrent Programming in Java™: Design Principles and Pattern
  4. JSR-133: Java Memory Model and Thread Specification
  5. JSR-133 (Java Memory Model) FAQ

深入理解 Java 内存模型三:顺序一致性

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

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

  1. 在一个线程中写一个变量,
  2. 在另一个线程读同一个变量,
  3. 而且写和读没有通过同步来排序。

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

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

如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)--即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(马上我们将会看到,这对于程序员来说是一个极强的保证)。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。

顺序一致性内存模型

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

  1. 一个线程中的所有操作必须按照程序的顺序来执行。
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

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

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读/写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。

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

假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:

A1->A2->A3

B 线程也有三个操作,它们在程序中的顺序是:

B1->B2->B3

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

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

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:

B1->A1->A2->B2->A3->B3

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

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

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

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

请看下面的示例代码:

class SynchronizedExample {    int a = 0;    boolean flag = false;    public synchronized void writer() {        a = 1;        flag = true;    }    public synchronized void reader() {        if (flag) {            int i = a;            // ......        }    }}

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

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码逸出到临界区之外,那样会破坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法观察到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

从这里我们可以看到 JMM 在具体实现上的基本方针:

在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:

线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。

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

JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。

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

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。
  3. JMM 不保证对 64 位的 long 型和 double 型变量的读/写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

第 3 个差异与处理器总线的工作机制密切相关。

在计算机中,数据通过总线在处理器和内存之间传递,每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。

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

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

如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。

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

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

当单个内存操作不具有原子性,将可能会产生意想不到后果。

请看下面示意图:

如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A 写了一半的无效值。

无缓存处理器指令重排序对程序的影响示例图

上面几个图例原文说明参考链接 Shared Memory Consistency Models: A Tutorial

参考文献

  1. https://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
  2. JSR-133: Java Memory Model and Thread Specification
  3. Shared memory consistency models: A tutorial
  4. The JSR-133 Cookbook for Compiler Writers
  5. 深入理解计算机系统(原书第 2 版)
  6. UNIX Systems for Modern Architectures: Symmetric Multiprocessing and Caching for Kernel Programmers
  7. The Java Language Specification, Third Edition

深入理解 Java 内存模型四:volatile

volatile 的特性

当我们声明共享变量为 volatile 后,对这个变量的读/写将会很特别。理解 volatile 特性的一个好方法是:

把对 volatile 变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步。

下面我们通过具体的示例来说明,请看下面的示例代码:

class VolatileFeaturesExample {    volatile long vl = 0L; // 使用 volatile 声明 64 位的 long 型变量    public void set(long l) {        vl = l;            // 单个 volatile 变量的写    }    public void getAndIncrement () {        vl++;              // 复合(多个)volatile 变量的读/写    }    public long get() {        return vl;         // 单个 volatile 变量的读    }}

假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:

class VolatileFeaturesExample {    long vl = 0L;               // 64 位的 long 型普通变量    public synchronized void set(long l) {     // 对单个的普通变量的写用同一个监视器同步        vl = l;    }    public void getAndIncrement () { // 普通方法调用        long temp = get();           // 调用已同步的读方法        temp += 1L;                  // 普通写操作        set(temp);                   // 调用已同步的写方法    }    public synchronized long get() {        // 对单个的普通变量的读用同一个监视器同步        return vl;    }}

如上面示例程序所示,对一个 volatile 变量的单个读/写操作,与对一个普通变量的读/写操作使用同一个监视器锁来同步,它们之间的执行效果相同。

监视器锁的happens-before规则保证释放监视器和获取监视器的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。

监视器锁的语义决定了临界区代码的执行具有原子性。这意味着即使是 64 位的 long 型和 double 型变量,只要它是 volatile 变量,对该变量的读写就将具有原子性。如果是多个 volatile 操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性

简而言之,volatile 变量自身具有下列特性:

  1. 可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
  2. 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性

volatile 写-读 建立的 happens-before 关系

上面讲的是 volatile 变量自身的特性,对程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,也更需要我们去关注。

JSR-133开始,volatile 变量的写-读可以实现线程之间的通信。

从内存语义的角度来说,volatile 与监视器锁有相同的效果:

volatile 写和监视器的释放有相同的内存语义;
volatile 读与监视器的获取有相同的内存语义。

请看下面使用 volatile 变量的示例代码:

class VolatileExample {    int a = 0;    volatile boolean flag = false;    public void writer() {        a = 1;                     // 1        flag = true;               // 2    }    public void reader() {        if (flag) {                // 3            int i =  a;            // 4            // ......        }    }}

假设线程 A 执行writer()方法之后,线程 B 执行reader()方法。根据happens-before规则,这个过程建立的happens-before关系可以分为两类:

  1. 根据程序次序规则,1 happens-before 2; 3 happens-before 4。
  2. 根据 volatile 规则,2 happens-before 3。
  3. 根据 happens-before 的传递性规则,1 happens-before 4。

上述happens-before关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。

  1. 黑色箭头表示程序顺序规则;橙色箭头表示 volatile 规则;
  2. 蓝色箭头表示组合这些规则后提供的happens-before保证。

这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。

volatile 写-读 的内存语义

普通共享变量并行读的测试代码

下面测试代码运行时设置 JVM 参数 -XX:-RestrictContended

/** * Normal Loads are getfield, getstatic, array load of non-volatile fields. * Normal Stores are putfield, putstatic, array store of non-volatile fields * Volatile Loads are getfield, getstatic of volatile fields that are accessible by multiple threads * Volatile Stores are putfield, putstatic of volatile fields that are accessible by multiple threads * MonitorEnters (including entry to synchronized methods) are for lock objects accessible by multiple threads. * MonitorExits (including exit from synchronized methods) are for lock objects accessible by multiple threads. */public class ConcurrentWithoutVolatileRead {    private int i = 0;    public static void main(String[] args) {        final int loop = 100_000_000;        final CyclicBarrier cyclicBarrier = new CyclicBarrier(2);        ConcurrentWithoutVolatileRead example = new ConcurrentWithoutVolatileRead();        Thread thread1 = new Thread(() -> {            try {                cyclicBarrier.await();            } catch (InterruptedException e) {                e.printStackTrace();            } catch (BrokenBarrierException e) {                e.printStackTrace();            }            int diff = 0;            for (int j = 1; j <= loop; j++) {                example.i = j; // putfield                if (example.i != j) { // getfield                    diff++;                }            }            System.out.println(Thread.currentThread().getName() + ": diff = " + diff);        }, "t1");        Thread thread2 = new Thread(() -> {            try {                cyclicBarrier.await();            } catch (InterruptedException e) {                e.printStackTrace();            } catch (BrokenBarrierException e) {                e.printStackTrace();            }            int diff = 0;            for (int j = loop + 1; j <= loop * 2; j++) {                example.i = j;                if (example.i != j) {                    diff++;                }            }            System.out.println(Thread.currentThread().getName() + ": diff = " + diff);        }, "t2");        try {            thread1.start();            thread2.start();            thread1.join();            thread2.join();            System.out.println("example.i : " + example.i);        } catch (InterruptedException e) {            e.printStackTrace();        }    }}

上面的代码运行后产生如下输出,没有 volatile 变量读写操作时,不同线程还是有极小的机会看见另一个线程对共享变量的修改:

t1: diff = 11
t2: diff = 21
example.i : 200000000

volatile 写的内存语义如下:

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

以上面示例程序 VolatileExample 为例,假设线程 A 首先执行writer()方法,随后线程 B 执行reader()方法,初始时两个线程的本地内存中的 flaga 都是初始状态。下图是线程 A 执行 volatile 写后,共享变量的状态示意图:

如上图所示,线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值被刷新到主内存中。此时,本地内存 A 和主内存中的共享变量的值是一致的。

volatile 共享变量写操作并行读示例

/** * http://mail.openjdk.java.net/pipermail/hotspot-dev/2012-November/007309.html * * <pre> * 如果对 t1 线程中的 volatile 变量 example.v 做写操作,共享变量 example.i 和 example.v 都会被刷回主内存, * 同时 volatile 共享变量的写操作相对于读操作要更耗性能更慢一些,所以 t1 线程要比 t2 线程慢才完成这个循环, * 并且将修改刷回主存,因为底层的各种原因有概率工作线程中的缓存失效,重新从主存读回的共享变量 example.i * 与当前循环所在 j 的值相异比较多。 * * 而 t2 线程中没有对 volatile 变量 example.v 读操作时,所以基本上对共享变量的变化没有感知,即不可见: * </pre> */public class ConcurrentVolatileWrite {    @Contended("g0")    private int i = 0;    @Contended("g1")    private volatile boolean v = true;    public static void main(String[] args) {        final int loop = 10_000_000;        final CyclicBarrier cyclicBarrier = new CyclicBarrier(2);        ConcurrentVolatileWrite example = new ConcurrentVolatileWrite();        Thread thread1 = new Thread(() -> {            try {                cyclicBarrier.await();            } catch (InterruptedException e) {                e.printStackTrace();            } catch (BrokenBarrierException e) {                e.printStackTrace();            }            int diff = 0;            for (int j = 1; j <= loop; j++) {                example.i = j;                example.v = true; // volatile write                // if (example.v) // volatile read                if (example.i != j) {                    diff++;                }            }            System.out.println(Thread.currentThread().getName() + ": diff = " + diff);        }, "t1");        Thread thread2 = new Thread(() -> {            try {                cyclicBarrier.await();            } catch (InterruptedException e) {                e.printStackTrace();            } catch (BrokenBarrierException e) {                e.printStackTrace();            }            int diff = 0;            for (int j = loop + 1; j <= loop * 10; j++) {                example.i = j;                // example.v = true; // volatile write                // if (example.v) // volatile read                if (example.i != j) {                    diff++;                }            }            System.out.println(Thread.currentThread().getName() + ": diff = " + diff);        }, "t2");        try {            thread1.start();            thread2.start();            thread1.join();            thread2.join();            System.out.println("example.i : " + example.i);        } catch (InterruptedException e) {            e.printStackTrace();        }    }}

上面示例代码运行之后生成如下结果,其中因为 volatile 共享变量写操作慢,所以 t1 线程结果输出要慢于 t2 线程(t2 线程循环次数也比 t1 线程多),t2 线程中的普通读操作,对 t1 线程中的 volatile 写操作是看不见的,虽然 t1 线程中已经将共享变量同步回缓存了。

t2: diff = 2
t1: diff = 42685
example.i : 10000000

volatile 读的内存语义如下:

当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

下面是线程 B 读同一个 volatile 变量后,共享变量的状态示意图:

如上图所示,在读 flag 变量后,本地内存 B 已经被置为无效。此时,线程 B 必须从主内存中读取共享变量。线程 B 的读取操作将导致本地内存 B 与主内存中的共享变量的值也变成一致的了。

如果我们把 volatile 写和 volatile 读这两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。

普通读和 volatile 读并行示例

/** * http://www.aligelenler.com/2017/02/volatile-variable-false-sharing-and.html * * Cache Lines and False Sharing: * * CPU's read memory in some block of bytes, usually 64 bytes. We call this block of bytes as cache lines. Generally a *maintain consistency on cache line basis, that means if any single byte of a cache line is changed all cache line * is invalited and this invalidation takes place for all cpu's in cluster. * */public class ConcurrentVolatileRead {    @Contended("g0")    private int i = 0;    @Contended("g1")    private volatile boolean v = true;    public static void main(String[] args) {        final int loop = 10_000_000;        final CyclicBarrier cyclicBarrier = new CyclicBarrier(2);        ConcurrentVolatileRead example = new ConcurrentVolatileRead();        Thread thread1 = new Thread(() -> {            try {                cyclicBarrier.await();            } catch (InterruptedException e) {                e.printStackTrace();            } catch (BrokenBarrierException e) {                e.printStackTrace();            }            int diff = 0;            for (int j = 1; j <= loop; j++) {                example.i = j;                // example.v = true;                if (example.v) // volatile read                    if (example.i != j) { // getfield #2 // Field i:I                        diff++;                    }            }            System.out.println(Thread.currentThread().getName() + ": diff = " + diff);        }, "t1");        Thread thread2 = new Thread(() -> {            try {                cyclicBarrier.await();            } catch (InterruptedException e) {                e.printStackTrace();            } catch (BrokenBarrierException e) {                e.printStackTrace();            }            int diff = 0;            for (int j = loop + 1; j <= loop * 10; j++) {                example.i = j;                // example.v = true;                // if (example.v) // volatile read                if (example.i != j) {                    diff++;                }            }            System.out.println(Thread.currentThread().getName() + ": diff = " + diff);        }, "t2");        try {            thread1.start();            thread2.start();            thread1.join();            thread2.join();            System.out.println("example.i : " + example.i);        } catch (InterruptedException e) {            e.printStackTrace();        }    }}

上面示例运行输出类似如下:

t2: diff = 1
t1: diff = 23249
example.i : 10000000

t2 线程因为没有对 volatile 变量example.v读取,所以对example.i变量是否改变不可见,所以基本上example.i != j的情况很少。
t1 线程则因为有对 volatile 变量example.v读操作,线程执行速度相比 t2 线程稍慢,并对 t2 线程中的example.i变量改变可见,所以发生example.i != j的情况要比 t2 线程更多。

volatile 读写内存语义总绕结

下面对 volatile 写和 volatile 读的内存语义做个总结:

  1. 线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量做过修改的)消息。
  2. 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(其他线程在写这个 volatile 变量之前对共享变量所做修改的)消息。
  3. 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。

volatile 内存语义的实现

下面,让我们来看看 JMM 如何实现 volatile 写-读的内存语义。

前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。

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

是否能重排序 第二个操作
第一个操作 普通读/写 volatile 读 volatile 写
普通读/写 NO
volatile 读 NO NO NO
volatile 写 NO NO

举例来说,第三行最后一个单元格的意思是:

在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。

从上表我们可以看出:

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

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。

下面是基于保守策略的 JMM 内存屏障插入策略:

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义。

Plus the special final-field rule requiring a StoreStore barrier in x.finalField = v; StoreStore; sharedRef = x;

volatile 禁止重排序测试

@JCStressTest@Outcome(id = {"0, 0", "0, 2", "1, 0"}, expect = Expect.ACCEPTABLE, desc = "Normal outcome")@Outcome(id = {"1, 2"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "Abnormal outcome")@Statepublic class JavaConcurrentStressExample2 {    int a = 0;    volatile int b = 0;    @Actor    public void method1(IntResult2 r) {        r.r2 = a;        b = 1; // 第二个写操作是 volatile 写    }    @Actor    public void method2(IntResult2 r) {        r.r1 = b; // 第一个操作是 volatile 读        a = 2;    }}

运行输出报告如下:

JVM options: [-server] Iterations: 5 Time: 1000Observed state Occurrence Expectation            Interpretation0,0            67954      ACCEPTABLE             Normal   outcome0,2            15715991   ACCEPTABLE             Normal   outcome1,0            5445985    ACCEPTABLE             Normal   outcome1,2            0          ACCEPTABLE_INTERESTING Abnormal outcomeJVM options: [-server, -XX:-TieredCompilation] Iterations: 5 Time: 1000Observed state Occurrence Expectation            Interpretation0,0            272725     ACCEPTABLE             Normal   outcome0,2            16361513   ACCEPTABLE             Normal   outcome1,0            9323562    ACCEPTABLE             Normal   outcome1,2            0          ACCEPTABLE_INTERESTING Abnormal outcome

volatile 重排序测试

@JCStressTest@Outcome(id = {"0, 2", "1, 0"}, expect = Expect.ACCEPTABLE, desc = "Normal outcome")@Outcome(id = {"0, 0"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "Normal outcome")@Outcome(id = {"1, 2"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "Normal outcome")@Statepublic class JavaConcurrentStressExample4 {    int a = 0;    volatile int b = 0;    @Actor    public void method1(IntResult2 r) {        b = 1; // 第一个操作是 volatile 写操作,写读仍然会重排序        r.r2 = a;    }    @Actor    public void method2(IntResult2 r) {        a = 2; // 写读        r.r1 = b; // 第二个操作是 volatile 读操作,写读仍然会重排序    }}

运行输出报告如下:

JVM options: [-server, -XX:-TieredCompilation] Iterations: 5 Time: 1000Observed state Occurrence Expectation            Interpretation0,0            177960     ACCEPTABLE_INTERESTING Normal outcome0,2            17203654   ACCEPTABLE             Normal outcome1,0            8153691    ACCEPTABLE             Normal outcome1,2            1015       ACCEPTABLE_INTERESTING Normal outcomeJVM options: [-server] Iterations: 5 Time: 1000Observed state Occurrence Expectation            Interpretation0,0            66304      ACCEPTABLE_INTERESTING Normal outcome0,2            13360900   ACCEPTABLE             Normal outcome1,0            5129092    ACCEPTABLE             Normal outcome1,2            384        ACCEPTABLE_INTERESTING Normal outcome

内存屏障插入位置与 java 代码对比示例

下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图:

上图中的StoreStore屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

这里比较有意思的是 volatile 写后面的StoreLoad屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个StoreLoad屏障(比如,一个 volatile 写之后方法立即 return)。为了保证能正确实现 volatile 的内存语义,JMM 在这里采取了保守策略:

在每个 volatile 写的后面或在每个 volatile 读的前面插入一个StoreLoad屏障。

从整体执行效率的角度考虑,JMM 选择了在每个 volatile 写的后面插入一个StoreLoad屏障。因为 volatile 写-读内存语义的常见使用模式是:

一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。

当读线程的数量大大超过写线程时,选择在 volatile 写之后插入StoreLoad屏障将带来可观的执行效率的提升。

从这里我们可以看到 JMM 在实现上的一个特点:

首先确保正确性,然后再去追求执行效率。

下面是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图:

上图中的LoadLoad屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序,LoadStore屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序(下面的普通读写可能重排序,因此多加了一个LoadStore屏障)。

上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:

class VolatileBarrierExample {    int a;    volatile int v1 = 1;    volatile int v2 = 2;    void readAndWrite() {        int i = v1;           // 第一个 volatile 读        int j = v2;           // 第二个 volatile 读        a = i + j;            // 普通写        v1 = i + 1;           // 第一个 volatile 写        v2 = j * 2;           // 第二个 volatile 写    }    // ......                 // 其他方法}

针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:

注意,最后的StoreLoad屏障不能省略。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器常常会在这里插入一个StoreLoad屏障。

上面的优化是针对任意处理器平台,由于不同的处理器有不同松紧度的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 x86 处理器为例,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。

前面保守策略下的 volatile 读和写,在 x86 处理器平台可以优化成:

x86 架构的内存屏障

x86 架构并没有实现全部的内存屏障。

Store Barrier

SFENCE 指令实现了Store Barrier,相当于StoreStore Barriers

强制所有在 SFENCE 指令之前的 store 指令,都在该 SFENCE 指令执行之前被执行,发送缓存失效信号,并把 store buffer 中的数据刷出到 CPU 的 L1 Cache 中;所有在 SFENCE 指令之后的 store 指令,都在该 SFENCE 指令执行之后被执行。即禁止对 SFENCE 指令前后 store 指令的重排序跨越 SFENCE 指令,使所有Store Barrier之前发生的内存更新都是可见的。

这里的可见,指修改值可见(内存可见性)且操作结果可见(禁用重排序)。

内存屏障的标准中,讨论的是缓存与内存间的相干性,实际上,同样适用于寄存器与缓存、甚至寄存器与内存间等多级缓存之间,寄存器实际也是一种 Memory,只是历史原因,被命名为了寄存器。

x86 架构使用了MESI协议的一个变种(MESIF),由协议保证三层缓存与内存间的相关性,则内存屏障只需要保证 store buffer(可以认为是寄存器与 L1 Cache 间的一层缓存)与 L1 Cache 间的相干性。

Load Barrier

x86 的 LFENCE 指令实现了Load Barrier,相当于LoadLoad Barriers,强制所有在 LFENCE 指令之后的 load 指令,都在该 LFENCE 指令执行之后被执行,并且一直等到 load buffer 被该 CPU 读完才能执行之后的 load 指令(发现缓存失效后发起的刷入)。即禁止对 LFENCE 指令前后 load 指令的重排序跨越 LFENCE 指令,配合Store Barrier,使所有Store Barrier之前发生的内存更新,对Load Barrier之后的 load 操作都是可见的。

Full Barrier

MFENCE 指令实现了Full Barrier,相当于StoreLoad Barriers,MFENCE 指令综合了 SFENCE 指令与 LFENCE 指令的作用,强制所有在 MFENCE 指令之前的 store/load 指令,都在该 MFENCE 指令执行之前被执行;所有在 MFENCE 指令之后的 store/load 指令,都在该 MFENCE 指令执行之后被执行。即禁止对 MFENCE 指令前后 store/load 指令的重排序跨越 MFENCE 指令,使所有Full Barrier之前发生的操作,对所有Full Barrier之后的操作都是可见的。

SSE2 Cacheability Control and Ordering Instructions

SSE2 cacheability control instructions provide additional operations for caching of non-temporal data when storing data from XMM registers to memory. LFENCE and MFENCE provide additional control of instruction ordering on store operations.

LFENCE: Serializes load operations.
MFENCE: Serializes load and store operations.

LFENCE - Load Fence

Performs a serializing operation on all load-from-memory instructions that were issued prior the LFENCE instruction. Specifically, LFENCE does not execute until all prior instructions have completed locally, and no later instruction begins execution until LFENCE completes. In particular, an instruction that loads from memory and that precedes an LFENCE receives data from memory prior to completion of the LFENCE. (An LFENCE that follows an instruction that stores to memory might complete before the data being stored have become globally visible.) Instructions following an LFENCE may be fetched from memory before the LFENCE, but they will not execute (even speculatively) until the LFENCE completes.

Weakly ordered memory types can be used to achieve higher processor performance through such techniques as out-of-order issue and speculative reads. The degree to which a consumer of data recognizes or knows that the data is weakly ordered varies among applications and may be unknown to the producer of this data. The LFENCE instruction provides a performance-efficient way of ensuring load ordering between routines that produce weakly-ordered results and routines that consume that data.

Processors are free to fetch and cache data speculatively from regions of system memory that use the WB, WC, and WT memory types. This speculative fetching can occur at any time and is not tied to instruction execution. Thus, it is not ordered with respect to executions of the LFENCE instruction; data can be brought into the caches speculatively just before, during, or after the execution of an LFENCE instruction.

This instruction’s operation is the same in non-64-bit modes and 64-bit mode.
Specification of the instruction's opcode above indicates a ModR/M byte of E8. For this instruction, the processor ignores the r/m field of the ModR/M byte. Thus, LFENCE is encoded by any opcode of the form 0F AE Ex, where x is in the range 8-F.

MFENCE - Memory Fence

Performs a serializing operation on all load-from-memory and store-to-memory instructions that were issued prior the MFENCE instruction. This serializing operation guarantees that every load and store instruction that precedes the MFENCE instruction in program order becomes globally visible before any load or store instruction that follows the MFENCE instruction.1 The MFENCE instruction is ordered with respect to all load and store instructions, other MFENCE instructions, any LFENCE and SFENCE instructions, and any serializing instructions (such as the CPUID instruction). MFENCE does not serialize the instruction stream.

Weakly ordered memory types can be used to achieve higher processor performance through such techniques as out-of-order issue, speculative reads, write-combining, and write-collapsing. The degree to which a consumer of data recognizes or knows that the data is weakly ordered varies among applications and may be unknown to the producer of this data. The MFENCE instruction provides a performance-efficient way of ensuring load and store ordering between routines that produce weakly-ordered results and routines that consume that data.

Processors are free to fetch and cache data speculatively from regions of system memory that use the WB, WC, and WT memory types. This speculative fetching can occur at any time and is not tied to instruction execution. Thus, it is not ordered with respect to executions of the MFENCE instruction; data can be brought into the caches specula- tively just before, during, or after the execution of an MFENCE instruction.

This instruction’s operation is the same in non-64-bit modes and 64-bit mode.

Specification of the instruction's opcode above indicates a ModR/M byte of F0. For this instruction, the processor ignores the r/m field of the ModR/M byte. Thus, MFENCE is encoded by any opcode of the form 0F AE Fx, where x is in the range 0-7.

Memory Ordering Instructions

SSE2 extensions introduce two new fence instructions (LFENCE and MFENCE) as companions to the SFENCE instruction introduced with SSE extensions.

The LFENCE instruction establishes a memory fence for loads. It guarantees ordering between two loads and prevents speculative loads from passing the load fence (that is, no speculative loads are allowed until all loads specified before the load fence have been carried out).

The MFENCE instruction establishes a memory fence for both loads and stores. The processor ensures that no load or store after MFENCE will become globally visible until all loads and stores before MFENCE are globally visible.
Note that the sequences LFENCE;SFENCE and SFENCE;LFENCE are not equivalent to MFENCE because neither ensures that older stores are globally observed prior to younger loads.

x86 架构下 Hotspot 对 volatile 变量的处理

x86 处理器仅会对 写-读 操作做重排序。

X86 不会对 读-读读-写写-写 操作做重排序,因此在 x86 处理器中会省略掉这三种操作类型对应的内存屏障。在 x86 中,JMM 仅需在 volatile 写后面插入一个StoreLoad屏障即可正确实现 volatile 写-读 的内存语义。

虽然 x86 Intel 有一个 MFENCE 内存屏障指令可以实现 volatile 写-读 的内存语义,但在 Hotspot JVM 虚拟机的实现上,HotSpot 是通过指令lock addl $0x0,(%rsp)实现刷缓冲的目的,而非 MFENCE,看源码注释应该是出于性能的考虑,关于 LOCK 前缀更多说明可参考下面 Intel 开发者手册,HotSpot 源码摘录部分如下:

http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/f3108e56b502/src/cpu/x86/vm/assembler_x86.hpp
1283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307
enum Membar_mask_bits {  StoreStore = 1 << 3,  LoadStore  = 1 << 2,  StoreLoad  = 1 << 1,  LoadLoad   = 1 << 0};// Serializes memory and blows flagsvoid membar(Membar_mask_bits order_constraint) {  if (os::is_MP()) {    // We only have to handle StoreLoad    if (order_constraint & StoreLoad) {      // All usable chips support "locked" instructions which suffice      // as barriers, and are much faster than the alternative of      // using cpuid instruction. We use here a locked add [esp],0.      // This is conveniently otherwise a no-op except for blowing      // flags.      // Any change to this code may need to revisit other places in      // the code where this idiom is used, in particular the      // orderAccess code.      lock();      addl(Address(rsp, 0), 0);// Assert the lock# signal here    }  }}

关于 Memory/Caches/MESI 说明 (The Art of Multiprocessor Programming)

Memory 和 Cache 的读写速度比较

Memory 和 L1 cache 的读写速度分别为0.5 ns100 ns,二者相差 200 倍,参考如下:

Latency Comparison Numbers (~2012)----------------------------------L1 cache reference                           0.5 nsBranch mispredict                            5   nsL2 cache reference                           7   ns                      14x L1 cacheMutex lock/unlock                           25   nsMain memory reference                      100   ns                      20x L2 cache, 200x L1 cacheCompress 1K bytes with Zippy             3,000   ns        3 usSend 1K bytes over 1 Gbps network       10,000   ns       10 usRead 4K randomly from SSD*             150,000   ns      150 us          ~1GB/sec SSDRead 1 MB sequentially from memory     250,000   ns      250 usRound trip within same datacenter      500,000   ns      500 usRead 1 MB sequentially from SSD*     1,000,000   ns    1,000 us    1 ms  ~1GB/sec SSD, 4X memoryDisk seek                           10,000,000   ns   10,000 us   10 ms  20x datacenter roundtripRead 1 MB sequentially from disk    20,000,000   ns   20,000 us   20 ms  80x memory, 20X SSDSend packet CA->Netherlands->CA    150,000,000   ns  150,000 us  150 msNotes-----1 ns = 10^-9 seconds1 us = 10^-6 seconds = 1,000 ns1 ms = 10^-3 seconds = 1,000 us = 1,000,000 nsCredit------By Jeff Dean:               http://research.google.com/people/jeff/Originally by Peter Norvig: http://norvig.com/21-days.html#answersContributions-------------'Humanized' comparison:  https://gist.github.com/hellerbarde/2843375Visual comparison chart: http://i.imgur.com/k0t1e.png

Memory

Processors share a main memory, which is a large array of words, indexed by address. Depending on the platform, a word is typically either 32 or 64 bits, and so is an address. Simplifying somewhat, a processor reads a value from memory by sending a message containing the desired address to memory. The response message contains the associated data, that is, the contents of memory at that address. A processor writes a value by sending the address and the new data to memory, and the memory sends back an acknowledgment when the new data has been installed.

Caches

Unfortunately, on modern architectures a main memory access may take hundreds of cycles, so there is a real danger that a processor may spend much of its time just waiting for the memory to respond to requests. We can alleviate this problem by introducing one or more caches: small memories that are situated closer to the processors and are therefore much faster than memory. These caches are logically situated “between” the processor and the memory: when a processor attempts to read a value from a given memory address, it first looks to see if the value is already in the cache, and if so, it does not need to perform the slower access to memory. If the desired address’s value was found, we say the processor hits in the cache, and otherwise it misses. In a similar way, if a processor attempts to write an address that is in the cache, it does not need to perform the slower access to memory. The proportion of requests satisfied in the cache is called the cache hit ratio (or hit rate).

Caches are effective because most programs display a high degree of locality: if a processor reads or writes a memory address (also called a memory location), then it is likely to read or write the same location again soon. Moreover, if a processor reads or writes a memory location, then it is also likely to read or write nearby locations soon. To exploit this second observation, caches typically operate at a granularity larger than a single word: a cache holds a group of neighboring words called cache lines (sometimes called cache blocks).

In practice, most processors have two levels of caches, called the L1 and L2 caches. The L1 cache typically resides on the same chip as the processor, and takes one or two cycles to access. The L2 cache may reside either on or off-chip, and may take tens of cycles to access. Both are significantly faster than the hundreds of cycles required to access the memory. Of course, these times vary from platform to platform, and many multiprocessors have even more elaborate cache structures.

The original proposals for NUMA architectures did not include caches because it was felt that local memory was enough. Later, however, commercial NUMA architectures did include caches. Sometimes the term cc-NUMA (for cachecoherent NUMA) is used to mean NUMA architectures with caches. Here, to avoid ambiguity, we use NUMA to include cache-coherence unless we explicitly state otherwise.

Caches are expensive to build and therefore significantly smaller than the memory: only a fraction of the memory locations will fit in a cache at the same time. We would therefore like the cache to maintain values of the most highly used locations. This implies that when a location needs to be cached and the cache is full, it is necessary to evict a line, discarding it if it has not been modified, and writing it back to main memory if it has. A replacement policy determines which cache line to replace to make room for a given new location. If the replacement policy is free to replace any line then we say the cache is fully associative. If, on the other hand, there is only one line that can be replaced then we say the cache is direct mapped. If we split the difference, allowing any line from a set of size k to be replaced to make room for a given line, then we say the cache is k-way set associative.

Coherence

Sharing (or, less politely, memory contention), occurs when one processor reads or writes a memory address that is cached by another. If both processors are reading the data without modifying it, then the data can be cached at both processors. If, however, one processor tries to update the shared cache line, then the other’s copy must be invalidated to ensure that it does not read an out-of-date value. In its most general form, this problem is called cache coherence. The literature contains a variety of very complex and clever cache coherence protocols. Here we review one of the most commonly used, called the MESI protocol (pronounced messy) after the names of possible cache line states. This protocol has been used in the Pentium and PowerPC processors. Here are the cache line states.

  1. Modified: the line has been modified in the cache. and it must eventually be written back to main memory. No other processor has this line cached.
  2. Exclusive: the line has not been modified, and no other processor has this line cached.
  3. Shared: the line has not been modified, and other processors may have this line cached.
  4. Invalid: the line does not contain meaningful data.

We illustrate this protocol by a short example depicted in Fig. B.5. For simplicity, we assume processors and memory are linked by a bus.

Processor A reads data from address a, and stores the data in its cache in the exclusive state. When processor B attempts to read from the same address, A detects the address conflict, and responds with the associated data. Now a is cached at both A and B in the shared state. If B writes to the shared address a, it changes its state to modified, and broadcasts a message warning A (and any other processor that might have that data cached) to set its cache line state to invalid. If A then reads from a, it broadcasts a request, and B responds by sending the modified data both to A and to the main memory, leaving both copies in the shared state.

False sharing occurs when processors that are accessing logically distinct data nevertheless conflict because the locations they are accessing lie on the same cache line. This observation illustrates a difficult tradeoff: large cache lines are good for locality, but they increase the likelihood of false sharing. The likelihood of false sharing can be reduced by ensuring that data objects that might be accessed concurrently by independent threads lie far enough apart in memory. For example, having multiple threads share a byte array invites false sharing, but having them share an array of double-precision integers is less dangerous.

Spinning

A processor is spinning if it is repeatedly testing some word in memory, waiting for another processor to change it. Depending on the architecture, spinning can have a dramatic effect on overall system performance.

On an SMP architecture without caches, spinning is a very bad idea. Each time the processor reads the memory, it consumes bus bandwidth without accomplishing any useful work. Because the bus is a broadcast medium, these requests directed to memory may prevent other processors from making progress.

On a NUMA architecture without caches, spinning may be acceptable if the address in question resides in the processor’s local memory. Even though multi-processor architectures without caches are rare, we will still ask when we consider a synchronization protocol that involves spinning, whether it permits each processor to spin on its own local memory.

On an SMP or NUMA architecture with caches, spinning consumes significantly fewer resources. The first time the processor reads the address, it takes a cache miss, and loads the contents of that address into a cache line. Thereafter, as long as that data remains unchanged, the processor simply rereads from its own cache, consuming no interconnect bandwidth, a process known as local spinning. When the cache state changes, the processor takes a single cache miss, observes that the data has changed, and stops spinning.

Relaxed Memory Consistency

When a processor writes a value to memory, that value is kept in the cache and marked as dirty, meaning that it must eventually be written back to main memory. On most modern processors, write requests are not applied to memory when they are issued. Rather, they are collected in a hardware queue, called a write buffer (or store buffer), and applied to memory together at a later time. A write buffer provides two benefits. First, it is often more efficient to issue a number of requests all at once, a phenomenon called batching. Second, if a thread writes to an address more than once, the earlier request can be discarded, saving a trip to memory, a phenomenon called write absorption.

x86 架构与内存屏障

cache 的引入一定程度上缓解了 cpus 与 memory 速度不匹配的问题,MESI 解决了 cache 数据一致性的问题。但是这又出现了新的问题,cpu 之间通信存在延迟,所以在 cpu 与 cache 中间加入了 store buffers,由于 store buffers 非常小,cpu 执行几个 store 操作就会把 buffer 填满, 这时候 CPU 必须等待 invalidation ACK 消息,来释放缓冲区空间 —— invalidation ACK 消息的记录会同步到 cache 中,并从 store buffer 中移除,所以在这里引入 Invalidate Queues。更多说明可参考 Paul Mckenney 的 Memory Barriers: a Hardware View for Software Hackers 和兰州大学 MARS Team 的这个 PPT,Memory Barriers: a Hardware View for Software Hackers,x86 系统架构示意图如下:

Interconnect

The interconnect is the medium by which processors communicate with the memory and with other processors. There are essentially two kinds of interconnect architectures in use: SMP (symmetric multiprocessing) and NUMA (nonuniform memory access).
In an SMP architecture, processors and memory are linked by a bus interconnect, a broadcast medium that acts like a tiny Ethernet. Both processors and the main memory have bus controller units in charge of sending and listening for messages broadcast on the bus. (Listening is sometimes called snooping). Today, SMP architectures are the most common, because they are the easiest to build, but they are not scalable to large numbers of processors because eventually the bus becomes overloaded.

Store Buffer

Intel 64 and IA-32 processors temporarily store each write (store) to memory in a store buffer. The store buffer improves processor performance by allowing the processor to continue executing instructions without having to wait until a write to memory and/or to a cache is complete. It also allows writes to be delayed for more efficient use of memory-access bus cycles.

In general, the existence of the store buffer is transparent to software, even in systems that use multiple processors. The processor ensures that write operations are always carried out in program order. It also insures that the contents of the store buffer are always drained to memory in the following situations:

  1. When an exception or interrupt is generated.
  2. (P6 and more recent processor families only) When a serializing instruction is executed.
  3. When an I/O instruction is executed.
  4. When a LOCK operation is performed.
  5. (P6 and more recent processor families only) When a BINIT operation is performed.
  6. (Pentium III, and more recent processor families only) When using an SFENCE instruction to order stores.
  7. (Pentium 4 and more recent processor families only) When using an MFENCE instruction to order stores.

CPU Intel i7 Shared L3 Cache

上图中每个核上有两个 L1 Cache, L1-D 存数据, L1-I 存指令。

有了invalidate queue的 CPU,在收到 invalidate 消息的时候首先把它放入invalidate queue,同时立刻回送 acknowledge 消息,无需等到该 cacheline 被真正 invalidate 之后再回应。

读屏障作用于invalidate queue,每次遇到这个指令都将自己积压已久的invalidate ack处理掉,具体就是使得对应的缓存失效,这样自己再读的时候,能保证读到最新的副本。
写屏障作用于store buffer,将处于store buffer中的写操作真正执行掉,具体就是向其他 CPU 发送invalidate cache的消息,写自己的独占缓存。
全能型屏障这两件事都做。

关于为何禁止重排序的说明

可以从上面VolatileExample例子中对flag = true的写操作示意图可以看到,这个操作之前的另一个写操作a = 1的结果也会因为缓存中的共享变量会被写回主存,而让别的线程一定能看到a的值也发生变化了,如果在 volatile 写之前的其他写操作重排序到 volatile 写之后,那该变量的写操作就不一定能被别的线程所观察到,这是为了保证共享变量的可见性。同样 volatile 写之前的普通读操作不能重排序到写之后,可以避免读到别的线程在 volatile 写之后对普通读共享变量的修改,读到不应该读到的新值。

普通读和 volatile 写重排序说明

举例 2 个线程正确的次序如下:

执行次序 线程 A 线程 B
1 load shared a;
2 store volatile b;
3 load volatile b;
4 store shared a;

如果线程 A 中的普通读可以重排序到 volatile 写之后,则可能产生如下的执行次序:

执行次序 线程 A 线程 B
1 store volatile b;
2 load volatile b;
3 store shared a;
4 load shared a; // wrong  

volatile 读和普通写重排序说明

volatile 读操作会清空本地共享变量的缓存。

执行次序 线程 A 线程 B
1 load volatile a;
2 load shared b;
3 load volatile a;
4 store shared b;  

如果普通写可以重排序到 volatile 写之前的话,另一个线程 B 就可能看到不应该看到的b的新值。

执行次序 线程 A 线程 B
4 store shared b; // wrong
1 load volatile a;
2 load shared b;
3 load volatile a;  

volatile 读和普通读重排序说明

普通读重排序到 volatile 读之前也可能会因为别的线程对此共享变量有更新而没有正确读到更新的值。举例 2 个线程正确的执行次序如下:

执行次序 线程 A 线程 B
1 store shared b;
2 store volatile a;
3 load volatile a;
4 load shared b;  

如果线程 A 中的普通共享变量b的读操作重排序到 volatile 读之前,那可能产生的执行次序:

执行次序 线程 A 线程 B
1 load shared b; // wrong
2 store shared b;
3 store volatile a;
4 load volatile a;  

那共享变量b已经把旧值从缓存中加载到 CPU 中,然后 volatile 读清掉缓存,没有重排序情况下会从主存中加载到最新的b值,但上面重排序之后共享变量b没有在正确读到更新后的值。

总结一句话就是:

重排序会造成共享变量在不同线程之间的可见性问题。

JSR-133 为什么要增强 volatile 的内存语义

JSR-133之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量之间重排序。在旧的内存模型中,VolatileExample 示例程序可能被重排序成下列时序来执行:

在旧的内存模型中,当 1 和 2 之间没有数据依赖关系时,1 和 2 之间就可能被重排序(3 和 4 类似)。

其结果就是:

读线程 B 执行 4 时,不一定能看到写线程 A 在执行 1 时对共享变量的修改。

因此在旧的内存模型中 ,volatile 的写-读没有监视器的释放/获取所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强 volatile 的内存语义:

严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和监视器的释放/获取一样,具有相同的内存语义。

从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果读者想在程序中用 volatile 代替监视器锁,请一定谨慎。

java -XX:+PrintAssembly 查看 volatile 底层实现

使用-XX:PrintAssembly来查看程序运行时输出的反汇编结果,其中在注释里也给了一些文档链接,并且需要下载平台相关的hsdis-xxxxx.xxx文件,更多细节可查看 https://github.com/yuweijun/hsdis 项目说明,并将此文件放到${JAVA_HOME}/jre/lib/server/目录或者是${JAVA_HOME}/jre/lib/amd64/server/目录下。

12345678910111213141516171819202122232425262728
/** * PrintAssembly 相关的文档参考 * * http://psy-lob-saw.blogspot.com/2013/01/java-print-assembly.html * * https://wiki.openjdk.java.net/display/HotSpot/PrintAssembly * * http://www.cs.virginia.edu/~evans/cs216/guides/x86.html * * <pre> * $ sudo cp build/macosx-amd64/hsdis-amd64.dylib $(/usr/libexec/java_home -v 1.8)/jre/lib/server/ * ## or * $ sudo cp build/linux-amd64/hsdis-amd64.so /usr/lib/jvm/java-8-oracle/jre/lib/amd64/server/ * <pre> * * @author yuweijun 2019-03-30. */public class PrintAssemblyVolatile {    private volatile int i = 0;    public static void main(String[] args) {        PrintAssemblyVolatile example = new PrintAssemblyVolatile();        example.i++;        System.out.println(example.i);    }}

编译上面的代码之后,用如下命令运行此程序:

 java -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+PrintAssembly -XX:+LogCompilation -XX:LogFile=jit.log PrintAssemblyVolatile

程序输出内容比较多,生成的jit.log文件可以使用 JITWatch 工具进行查看,其中关键部分如下所示,注意其中行号6370行这部分关于 volatile 共享变量的getfield iputfield i的操作,x86架构的 CPU 中只在 volatile 写之后加了lock前缀以达到内存屏障效果:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
<nmethod compile_id='1057' compile_kind='c2n' level='0' entry='0x00000001055116c0' size='832' address='0x0000000105511550' relocation_offset='296' consts_offset='832' insts_offset='368' method='java/lang/Class getDeclaringClass0 ()Ljava/lang/Class;' bytes='0' count='0' iicount='0' stamp='8.649'/><task_queued compile_id='1058' method='com/example/lang/PrintAssemblyVolatile main ([Ljava/lang/String;)V' bytes='29' count='0' iicount='0' level='3' blocking='1' stamp='8.649' comment='must_be_compiled'/><writer thread='14595'/>Decoding compiled method 0x0000000105510e10:Code:[Entry Point][Verified Entry Point][Constants]  # {method} {0x0000000118cff320} 'main' '([Ljava/lang/String;)V' in 'com/example/lang/PrintAssemblyVolatile'  # parm0:    rsi:rsi   = '[Ljava/lang/String;'  #           [sp+0x60]  (sp of caller)  0x0000000105510fa0: mov    %eax,-0x14000(%rsp)  0x0000000105510fa7: push   %rbp  0x0000000105510fa8: sub    $0x50,%rsp  0x0000000105510fac: movabs $0x118cff448,%rdx  ;   {metadata(method data for {method} {0x0000000118cff320} 'main' '([Ljava/lang/String;)V' in 'com/example/lang/PrintAssemblyVolatile')}  0x0000000105510fb6: mov    0xdc(%rdx),%edi  0x0000000105510fbc: add    $0x8,%edi  0x0000000105510fbf: mov    %edi,0xdc(%rdx)  0x0000000105510fc5: movabs $0x118cff320,%rdx  ;   {metadata({method} {0x0000000118cff320} 'main' '([Ljava/lang/String;)V' in 'com/example/lang/PrintAssemblyVolatile')}  0x0000000105510fcf: and    $0x0,%edi  0x0000000105510fd2: cmp    $0x0,%edi  0x0000000105510fd5: je     0x0000000105511188  0x0000000105510fdb: nopl   0x0(%rax,%rax,1)  0x0000000105510fe0: jmpq   0x00000001055111ae  ;   {no_reloc}  0x0000000105510fe5: add    %al,(%rax)  0x0000000105510fe7: add    %al,(%rax)  0x0000000105510fe9: add    %cl,-0x75(%rcx)  0x0000000105510fec: rex.RXB (bad)  0x0000000105510fee: lea    0x10(%rax),%rdi  0x0000000105510ff2: cmp    0x70(%r15),%rdi  0x0000000105510ff6: ja     0x00000001055111b8  0x0000000105510ffc: mov    %rdi,0x60(%r15)  0x0000000105511000: mov    0xa8(%rdx),%rcx  0x0000000105511007: mov    %rcx,(%rax)  0x000000010551100a: mov    %rdx,%rcx  0x000000010551100d: shr    $0x3,%rcx  0x0000000105511011: mov    %ecx,0x8(%rax)  0x0000000105511014: xor    %rcx,%rcx  0x0000000105511017: mov    %ecx,0xc(%rax)  0x000000010551101a: xor    %rcx,%rcx          ;*new  ; - com.example.lang.PrintAssemblyVolatile::main@0 (line 21)  0x000000010551101d: mov    %rax,%rdx  0x0000000105511020: movabs $0x118cff448,%rsi  ;   {metadata(method data for {method} {0x0000000118cff320} 'main' '([Ljava/lang/String;)V' in 'com/example/lang/PrintAssemblyVolatile')}  0x000000010551102a: addq   $0x1,0x108(%rsi)  0x0000000105511032: movabs $0x118cff5d0,%rdx  ;   {metadata(method data for {method} {0x0000000118cff258} '<init>' '()V' in 'com/example/lang/PrintAssemblyVolatile')}  0x000000010551103c: mov    0xdc(%rdx),%esi  0x0000000105511042: add    $0x8,%esi  0x0000000105511045: mov    %esi,0xdc(%rdx)  0x000000010551104b: movabs $0x118cff258,%rdx  ;   {metadata({method} {0x0000000118cff258} '<init>' '()V' in 'com/example/lang/PrintAssemblyVolatile')}  0x0000000105511055: and    $0x7ffff8,%esi  0x000000010551105b: cmp    $0x0,%esi  0x000000010551105e: je     0x00000001055111c5  0x0000000105511064: mov    %rax,%rdx  0x0000000105511067: movabs $0x118cff5d0,%rsi  ;   {metadata(method data for {method} {0x0000000118cff258} '<init>' '()V' in 'com/example/lang/PrintAssemblyVolatile')}  0x0000000105511071: addq   $0x1,0x108(%rsi)  0x0000000105511079: movabs $0x118a76490,%rdx  ;   {metadata(method data for {method} {0x00000001188ff480} '<init>' '()V' in 'java/lang/Object')}  0x0000000105511083: mov    0xdc(%rdx),%esi  0x0000000105511089: add    $0x8,%esi  0x000000010551108c: mov    %esi,0xdc(%rdx)  0x0000000105511092: movabs $0x1188ff480,%rdx  ;   {metadata({method} {0x00000001188ff480} '<init>' '()V' in 'java/lang/Object')}  0x000000010551109c: and    $0x7ffff8,%esi  0x00000001055110a2: cmp    $0x0,%esi  0x00000001055110a5: je     0x00000001055111dc  0x00000001055110ab: mov    0xc(%rax),%edx     ;*getfield i                                                ; - com.example.lang.PrintAssemblyVolatile::main@10 (line 22)  0x00000001055110ae: inc    %edx  0x00000001055110b0: mov    %edx,0xc(%rax)  0x00000001055110b3: lock addl $0x0,(%rsp)     ;*putfield i                                                ; - com.example.lang.PrintAssemblyVolatile::main@15 (line 22)  0x00000001055110b8: jmpq   0x0000000105511250  ;   {no_reloc}  0x00000001055110bd: add    %al,(%rax)  0x00000001055110bf: add    %al,(%rax)  0x00000001055110c1: add    %ah,0xf(%rsi)  0x00000001055110c4: (bad)  0x00000001055110c5: add    %r8b,(%rax)  0x00000001055110c8: jmpq   0x000000010551126a  ; implicit exception: dispatches to 0x000000010551125a  0x00000001055110cd: nop  0x00000001055110ce: shl    $0x3,%rsi          ;*getstatic out                                                ; - com.example.lang.PrintAssemblyVolatile::main@18 (line 23)  0x00000001055110d2: mov    0xc(%rax),%edx     ;*getfield i                                                ; - com.example.lang.PrintAssemblyVolatile::main@22 (line 23)  0x00000001055110d5: cmp    (%rsi),%rax        ;*invokevirtual println                                                ; - com.example.lang.PrintAssemblyVolatile::main@25 (line 23)                                                ; implicit exception: dispatches to 0x0000000105511274  0x00000001055110d8: mov    %rsi,%rdi  0x00000001055110db: movabs $0x118cff448,%rbx  ;   {metadata(method data for {method} {0x0000000118cff320} 'main' '([Ljava/lang/String;)V' in 'com/example/lang/PrintAssemblyVolatile')}  0x00000001055110e5: mov    0x8(%rdi),%edi  0x00000001055110e8: shl    $0x3,%rdi  0x00000001055110ec: cmp    0x120(%rbx),%rdi  0x00000001055110f3: jne    0x0000000105511102  0x00000001055110f5: addq   $0x1,0x128(%rbx)  0x00000001055110fd: jmpq   0x0000000105511168  0x0000000105511102: cmp    0x130(%rbx),%rdi  0x0000000105511109: jne    0x0000000105511118  0x000000010551110b: addq   $0x1,0x138(%rbx)  0x0000000105511113: jmpq   0x0000000105511168  0x0000000105511118: cmpq   $0x0,0x120(%rbx)  0x0000000105511123: jne    0x000000010551113c  0x0000000105511125: mov    %rdi,0x120(%rbx)  0x000000010551112c: movq   $0x1,0x128(%rbx)  0x0000000105511137: jmpq   0x0000000105511168  0x000000010551113c: cmpq   $0x0,0x130(%rbx)  0x0000000105511147: jne    0x0000000105511160  0x0000000105511149: mov    %rdi,0x130(%rbx)  0x0000000105511150: movq   $0x1,0x138(%rbx)  0x000000010551115b: jmpq   0x0000000105511168  0x0000000105511160: addq   $0x1,0x118(%rbx)  0x0000000105511168: nop  0x0000000105511169: nop  0x000000010551116a: nop  0x000000010551116b: nop  0x000000010551116c: nop  0x000000010551116d: movabs $0xffffffffffffffff,%rax  0x0000000105511177: callq  0x00000001051442e0  ; OopMap{off=476}                                                ;*invokevirtual println                                                ; - com.example.lang.PrintAssemblyVolatile::main@25 (line 23)                                                ;   {virtual_call}  0x000000010551117c: add    $0x50,%rsp  0x0000000105511180: pop    %rbp  0x0000000105511181: test   %eax,-0x2489087(%rip)        # 0x0000000103088100                                                ;   {poll_return}  0x0000000105511187: retq  0x0000000105511188: mov    %rdx,0x8(%rsp)  0x000000010551118d: movq   $0xffffffffffffffff,(%rsp)  0x0000000105511195: callq  0x00000001051fc420  ; OopMap{rsi=Oop off=506}                                                ;*synchronization entry                                                ; - com.example.lang.PrintAssemblyVolatile::main@-1 (line 21)                                                ;   {runtime_call}  0x000000010551119a: jmpq   0x0000000105510fdb  0x000000010551119f: movabs $0x0,%rdx          ;   {metadata(NULL)}  0x00000001055111a9: mov    $0xa050f00,%eax  0x00000001055111ae: callq  0x00000001051fb2e0  ; OopMap{off=531}                                                ;*new  ; - com.example.lang.PrintAssemblyVolatile::main@0 (line 21)                                                ;   {runtime_call}  0x00000001055111b3: jmpq   0x0000000105510fe0  0x00000001055111b8: mov    %rdx,%rdx  0x00000001055111bb: callq  0x00000001051f83e0  ; OopMap{off=544}                                                ;*new  ; - com.example.lang.PrintAssemblyVolatile::main@0 (line 21)                                                ;   {runtime_call}  0x00000001055111c0: jmpq   0x000000010551101d  0x00000001055111c5: mov    %rdx,0x8(%rsp)  0x00000001055111ca: movq   $0xffffffffffffffff,(%rsp)  0x00000001055111d2: callq  0x00000001051fc420  ; OopMap{rax=Oop off=567}                                                ;*synchronization entry                                                ; - com.example.lang.PrintAssemblyVolatile::<init>@-1 (line 16)                                                ; - com.example.lang.PrintAssemblyVolatile::main@4 (line 21)                                                ;   {runtime_call}  0x00000001055111d7: jmpq   0x0000000105511064  0x00000001055111dc: mov    %rdx,0x8(%rsp)  0x00000001055111e1: movq   $0xffffffffffffffff,(%rsp)  0x00000001055111e9: callq  0x00000001051fc420  ; OopMap{rax=Oop off=590}                                                ;*synchronization entry                                                ; - java.lang.Object::<init>@-1 (line 37)                                                ; - com.example.lang.PrintAssemblyVolatile::<init>@1 (line 16)                                                ; - com.example.lang.PrintAssemblyVolatile::main@4 (line 21)                                                ;   {runtime_call}  0x00000001055111ee: jmpq   0x00000001055110ab  0x00000001055111f3: movabs $0x0,%rdx          ;   {oop(NULL)}  0x00000001055111fd: push   %rax  0x00000001055111fe: push   %rbx  0x00000001055111ff: mov    0x48(%rdx),%rbx  0x0000000105511203: push   %rdi  0x0000000105511204: push   %rsi  0x0000000105511205: push   %rdx  0x0000000105511206: push   %rcx  0x0000000105511207: push   %r8  0x0000000105511209: push   %r9  0x000000010551120b: push   %r10  0x000000010551120d: mov    %rsp,%r10  0x0000000105511210: and    $0xfffffffffffffff0,%rsp  0x0000000105511214: push   %r10  0x0000000105511216: push   %r11  0x0000000105511218: mov    $0x109,%edi  0x000000010551121d: movabs $0x7fff58689992,%r10  ;   {runtime_call}  0x0000000105511227: callq  *%r10  0x000000010551122a: pop    %r11  0x000000010551122c: pop    %rsp  0x000000010551122d: pop    %r10  0x000000010551122f: pop    %r9  0x0000000105511231: pop    %r8  0x0000000105511233: pop    %rcx  0x0000000105511234: pop    %rdx  0x0000000105511235: pop    %rsi  0x0000000105511236: pop    %rdi  0x0000000105511237: cmp    0x118(%rbx),%rax  0x000000010551123e: pop    %rbx  0x000000010551123f: pop    %rax  0x0000000105511240: jne    0x0000000105511250  0x0000000105511246: jmpq   0x00000001055110c2  0x000000010551124b: mov    $0xa535d00,%eax  0x0000000105511250: callq  0x00000001051fb6e0  ; OopMap{rax=Oop off=693}                                                ;*getstatic out                                                ; - com.example.lang.PrintAssemblyVolatile::main@18 (line 23)                                                ;   {runtime_call}  0x0000000105511255: jmpq   0x00000001055110b8  0x000000010551125a: callq  0x00000001051f7c80  ; OopMap{rax=Oop rdx=Oop off=703}                                                ;*getstatic out                                                ; - com.example.lang.PrintAssemblyVolatile::main@18 (line 23)                                                ;   {runtime_call}  0x000000010551125f: mov    0x0(%rdx),%esi  0x0000000105511265: mov    $0x6050b00,%eax  0x000000010551126a: callq  0x00000001051faee0  ; OopMap{rax=Oop rdx=Oop off=719}                                                ;*getstatic out                                                ; - com.example.lang.PrintAssemblyVolatile::main@18 (line 23)                                                ;   {runtime_call}  0x000000010551126f: jmpq   0x00000001055110c8  0x0000000105511274: callq  0x00000001051f7c80  ; OopMap{rsi=Oop off=729}                                                ;*invokevirtual println                                                ; - com.example.lang.PrintAssemblyVolatile::main@25 (line 23)                                                ;   {runtime_call}  0x0000000105511279: nop  0x000000010551127a: nop  0x000000010551127b: mov    0x2a8(%r15),%rax  0x0000000105511282: movabs $0x0,%r10  0x000000010551128c: mov    %r10,0x2a8(%r15)  0x0000000105511293: movabs $0x0,%r10  0x000000010551129d: mov    %r10,0x2b0(%r15)  0x00000001055112a4: add    $0x50,%rsp  0x00000001055112a8: pop    %rbp  0x00000001055112a9: jmpq   0x000000010516a6e0  ;   {runtime_call}  0x00000001055112ae: hlt  0x00000001055112af: hlt  0x00000001055112b0: hlt  0x00000001055112b1: hlt  0x00000001055112b2: hlt  0x00000001055112b3: hlt  0x00000001055112b4: hlt  0x00000001055112b5: hlt  0x00000001055112b6: hlt  0x00000001055112b7: hlt  0x00000001055112b8: hlt  0x00000001055112b9: hlt  0x00000001055112ba: hlt  0x00000001055112bb: hlt  0x00000001055112bc: hlt  0x00000001055112bd: hlt  0x00000001055112be: hlt  0x00000001055112bf: hlt[Stub Code]  0x00000001055112c0: nop                       ;   {no_reloc}  0x00000001055112c1: nop  0x00000001055112c2: nop  0x00000001055112c3: nop  0x00000001055112c4: nop  0x00000001055112c5: movabs $0x0,%rbx          ;   {static_stub}  0x00000001055112cf: jmpq   0x00000001055112cf  ;   {runtime_call}[Exception Handler]  0x00000001055112d4: callq  0x00000001051f9b20  ;   {runtime_call}  0x00000001055112d9: mov    %rsp,-0x28(%rsp)  0x00000001055112de: sub    $0x80,%rsp  0x00000001055112e5: mov    %rax,0x78(%rsp)  0x00000001055112ea: mov    %rcx,0x70(%rsp)  0x00000001055112ef: mov    %rdx,0x68(%rsp)  0x00000001055112f4: mov    %rbx,0x60(%rsp)  0x00000001055112f9: mov    %rbp,0x50(%rsp)  0x00000001055112fe: mov    %rsi,0x48(%rsp)  0x0000000105511303: mov    %rdi,0x40(%rsp)  0x0000000105511308: mov    %r8,0x38(%rsp)  0x000000010551130d: mov    %r9,0x30(%rsp)  0x0000000105511312: mov    %r10,0x28(%rsp)  0x0000000105511317: mov    %r11,0x20(%rsp)  0x000000010551131c: mov    %r12,0x18(%rsp)  0x0000000105511321: mov    %r13,0x10(%rsp)  0x0000000105511326: mov    %r14,0x8(%rsp)  0x000000010551132b: mov    %r15,(%rsp)  0x000000010551132f: movabs $0x104725c8c,%rdi  ;   {external_word}  0x0000000105511339: movabs $0x1055112d9,%rsi  ;   {internal_word}  0x0000000105511343: mov    %rsp,%rdx  0x0000000105511346: and    $0xfffffffffffffff0,%rsp  0x000000010551134a: callq  0x000000010454e8f2  ;   {runtime_call}  0x000000010551134f: hlt[Deopt Handler Code]  0x0000000105511350: movabs $0x105511350,%r10  ;   {section_word}  0x000000010551135a: push   %r10  0x000000010551135c: jmpq   0x0000000105145500  ;   {runtime_call}  0x0000000105511361: hlt  0x0000000105511362: hlt  0x0000000105511363: hlt  0x0000000105511364: hlt  0x0000000105511365: hlt  0x0000000105511366: hlt  0x0000000105511367: hlt<nmethod compile_id='1058' compiler='C1' level='3' entry='0x0000000105510fa0' size='1824' address='0x0000000105510e10' relocation_offset='296' insts_offset='400' stub_offset='1200' scopes_data_offset='1400' scopes_pcs_offset='1504' dependencies_offset='1792' nul_chk_table_offset='1800' oops_offset='1368' method='com/example/lang/PrintAssemblyVolatile main ([Ljava/lang/String;)V' bytes='29' count='0' iicount='0' stamp='8.653'/><writer thread='9987'/><task_queued compile_id='1059' method='com/example/lang/PrintAssemblyVolatile main ([Ljava/lang/String;)V' bytes='29' count='1' iicount='1' blocking='1' stamp='8.653' comment='tiered' hot_count='1'/><writer thread='14339'/>Decoding compiled method 0x0000000105407090:Code:[Entry Point][Verified Entry Point][Constants]  # {method} {0x0000000118cff320} 'main' '([Ljava/lang/String;)V' in 'com/example/lang/PrintAssemblyVolatile'  # parm0:    rsi:rsi   = '[Ljava/lang/String;'  #           [sp+0x20]  (sp of caller)  0x00000001054071e0: mov    %eax,-0x14000(%rsp)  0x00000001054071e7: push   %rbp  0x00000001054071e8: sub    $0x10,%rsp         ;*synchronization entry                                                ; - com.example.lang.PrintAssemblyVolatile::main@-1 (line 21)  0x00000001054071ec: mov    $0x3,%esi  0x00000001054071f1: xchg   %ax,%ax  0x00000001054071f3: callq  0x00000001051436a0  ; OopMap{off=24}                                                ;*new  ; - com.example.lang.PrintAssemblyVolatile::main@0 (line 21)                                                ;   {runtime_call}  0x00000001054071f8: callq  0x00000001045d1f64  ;*new                                                ; - com.example.lang.PrintAssemblyVolatile::main@0 (line 21)                                                ;   {runtime_call}  0x00000001054071fd: hlt  0x00000001054071fe: hlt  0x00000001054071ff: hlt[Exception Handler][Stub Code]  0x0000000105407200: jmpq   0x000000010516a9a0  ;   {no_reloc}[Deopt Handler Code]  0x0000000105407205: callq  0x000000010540720a  0x000000010540720a: subq   $0x5,(%rsp)  0x000000010540720f: jmpq   0x0000000105145500  ;   {runtime_call}  0x0000000105407214: hlt  0x0000000105407215: hlt  0x0000000105407216: hlt  0x0000000105407217: hlt<nmethod compile_id='1059' compiler='C2' level='4' entry='0x00000001054071e0' size='520' address='0x0000000105407090' relocation_offset='296' insts_offset='336' stub_offset='368' scopes_data_offset='408' scopes_pcs_offset='432' dependencies_offset='512' oops_offset='392' method='com/example/lang/PrintAssemblyVolatile main ([Ljava/lang/String;)V' bytes='29' count='1' iicount='1' stamp='8.654'/><make_not_entrant thread='14339' compile_id='1058' compiler='C1' level='3' stamp='8.654'/><writer thread='9987'/><task_queued compile_id='1060' method='java/io/PrintStream println (I)V' bytes='24' count='0' iicount='0' level='3' blocking='1' stamp='8.654' comment='must_be_compiled'/><writer thread='14595'/>Decoding compiled method 0x00000001055170d0:Code:[Entry Point][Constants]  # {method} {0x0000000118a46618} 'println' '(I)V' in 'java/io/PrintStream'  # this:     rsi:rsi   = 'java/io/PrintStream'  # parm0:    rdx       = int  #           [sp+0x70]  (sp of caller)  0x0000000105517280: mov    0x8(%rsi),%r10d  0x0000000105517284: shl    $0x3,%r10  0x0000000105517288: cmp    %rax,%r10  0x000000010551728b: jne    0x0000000105143e60  ;   {runtime_call}  0x0000000105517291: data16 data16 nopw 0x0(%rax,%rax,1)  0x000000010551729c: data16 data16 xchg %ax,%ax[Verified Entry Point]  0x00000001055172a0: mov    %eax,-0x14000(%rsp)  0x00000001055172a7: push   %rbp  0x00000001055172a8: sub    $0x60,%rsp  0x00000001055172ac: movabs $0x118bcb1b8,%rax  ;   {metadata(method data for {method} {0x0000000118a46618} 'println' '(I)V' in 'java/io/PrintStream')}  0x00000001055172b6: mov    0xdc(%rax),%edi  0x00000001055172bc: add    $0x8,%edi  0x00000001055172bf: mov    %edi,0xdc(%rax)  0x00000001055172c5: movabs $0x118a46618,%rax  ;   {metadata({method} {0x0000000118a46618} 'println' '(I)V' in 'java/io/PrintStream')}  0x00000001055172cf: and    $0x0,%edi  0x00000001055172d2: cmp    $0x0,%edi  0x00000001055172d5: je     0x0000000105517550  ;*aload_0                                                ; - java.io.PrintStream::println@0 (line 735)  0x00000001055172db: lea    0x48(%rsp),%rdi  0x00000001055172e0: mov    %rsi,0x8(%rdi)  0x00000001055172e4: mov    (%rsi),%rax  0x00000001055172e7: mov    %rax,%rbx  0x00000001055172ea: and    $0x7,%rbx  0x00000001055172ee: cmp    $0x5,%rbx  0x00000001055172f2: jne    0x0000000105517379  0x00000001055172f8: mov    0x8(%rsi),%ebx  0x00000001055172fb: shl    $0x3,%rbx  0x00000001055172ff: mov    0xa8(%rbx),%rbx  0x0000000105517306: or     %r15,%rbx  0x0000000105517309: xor    %rax,%rbx  0x000000010551730c: and    $0xffffffffffffff87,%rbx  0x0000000105517310: je     0x00000001055173a1  0x0000000105517316: test   $0x7,%rbx  0x000000010551731d: jne    0x0000000105517366  0x000000010551731f: test   $0x300,%rbx  0x0000000105517326: jne    0x0000000105517345  0x0000000105517328: and    $0x37f,%rax  0x000000010551732f: mov    %rax,%rbx  0x0000000105517332: or     %r15,%rbx  0x0000000105517335: lock cmpxchg %rbx,(%rsi)  0x000000010551733a: jne    0x0000000105517567  0x0000000105517340: jmpq   0x00000001055173a1  0x0000000105517345: mov    0x8(%rsi),%ebx  0x0000000105517348: shl    $0x3,%rbx  0x000000010551734c: mov    0xa8(%rbx),%rbx  0x0000000105517353: or     %r15,%rbx  0x0000000105517356: lock cmpxchg %rbx,(%rsi)  0x000000010551735b: jne    0x0000000105517567  0x0000000105517361: jmpq   0x00000001055173a1  0x0000000105517366: mov    0x8(%rsi),%ebx  0x0000000105517369: shl    $0x3,%rbx  0x000000010551736d: mov    0xa8(%rbx),%rbx  0x0000000105517374: lock cmpxchg %rbx,(%rsi)  0x0000000105517379: mov    (%rsi),%rax  0x000000010551737c: or     $0x1,%rax  0x0000000105517380: mov    %rax,(%rdi)  0x0000000105517383: lock cmpxchg %rdi,(%rsi)  0x0000000105517388: je     0x00000001055173a1  0x000000010551738e: sub    %rsp,%rax  0x0000000105517391: and    $0xfffffffffffff007,%rax  0x0000000105517398: mov    %rax,(%rdi)  0x000000010551739b: jne    0x0000000105517567  ;*monitorenter                                                ; - java.io.PrintStream::println@3 (line 735)  0x00000001055173a1: mov    %rsi,%rdi  0x00000001055173a4: movabs $0x118bcb1b8,%rbx  ;   {metadata(method data for {method} {0x0000000118a46618} 'println' '(I)V' in 'java/io/PrintStream')}  0x00000001055173ae: movabs $0x7c0027950,%r10  ;   {metadata('java/io/PrintStream')}  0x00000001055173b8: mov    %r10,0x110(%rbx)  0x00000001055173bf: addq   $0x1,0x118(%rbx)  0x00000001055173c7: movabs $0x118bcb360,%rdi  ;   {metadata(method data for {method} {0x0000000118a45fa8} 'print' '(I)V' in 'java/io/PrintStream')}  0x00000001055173d1: mov    0xdc(%rdi),%ebx  0x00000001055173d7: add    $0x8,%ebx  0x00000001055173da: mov    %ebx,0xdc(%rdi)  0x00000001055173e0: movabs $0x118a45fa8,%rdi  ;   {metadata({method} {0x0000000118a45fa8} 'print' '(I)V' in 'java/io/PrintStream')}  0x00000001055173ea: and    $0x7ffff8,%ebx  0x00000001055173f0: cmp    $0x0,%ebx  0x00000001055173f3: je     0x000000010551757a  0x00000001055173f9: movabs $0x118bcb360,%rdi  ;   {metadata(method data for {method} {0x0000000118a45fa8} 'print' '(I)V' in 'java/io/PrintStream')}  0x0000000105517403: addq   $0x1,0x108(%rdi)  0x000000010551740b: movabs $0x118bcb4d0,%rdi  ;   {metadata(method data for {method} {0x0000000118906500} 'valueOf' '(I)Ljava/lang/String;' in 'java/lang/String')}  0x0000000105517415: mov    0xdc(%rdi),%ebx  0x000000010551741b: add    $0x8,%ebx  0x000000010551741e: mov    %ebx,0xdc(%rdi)  0x0000000105517424: movabs $0x118906500,%rdi  ;   {metadata({method} {0x0000000118906500} 'valueOf' '(I)Ljava/lang/String;' in 'java/lang/String')}  0x000000010551742e: and    $0x7ffff8,%ebx  0x0000000105517434: cmp    $0x0,%ebx  0x0000000105517437: je     0x0000000105517591  0x000000010551743d: movabs $0x118bcb4d0,%rdi  ;   {metadata(method data for {method} {0x0000000118906500} 'valueOf' '(I)Ljava/lang/String;' in 'java/lang/String')}  0x0000000105517447: addq   $0x1,0x108(%rdi)  0x000000010551744f: mov    %rsi,0x40(%rsp)  0x0000000105517454: mov    %rdx,%rsi          ;*invokestatic toString                                                ; - java.lang.String::valueOf@1 (line 3099)                                                ; - java.io.PrintStream::print@2 (line 597)                                                ; - java.io.PrintStream::println@6 (line 736)  0x0000000105517457: callq  0x0000000105144520  ; OopMap{[64]=Oop [80]=Oop off=476}                                                ;*invokestatic toString                                                ; - java.lang.String::valueOf@1 (line 3099)                                                ; - java.io.PrintStream::print@2 (line 597)                                                ; - java.io.PrintStream::println@6 (line 736)                                                ;   {static_call}  0x000000010551745c: mov    0x40(%rsp),%rsi  0x0000000105517461: movabs $0x118bcb360,%rdx  ;   {metadata(method data for {method} {0x0000000118a45fa8} 'print' '(I)V' in 'java/io/PrintStream')}  0x000000010551746b: addq   $0x1,0x118(%rdx)  0x0000000105517473: mov    %rax,%rdx  0x0000000105517476: mov    0x40(%rsp),%rsi    ;*invokespecial write                                                ; - java.io.PrintStream::print@5 (line 597)                                                ; - java.io.PrintStream::println@6 (line 736)  0x000000010551747b: nop  0x000000010551747c: nop  0x000000010551747d: nop  0x000000010551747e: nop  0x000000010551747f: callq  0x00000001051440a0  ; OopMap{[64]=Oop [80]=Oop off=516}                                                ;*invokespecial write                                                ; - java.io.PrintStream::print@5 (line 597)                                                ; - java.io.PrintStream::println@6 (line 736)                                                ;   {optimized virtual_call}  0x0000000105517484: mov    0x40(%rsp),%rsi  0x0000000105517489: movabs $0x118bcb1b8,%rdi  ;   {metadata(method data for {method} {0x0000000118a46618} 'println' '(I)V' in 'java/io/PrintStream')}  0x0000000105517493: addq   $0x1,0x138(%rdi)  0x000000010551749b: mov    0x40(%rsp),%rsi    ;*invokespecial newLine                                                ; - java.io.PrintStream::println@10 (line 737)  0x00000001055174a0: nop  0x00000001055174a1: nop  0x00000001055174a2: nop  0x00000001055174a3: nop  0x00000001055174a4: nop  0x00000001055174a5: nop  0x00000001055174a6: nop  0x00000001055174a7: callq  0x00000001051440a0  ; OopMap{[64]=Oop [80]=Oop off=556}                                                ;*invokespecial newLine                                                ; - java.io.PrintStream::println@10 (line 737)                                                ;   {optimized virtual_call}  0x00000001055174ac: lea    0x48(%rsp),%rax  0x00000001055174b1: mov    0x8(%rax),%rdi  0x00000001055174b5: mov    (%rdi),%rsi  0x00000001055174b8: and    $0x7,%rsi  0x00000001055174bc: cmp    $0x5,%rsi  0x00000001055174c0: je     0x00000001055174dd  0x00000001055174c6: mov    (%rax),%rsi  0x00000001055174c9: test   %rsi,%rsi  0x00000001055174cc: je     0x00000001055174dd  0x00000001055174d2: lock cmpxchg %rsi,(%rdi)  0x00000001055174d7: jne    0x00000001055175a8  ;*monitorexit                                                ; - java.io.PrintStream::println@14 (line 738)  0x00000001055174dd: movabs $0x118bcb1b8,%rax  ;   {metadata(method data for {method} {0x0000000118a46618} 'println' '(I)V' in 'java/io/PrintStream')}  0x00000001055174e7: incl   0x148(%rax)        ;*goto                                                ; - java.io.PrintStream::println@15 (line 738)  0x00000001055174ed: add    $0x60,%rsp  0x00000001055174f1: pop    %rbp  0x00000001055174f2: test   %eax,-0x248f3f8(%rip)        # 0x0000000103088100                                                ;   {poll_return}  0x00000001055174f8: retq                      ;*return                                                ; - java.io.PrintStream::println@23 (line 739)

System.out.println 源码

从上面输出日志里也可以第402行到第488行,关于System.out.println代码运行时的部分反汇编结果,是关于字节码指令monitorentermonitorexit的执行部分。

JITWatch 截屏

参考文献

  1. Concurrent Programming in Java™: Design Principles and Pattern
  2. JSR 133 (Java Memory Model) FAQ
  3. JSR-133: Java Memory Model and Thread Specification
  4. The JSR-133 Cookbook for Compiler Writers
  5. Java 理论与实践: 正确使用 Volatile 变量
  6. Java theory and practice: Fixing the Java Memory Model, Part 2

深入理解 Java 内存模型五:锁

锁的释放/获取建立的 happens-before 关系

锁是 java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

下面是锁释放/获取的示例代码:

class MonitorExample {    int a = 0;    public synchronized void writer() { // 1        a++;                            // 2    }                                   // 3    public synchronized void reader() { // 4        int i = a;                      // 5        // ......    }                                   // 6}

假设线程 A 执行writer()方法,随后线程 B 执行reader()方法。根据happens-before规则,这个过程包含的happens-before关系可以分为两类:

  1. 根据程序次序规则,1 happens-before 2, 2 happens-before 3; 4 happens-before 5, 5 happens-before 6。
  2. 根据监视器锁规则,3 happens-before 4。
  3. 根据 happens-before 的传递性,2 happens-before 5。

上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的happens-before保证。

上图表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens-before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。

锁释放和获取的内存语义

当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的 MonitorExample 程序为例,A 线程释放锁后,共享数据的状态示意图如下:

当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。下面是锁获取的状态示意图:

对比锁释放/获取的内存语义与 volatile 写-读的内存语义,可以看出:

锁释放与 volatile 写有相同的内存语义;锁获取与 volatile 读有相同的内存语义。

下面对锁释放和锁获取的内存语义做个总结:

  1. 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出了(线程 A 对共享变量所做修改的)消息。
  2. 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  3. 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。

锁内存语义的实现

本文将借助 ReentrantLock 的源代码,来分析锁内存语义的具体实现机制。

请看下面的示例代码:

class ReentrantLockExample {    int a = 0;    ReentrantLock lock = new ReentrantLock();    public void writer() {        lock.lock();        // 获取锁        try {            a++;        } finally {            lock.unlock();  // 释放锁        }    }    public void reader() {        lock.lock();        // 获取锁        try {            int i = a;            // ......        } finally {            lock.unlock();  // 释放锁        }    }}

在 ReentrantLock 中,调用lock()方法获取锁;调用unlock()方法释放锁。

ReentrantLock 的实现依赖于 java 同步器框架 AbstractQueuedSynchronizer(本文简称之为 AQS)。AQS 使用一个整型的 volatile 变量(命名为 state)来维护同步状态,马上我们会看到,这个 volatile 变量是 ReentrantLock 内存语义实现的关键。 下面是 ReentrantLock 的类图(仅画出与本文相关的部分):

ReentrantLock 分为公平锁和非公平锁,我们首先分析公平锁

使用公平锁时,加锁方法lock()的方法调用轨迹如下:

  1. ReentrantLock : lock()
  2. FairSync : lock()
  3. AbstractQueuedSynchronizer : acquire(int arg)
  4. ReentrantLock : tryAcquire(int acquires)

在第 4 步真正开始加锁,下面是该方法的源代码:

protected final boolean tryAcquire(int acquires) {    final Thread current = Thread.currentThread();    int c = getState();  // 获取锁的开始,首先读 volatile 变量 state    if (c == 0) {        if (isFirst(current) &&            compareAndSetState(0, acquires)) {            setExclusiveOwnerThread(current);            return true;        }    }    else if (current == getExclusiveOwnerThread()) {        int nextc = c + acquires;        if (nextc < 0)            throw new Error("Maximum lock count exceeded");        setState(nextc);        return true;    }    return false;}

从上面源代码中我们可以看出,加锁方法首先读 volatile 变量 state。

在使用公平锁时,解锁方法unlock()的方法调用轨迹如下:

  1. ReentrantLock : unlock()
  2. AbstractQueuedSynchronizer : release(int arg)
  3. Sync : tryRelease(int releases)

在第 3 步真正开始释放锁,下面是该方法的源代码:

protected final boolean tryRelease(int releases) {    int c = getState() - releases;    if (Thread.currentThread() != getExclusiveOwnerThread())        throw new IllegalMonitorStateException();    boolean free = false;    if (c == 0) {        free = true;        setExclusiveOwnerThread(null);    }    setState(c);          // 释放锁的最后,写 volatile 变量 state    return free;}

从上面的源代码我们可以看出,在释放锁的最后写 volatile 变量 state。

公平锁在释放锁的最后写 volatile 变量 state;在获取锁时首先读这个 volatile 变量。根据 volatile 的happens-before规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变的对获取锁的线程可见。

现在我们分析非公平锁的内存语义的实现。

非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。

使用公平锁时,加锁方法lock()的方法调用轨迹如下:

  1. ReentrantLock : lock()
  2. NonfairSync : lock()
  3. AbstractQueuedSynchronizer : compareAndSetState(int expect, int update)

在第 3 步真正开始加锁,下面是该方法的源代码:

java.util.concurrent.locks.AbstractQueuedSynchronizer.java
553554555556557558559560561562563564565566567
/** * Atomically sets synchronization state to the given updated * value if the current state value equals the expected value. * This operation has memory semantics of a {@code volatile} read * and write. * * @param expect the expected value * @param update the new value * @return {@code true} if successful. False return indicates that the actual *         value was not equal to the expected value. */protected final boolean compareAndSetState(int expect, int update) {    // See below for intrinsics setup to support this    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);}

该方法以原子操作的方式更新 state 变量,本文把 java 的compareAndSet()方法调用简称为 CAS。

JDK 文档对该方法的说明如下:

如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值,此操作具有 volatile 读和写的内存语义。

CAS 具有 volatile 读写内存语义示例代码

public class CASVolatileSemantics {    private static final Logger LOGGER = LoggerFactory.getLogger(CASVolatileSemantics.class);    private int i = 0;    public static void main(String[] args) throws NoSuchFieldException, InterruptedException {        long start = System.nanoTime();        casAdder();        long end = System.nanoTime();        LOGGER.info("duration of cas : {}", (end - start)/1000000);        start = System.nanoTime();        synchronizedAdder();        end = System.nanoTime();        LOGGER.info("duration of synchronized : {}", (end - start)/1000000);    }    /**     * <pre>     * * Setup to support compareAndSet. We need to natively implement     * * this here: For the sake of permitting future enhancements, we     * * cannot explicitly subclass AtomicInteger, which would be     * * efficient and useful otherwise. So, as the lesser of evils, we     * * natively implement using hotspot intrinsics API. And while we     * * are at it, we do the same for other CASable fields (which could     * * otherwise be done with atomic field updaters).     *     * private static final Unsafe unsafe = Unsafe.getUnsafe();     * </pre>     */    private static Unsafe getUnsafe() {        try {            Field field = Unsafe.class.getDeclaredField("theUnsafe");            field.setAccessible(true);            return (Unsafe) field.get(null);        } catch (NoSuchFieldException | IllegalAccessException e) {            throw new IllegalStateException(e);        }    }    private static void casAdder() throws NoSuchFieldException, InterruptedException {        final Unsafe unsafe = getUnsafe();        final CASVolatileSemantics cas = new CASVolatileSemantics();        final long fieldOffset = unsafe.objectFieldOffset(CASVolatileSemantics.class.getDeclaredField("i"));        final ExecutorService executorService = Executors.newFixedThreadPool(4);        for (int j = 0; j < 4; j++) {            executorService.submit(() -> {                LOGGER.info("start thread : {}", Thread.currentThread().getName());                int counter = 0;                for (int loop = 0; loop < 100_000_000; loop++) {                    for (;;) {                        int current = cas.i;                        // 这个还是要看脸的,运气好冲突少,很快就执行完,冲突多就很慢,比 synchronized 还慢                        // 快的 10 秒完成,慢则 30 秒,而 synchronized 比较稳定在 15 秒左右                        if (unsafe.compareAndSwapInt(cas, fieldOffset, current, current + 1)) {                            break;                        } else {                            // 记录失败次数                            counter++;                        }                    }                }                LOGGER.info("unsafe.compareAndSwapInt failure counter : {}", counter);            });        }        executorService.shutdown();        if (!executorService.awaitTermination(60L, TimeUnit.SECONDS)) {            LOGGER.info("termination failure");            executorService.shutdownNow();        }        LOGGER.info("CAS i = {}", cas.i);    }    private static void synchronizedAdder() throws InterruptedException {        final CASVolatileSemantics cas = new CASVolatileSemantics();        final ExecutorService executorService = Executors.newFixedThreadPool(4);        for (int j = 0; j < 4; j++) {            executorService.submit(() -> {                LOGGER.info("start thread : {}", Thread.currentThread().getName());                for (int loop = 0; loop < 100_000_000; loop++) {                    synchronized (cas) {                        cas.i = cas.i + 1;                    }                }            });        }        executorService.shutdown();        if (!executorService.awaitTermination(60L, TimeUnit.SECONDS)) {            LOGGER.info("termination failure");            executorService.shutdownNow();        }        LOGGER.info("synchronized cas.i = {}", cas.i);    }}

下面分别从编译器和处理器的角度来分析,CAS 如何同时具有 volatile 读和 volatile 写的内存语义。

前文我们提到过,编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现 volatile 读和 volatile 写的内存语义,编译器不能对 CAS 与 CAS 前面和后面的任意内存操作重排序。

下面我们来分析在常见的 intel x86 处理器中,CAS 是如何同时具有 volatile 读和 volatile 写的内存语义的。

下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:

public final native boolean compareAndSwapInt(Object o, long offset,         int expected,         int x);

可以看到这是个本地方法调用。

这个本地方法在 openjdk8 中依次调用的c++代码为:

  1. unsafe.cpp
  2. atomic.cpp
  3. atomic_linux_x86.inline.hpp

下面是 x86 处理器在 linux 平台上的相关源码

http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/f3108e56b502/src/os_cpu/linux_x86/vm/atomic_linux_x86.inline.hpp
4748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
// Adding a lock prefix to an instruction on MP machine#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "inline jint     Atomic::add    (jint     add_value, volatile jint*     dest) {  jint addend = add_value;  int mp = os::is_MP();  __asm__ volatile (  LOCK_IF_MP(%3) "xaddl %0,(%2)"                    : "=r" (addend)                    : "0" (addend), "r" (dest), "r" (mp)                    : "cc", "memory");  return addend + add_value;}inline void Atomic::inc    (volatile jint*     dest) {  int mp = os::is_MP();  __asm__ volatile (LOCK_IF_MP(%1) "addl $1,(%0)" :                    : "r" (dest), "r" (mp) : "cc", "memory");}inline void Atomic::inc_ptr(volatile void*     dest) {  inc_ptr((volatile intptr_t*)dest);}inline void Atomic::dec    (volatile jint*     dest) {  int mp = os::is_MP();  __asm__ volatile (LOCK_IF_MP(%1) "subl $1,(%0)" :                    : "r" (dest), "r" (mp) : "cc", "memory");}inline void Atomic::dec_ptr(volatile void*     dest) {  dec_ptr((volatile intptr_t*)dest);}inline jint     Atomic::xchg    (jint     exchange_value, volatile jint*     dest) {  __asm__ volatile (  "xchgl (%2),%0"                    : "=r" (exchange_value)                    : "0" (exchange_value), "r" (dest)                    : "memory");  return exchange_value;}inline void*    Atomic::xchg_ptr(void*    exchange_value, volatile void*     dest) {  return (void*)xchg_ptr((intptr_t)exchange_value, (volatile intptr_t*)dest);}inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {  int mp = os::is_MP();  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"                    : "=a" (exchange_value)                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)                    : "cc", "memory");  return exchange_value;}#ifdef AMD64inline void Atomic::store    (jlong    store_value, jlong*    dest) { *dest = store_value; }inline void Atomic::store    (jlong    store_value, volatile jlong*    dest) { *dest = store_value; }inline intptr_t Atomic::add_ptr(intptr_t add_value, volatile intptr_t* dest) {  intptr_t addend = add_value;  bool mp = os::is_MP();  __asm__ __volatile__ (LOCK_IF_MP(%3) "xaddq %0,(%2)"                        : "=r" (addend)                        : "0" (addend), "r" (dest), "r" (mp)                        : "cc", "memory");  return addend + add_value;}inline void*    Atomic::add_ptr(intptr_t add_value, volatile void*     dest) {  return (void*)add_ptr(add_value, (volatile intptr_t*)dest);}inline void Atomic::inc_ptr(volatile intptr_t* dest) {  bool mp = os::is_MP();  __asm__ __volatile__ (LOCK_IF_MP(%1) "addq $1,(%0)"                        :                        : "r" (dest), "r" (mp)                        : "cc", "memory");}inline void Atomic::dec_ptr(volatile intptr_t* dest) {  bool mp = os::is_MP();  __asm__ __volatile__ (LOCK_IF_MP(%1) "subq $1,(%0)"                        :                        : "r" (dest), "r" (mp)                        : "cc", "memory");}inline intptr_t Atomic::xchg_ptr(intptr_t exchange_value, volatile intptr_t* dest) {  __asm__ __volatile__ ("xchgq (%2),%0"                        : "=r" (exchange_value)                        : "0" (exchange_value), "r" (dest)                        : "memory");  return exchange_value;}inline jlong    Atomic::cmpxchg    (jlong    exchange_value, volatile jlong*    dest, jlong    compare_value) {  bool mp = os::is_MP();  __asm__ __volatile__ (LOCK_IF_MP(%4) "cmpxchgq %1,(%3)"                        : "=a" (exchange_value)                        : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)                        : "cc", "memory");  return exchange_value;}inline intptr_t Atomic::cmpxchg_ptr(intptr_t exchange_value, volatile intptr_t* dest, intptr_t compare_value) {  return (intptr_t)cmpxchg((jlong)exchange_value, (volatile jlong*)dest, (jlong)compare_value);}inline void*    Atomic::cmpxchg_ptr(void*    exchange_value, volatile void*     dest, void*    compare_value) {  return (void*)cmpxchg((jlong)exchange_value, (volatile jlong*)dest, (jlong)compare_value);}inline jlong Atomic::load(volatile jlong* src) { return *src; }#else // !AMD64// ......#endif // AMD64

如上面源代码所示,其中cmpxchglcmpxchgq的最后 CPU 实际指令就是cmpxchg,只是汇编指令的运行细节上的包装,宏命令LOCK_IF_MP会根据当前处理器的类型来决定是否为cmpxchg指令添加LOCK前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略LOCK 前缀(单处理器自身会维护单处理器内的顺序一致性,不需要LOCK 前缀提供的内存屏障效果)。

CAS 实现说明

Intel 的手册对LOCK 前缀的说明如下:

  1. 确保对内存的读-改-写操作原子执行。在 Pentium 及 Pentium 之前的处理器中,带有 LOCK 前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从 Pentium 4,Intel Xeon 及 P6 处理器开始,intel 在原有总线锁的基础上做了一个很有意义的优化:
    如果要访问的内存区域(area of memory)在 LOCK 前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。
    这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低 LOCK 前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 禁止该指令与之前的读和之后的写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

上面的第 2 点和第 3 点所具有的内存屏障效果,足以同时实现 volatile 读和 volatile 写的内存语义,需要注意的是虽然 CAS 具有 volatile 的内存语义,但是 AQS 实现里仍然需要一个 volatile 共享变量来控制此变量对于别的线程的可见性。

经过上面的这些分析,现在我们终于能明白为什么 JDK 文档说 CAS 同时具有 volatile 读和 volatile 写的内存语义了。

在 x86 架构上,CAS 被翻译为lock cmpxchgcmpxchg是 CAS 的汇编指令。在 CPU 架构中依靠lock信号保证可见性并禁止重排序。

LOCK 前缀是一个特殊的信号,执行过程如下:

  1. 对总线和缓存上锁。
  2. 强制所有 lock 信号之前的指令,都在此之前被执行,并同步相关缓存。
  3. 执行 lock 后的指令(如 cmpxchg)。
  4. 释放对总线和缓存上的锁。
  5. 强制所有 lock 信号之后的指令,都在此之后被执行,并同步相关缓存。

因此,lock 信号虽然不是内存屏障,但具有MFENCE的语义(当然,还有排他性的语义),与内存屏障相比,lock 信号要额外对总线和缓存上锁,成本更高。

stackoverflow 上对于 LOCK 前缀的解释

LOCK is not an instruction itself: it is an instruction prefix, which applies to the following instruction. That instruction must be something that does a read-modify-write on memory (inc, xchg, cmpxchg etc.)

The LOCK prefix ensures that the CPU has exclusive ownership of the appropriate cache line for the duration of the operation, and provides certain additional ordering guarantees. This may be achieved by asserting a bus lock, but the CPU will avoid this where possible. If the bus is locked then it is only for the duration of the locked instruction.

IA-32 Intel® Architecture Software Developer’s Manual

http://www.scs.stanford.edu/05au-cs240c/lab/ia32/IA32-2A.pdf 文档或者是 Intel® 64 and IA-32 Architectures Software Developer’s Manual 中关于 LOCK 前缀的说明,摘录如下:

The LOCK prefix (F0H) forces an operation that ensures exclusive use of shared memory in a multiprocessor environment.

LOCK—Assert LOCK# Signal Prefix

Opcode* Instruction 64-Bit Mode Compat/Leg Mode Description
F0 LOCK Valid Valid Asserts LOCK# signal for duration of the accompanying instruction.

NOTES: * See IA-32 Architecture Compatibility section below.

Description

Causes the processor’s LOCK# signal to be asserted during execution of the accompanying instruction (turns the instruction into an atomic instruction). In a multiprocessor environment, the LOCK# signal insures that the processor has exclusive use of any shared memory while the signal is asserted.

Note that, in later IA-32 processors (including the Pentium 4, Intel Xeon, and P6 family processors), locking may occur without the LOCK# signal being asserted. See IA-32 Architecture Compatibility below.

The LOCK prefix can be prepended only to the following instructions and only to those forms of the instructions where the destination operand is a memory operand: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG. If the LOCK prefix is used with one of these instructions and the source operand is a memory operand, an undefined opcode exception (#UD) may be generated. An undefined opcode exception will also be generated if the LOCK prefix is used with any instruction not in the above list. The XCHG instruction always asserts the LOCK# signal regardless of the presence or absence of the LOCK prefix.

The LOCK prefix is typically used with the BTS instruction to perform a read-modify-write operation on a memory location in shared memory environment.

The integrity of the LOCK prefix is not affected by the alignment of the memory field. Memory locking is observed for arbitrarily misaligned fields.

This instruction’s operation is the same in non-64-bit modes and 64-bit mode.

公平锁和非公平锁

现在对公平锁非公平锁的内存语义做个总结:

  1. 公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state。
  2. 公平锁获取时,首先会去读这个 volatile 变量。
  3. 非公平锁获取时,首先会用 CAS 更新这个 volatile 变量,这个操作同时具有 volatile 读和 volatile 写的内存语义。

从本文对 ReentrantLock 的分析可以看出,锁释放/获取的内存语义的实现至少有下面两种方式:

  1. 利用 volatile 变量的写-读所具有的内存语义。
  2. 利用 CAS 所附带的 volatile 读和 volatile 写的内存语义。

concurrent 包的实现

由于 java 的 CAS 同时具有 volatile 读和 volatile 写的内存语义,因此 Java 线程之间的通信现在有了下面四种方式:

  1. A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量。
  2. A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
  3. A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
  4. A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量。

Java 的 CAS 会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile 变量的读/写和 CAS 可以实现线程之间的通信,把这些特性整合在一起,就形成了整个 concurrent 包得以实现的基石。

如果我们仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式:

  1. 首先,声明共享变量为 volatile;
  2. 然后,使用 CAS 的原子条件更新来实现线程之间的同步;
  3. 同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic 包中的类),这些 concurrent 包中的基础类都是使用这种模式来实现的,而 concurrent 包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent 包的实现示意图如下:

参考文献

  1. Concurrent Programming in Java: Design Principles and Pattern
  2. JSR 133 (Java Memory Model) FAQ
  3. JSR-133: Java Memory Model and Thread Specification
  4. Java Concurrency in Practice
  5. Java™ Platform, Standard Edition 6 API Specification
  6. The JSR-133 Cookbook for Compiler Writers
  7. Intel® 64 and IA-32 ArchitecturesvSoftware Developer’s Manual Volume 3A: System Programming Guide, Part 1
  8. The Art of Multiprocessor Programming

深入理解 Java 内存模型六:final

与前面介绍的锁和 volatile 相比较,对 final 域的读和写更像是普通的变量访问。对于 final 域,编译器和处理器要遵守两个重排序规则:

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

下面,我们通过一些示例性的代码来分别说明这两个规则:

public class FinalExample {    int i;                              // 普通变量    final int j;                        // final 变量    static FinalExample obj;    public FinalExample() {             // 构造函数        i = 1;                          // 写普通域        j = 2;                          // 写 final 域    }    public static void writer() {       // 写线程 A 执行        obj = new FinalExample();    }    public static void reader() {        // 读线程 B 执行        FinalExample object = obj;       // 读对象引用        int a = object.i;                // 读普通域        int b = object.j;                // 读 final 域    }}

这里假设一个线程 A 执行writer()方法,随后另一个线程 B 执行reader()方法。下面我们通过这两个线程的交互来说明这两个规则。

写 final 域的重排序规则

写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面:

  1. JMM 禁止编译器把 final 域的写重排序到构造函数之外。
  2. 编译器会在 final 域的写之后,构造函数 return 之前,插入一个StoreStore屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

现在让我们分析writer()方法。writer()方法只包含一行代码:

obj = new FinalExample();

这行代码包含两个步骤:

  1. 构造一个 FinalExample 类型的对象;
  2. 把这个对象的引用赋值给引用变量 obj。

假设线程 B 读对象引用与读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),下图是一种可能的执行时序:

在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程 B 错误的读取了普通变量 i 初始化之前的值。而写 final 域的操作,被写 final 域的重排序规则限定在了构造函数之内,读线程 B 正确的读取了 final 变量初始化之后的值。

写 final 域的重排序规则可以确保:

在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。

以上图为例,在读线程 B 看到对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域 i 的写操作被重排序到构造函数外,此时初始值 2 还没有写入普通域 i)。

读 final 域的重排序规则

读 final 域的重排序规则如下:

  1. 在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
  2. 初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器。

reader()方法包含三个操作:

  1. 初次读引用变量 obj;
  2. 初次读引用变量 obj 指向对象的普通域 j。
  3. 初次读引用变量 obj 指向对象的 final 域 i。

现在我们假设写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:

在上图中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程 A 写入,这是一个错误的读取操作。而读 final 域的重排序规则会把读对象 final 域的操作限定在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。

读 final 域的重排序规则可以确保:

在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。

在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被 A 线程初始化过了。

如果 final 域是引用类型

上面我们看到的 final 域是基础数据类型,下面让我们看看如果 final 域是引用类型,将会有什么效果?

请看下列示例代码:

public class FinalReferenceExample {    final int[] intArray;                  // final 是引用类型    static FinalReferenceExample obj;    public FinalReferenceExample() {       // 构造函数        intArray = new int[1];             // 1        intArray[0] = 1;                   // 2    }    public static void writerOne() {       // 写线程 A 执行        obj = new FinalReferenceExample(); // 3    }    public static void writerTwo() {       // 写线程 B 执行        obj.intArray[0] = 2;               // 4    }    public static void reader() {          // 读线程 C 执行        if (obj != null) {                 // 5            int temp1 = obj.intArray[0];   // 6        }    }}

这里 final 域为一个引用类型,它引用一个 int 型的数组对象。对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:

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

对上面的示例程序,我们假设首先线程 A 执行writerOne()方法,执行完后线程 B 执行writerTwo()方法,执行完后线程 C 执行reader()方法。下面是一种可能的线程执行时序:

在上图中,1 是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。

JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看的到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。

如果想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线程 C 之间需要使用同步原语(lock 或 volatile)来确保内存可见性。

为什么 final 引用不能从构造函数内"逸出"

前面我们提到过,写 final 域的重排序规则可以确保:

在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。

其实要得到这个效果,还需要一个保证:

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

为了说明问题,让我们来看下面示例代码:

public class FinalReferenceEscapeExample {    final int i;    static FinalReferenceEscapeExample obj;    public FinalReferenceEscapeExample() {        i = 1;                               // 1 写 final 域        obj = this;                          // 2 this 引用在此逸出    }    public static void writer() {        new FinalReferenceEscapeExample();    }    public static void reader() {        if (obj != null) {                    // 3            int temp = obj.i;                 // 4        }    }}

假设一个线程 A 执行writer()方法,另一个线程 B 执行reader()方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且即使在程序中操作 2 排在操作 1 后面,执行read()方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重排序。实际的执行时序可能如下图所示:

从上图我们可以看出:

在构造函数返回前,被构造对象的引用不能为其他线程可见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。

final 语义在处理器中的实现

现在我们以 x86 处理器为例,说明 final 语义在处理器中的具体实现。

上面我们提到,写 final 域的重排序规则会要求译编器在 final 域的写之后,构造函数 return 之前,插入一个StoreStore障屏。读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个LoadLoad屏障。

由于 x86 处理器不会写-写操作做重排序,所以在 x86 处理器中,写 final 域需要的StoreStore障屏会被省略掉。同样,由于 x86 处理器不会对存在间接依赖关系的操作做重排序,所以在 x86 处理器中,读 final 域需要的LoadLoad屏障也会被省略掉。

也就是说在x86 处理器中,final 域的读/写不会插入任何内存屏障

JSR-133 为什么要增强 final 的语义

的 Java 内存模型中 ,最严重的一个缺陷就是线程可能看到 final 域的值会改变。比如,一个线程当前看到一个整形 final 域的值为 0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个 final 域的值时,却发现值变为了 1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会改变(参考文献 2 中有一个具体的例子,感兴趣的读者可以自行参考,这里就不赘述了)。

为了修补这个漏洞,JSR-133专家组增强了 final 的语义。通过为 final 域增加写和读重排序规则,可以为 java 程序员提供初始化安全保证:

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

参考文献

  1. Java Concurrency in Practice
  2. JSR 133 (Java Memory Model) FAQ
  3. Java Concurrency in Practice
  4. The JSR-133 Cookbook for Compiler Writers
  5. Intel® 64 and IA-32 ArchitecturesvSoftware Developer’s Manual Volume 3A: System Programming Guide, Part 1

深入理解 Java 内存模型七:总结

处理器内存模型

顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

根据对不同类型读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:

  1. 放松程序中写-读操作的顺序,由此产生了 total store ordering 内存模型(简称为 TSO)。
  2. 在前面 1 的基础上,继续放松程序中写-写操作的顺序,由此产生了 partial store order 内存模型(简称为 PSO)。
  3. 在前面 1 和 2 的基础上,继续放松程序中读-写读-读操作的顺序,由此产生了 relaxed memory order 内存模型(简称为 RMO)和 PowerPC 内存模型。

注意,这里处理器对读-写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守as-if-serial语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。

下面的表格展示了常见处理器内存模型的细节特征:

内存模型名称 对应的处理器 Store-Load 重排序 Store-Store 重排序 Load-Load 和 Load-Store 重排序 可以更早读取到其它处理器的写 可以更早读取到当前处理器的写
TSO sparc-TSO / X64 Y Y
PSO sparc-PSO Y Y Y
RMO ia64 Y Y Y Y
PowerPC PowerPC Y Y Y Y Y

在这个表格中,我们可以看到所有处理器内存模型都允许写-读重排序,原因在第一章以说明过:

它们都使用了写缓存区,写缓存区可能导致写-读操作重排序。

同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:

由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写。

上面表格中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。

由于常见的处理器内存模型比 JMM 要弱,java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:

如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java 程序员呈现了一个一致的内存模型。

JMM,处理器内存模型与顺序一致性内存模型之间的关系

JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:

从上图我们可以看出:

常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。

同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。

JMM 的设计

从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:

  1. 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码。
  2. 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

由于这两个因素互相矛盾,所以JSR-133专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:

一方面要为程序员提供足够强的内存可见性保证;
另一方面,对编译器和处理器的限制要尽可能的放松。

下面让我们看看JSR-133是如何实现这一目标的。

为了具体说明,请看前面提到过的计算圆面积的示例代码:

double pi  = 3.14;    // Adouble r   = 1.0;     // Bdouble area = pi * r * r; // C

上面计算圆的面积的示例代码存在三个happens-before关系:

  1. A happens-before B;
  2. B happens-before C;
  3. A happens-before C;

由于 A happens-before B,happens-before的定义会要求:

A 操作执行的结果要对 B 可见,且 A 操作的执行顺序排在 B 操作之前。

但是从程序语义的角度来说,对 A 和 B 做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。也就是说,上面这 3 个happens-before关系中,虽然 2 和 3 是必需要的,但 1 是不必要的。

因此,JMM 把happens-before要求禁止的重排序分为了下面两类:

  1. 会改变程序执行结果的重排序。
  2. 不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序,采取了不同的策略:

  1. 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
  2. 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。

下面是 JMM 的设计示意图:

从上图可以看出两点:

  1. JMM 向程序员提供的happens-before规则能满足程序员的需求。JMM 的happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens-before B)。
  2. JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:

只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

  1. 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  2. 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  3. 未同步/未正确同步的多线程程序。JMM 为它们提供了最小安全性保障,即线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:

只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

JSR-133 对旧内存模型的修补

JSR-133对 JDK5 之前的旧内存模型的修补主要有两个:

  1. 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133严格限制 volatile 变量与普通变量的重排序,使 volatile 的写-读和锁的释放/获取具有相同的内存语义。
  2. 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133为 final 增加了两个重排序规则。现在,final 具有了初始化安全性。

参考文献

  1. Computer Architecture: A Quantitative Approach, 4th Edition
  2. Shared memory consistency models: A tutorial
  3. Intel® Itanium® Architecture Software Developer’s Manual Volume 2: System Architecture
  4. Concurrent Programming on Windows
  5. JSR 133 (Java Memory Model) FAQ
  6. The JSR-133 Cookbook for Compiler Writers
  7. Java theory and practice: Fixing the Java Memory Model, Part 2

关于作者

程晓明,Java 软件工程师,国家认证的系统分析师、信息项目管理师。专注于并发编程,就职于富士通南大。

原文 PDF 下载

此文作者除了讲解 JMM 规则,并精心制作了示例代码和配图,对于深入理解 JMM 帮助很大,转载原文用以参考学习,另外作者提供的 PDF 下载地址:

References

  1. 深入理解 Java 内存模型一:基础
  2. 深入理解 Java 内存模型二:重排序
  3. 深入理解 Java 内存模型三:顺序一致性
  4. 深入理解 Java 内存模型四:volatile
  5. 深入理解 Java 内存模型五:锁
  6. 深入理解 Java 内存模型六:final
  7. 深入理解 Java 内存模型七:总结
  8. 指令流水线
  9. The JSR-133 Cookbook for Compiler Writers
  10. The Art of Multiprocessor Programming
  11. x86 Assembly Guide
  12. Java Memory Model Pragmatics
  13. JSR-133: Java Memory Model and Thread Specification
  14. Why Memory Barriers
  15. Shared Memory Consistency Models: A Tutorial
  16. What does the “lock” instruction mean in x86 assembly
  17. Intel® 64 and IA-32 Architectures Software Developer’s Manual
  18. Advanced OS Implementation Reference Materials
  19. http://www.scs.stanford.edu/05au-cs240c/lab/ia32/IA32-2A.pdf
  20. http://www.scs.stanford.edu/05au-cs240c/lab/pcasm-book.pdf
  21. x86 and amd64 instruction reference
  22. JEP 188: Java Memory Model Update
  23. Fixing the Java Memory Model
  24. Code Optimization: Memory Hierarchy
  25. Getting Spendy with Transistors - L3 cache
  26. Memory Barriers: a Hardware View for Software Hackers
  27. Java Memory Model Under The Hood
  28. A Journey Through the CPU Pipeline