「Java」内存屏障和 Java 并发
简述:本文是 InfoQ 上一篇文章的译文,文中略有不通顺之处。
内存屏障,或者说内存栅栏,是一组用于限制内存操作的执行顺序的 CPU 指令。本文将解析内存屏障对多线程程序的影响,具体到内存屏障和 JVM 并发结构(如 volatile
、synchronized
和 Atomic
类)之间的关系。读者需要对这些概念和 Java 内存模型有扎实的理解。不过,文章并不打算阐述互斥(mutual exclusion
),并行(parallelism
)或者原子性(atomicity
)。内存屏障用于实现一个很重要的并发编程基本特性,即 可见性
。
感谢 Brian Goetz 和 Eric Yew 对文章的审阅工作,同时也感谢 Christian Thalinger 提供的 SPARC
框架设备。
1 为什么内存屏障如此重要
访问主内存(main memory
)一次需要数以百计个时钟周期(clock cycle
)。CPU 使用缓存(cache
)将内存延迟(memory latency
)的开销降低了几个数量级。为了提高性能,缓存会对挂起(pending
)的内存操作进行重排序(re-order
)。也就是说,程序的读/写操作不一定按照代码顺序来执行。
当数据是不可变的(immutable
)或被封闭在某个线程内(confined to the scope of one thread
)时,这种优化不会改变程序结果。然而,当指令重排序遇上对称多处理(symmetric multi-processing
)和共享可变状态(shared mutable state
)这些情况时,可能会是一场噩梦。 对共享可变数据的内存操作进行重排序时,程序运行结果会变得不确定,比如一个线程可能以与代码顺序不一致的顺序来修改对另一个线程可见(visible
)的值。不过,内存屏障可以通过让处理器序列化(serialize
)挂起(pending
)的内存操作来避免这个问题。
2 内存屏障作为协议
JVM 并不直接显露内存屏障。(Memory barriers are not directly exposed by the JVM. )反之,为了保证语言级的并发原语语义,它们被 JVM 插入到指令序列中。(Instead they are inserted into the instruction sequence by the JVM in order to uphold the semantics of language level concurrency primitives. )我们将会看一些 Java 简单源码和其汇编指令,以了解其中原理。
让我们用 Dekker's algorithm
,来学习内存屏障的速成课程。该算法使用三个 volatile 变量(intentFirst
、intentSecond
、turn
)来协调(coordinate
)两个线程之间对共享资源的访问。
1 | // 线程 1 // 线程 2 |
不要关注这个算法的细节。那么,该看些什么呢?
仔细看,每个线程都试图在第一行通过将自己的 intent
置为 true
来进入临界区(critical section
)。如果一个线程在第三行观察(observe
)到冲突(即两个线程的 intent
都为 true
),那么将通过轮流执行(turn taking
)来解决冲突。在给定的时间点上,只有一个线程可以访问临界区。
硬件优化(hardware optimizations
)在没有内存屏障的情况下会使代码运行结果变得不确定,即使编译器以代码顺序来编译这些内存操作。
- 看第 3 行和第 4 行上的两个连续的
volatile
读操作。每个线程都会检查另一个线程是否有进入临界区的intent
,再检查轮到谁了。 - 看第 12 行和第 13 行上的两个连续的
volatile
写操作。每个线程将变量turn
改为另一个线程对应的值,并且将自己进入临界区的intent
置为false
。
读线程不应该在另一个线程将其 intent
置为 false
后才观察到那个线程对变量 turn
的写操作,这将是一场灾难(A reading thread should never expect to observe the other thread’s write to the turn variable after the other thread’s withdrawal of intent. This would be a disaster. )。但如果没有用 volatile
修饰这些变量,就会发生这种情况!比如,假如没有 volatile
,在线程 1 对变量 turn
写入(线程 1 倒数第二行)之前,线程 2 可能可以观察到线程 1 对 intentFirst
的写入(线程 1 最后一行)。
关键字 volatile
可以避免这个问题,因为它在对 turn
变量的写入和对 intentFirst
变量的写入之间建立了一个 happens before
关系(The keyword volatile prevents this problem because it establishes a happens before relationship between the write to the turn variable and the write to the intentFirst variable. )。编译器不能对这些写操作重排序,如果必须的话,它就会使用内存屏障来禁止处理器的重排序。
HotSpot 选项 PrintAssembly
是 JVM 的一个诊断标志(diagnostic flag),它能够让我们获取 JIT 编译器生成的汇编指令。这需要最新的 OpenJDK 版本(update 14 或以上)或新版 HotSpot。另外还需要一个反汇编插件。Kenai 就有适合 Solaris、Linux 和 BSD 平台的插件。hsdis 插件可以作为 Windows 平台上的替代方案。
源码中第 3 行连续的两个读操作中的第一个体现在下面的汇编指令中。环境是多核 CPU Itanium 2,JDK 1.6(Update 17)。
下面的所有汇编指令,所有指令流都是按照左侧的行号进行排序的。相关的读操作、写操作和内存屏障指令都有前缀 *
。建议读者不要陷入对每条指令的语义思考中。
1 | (Itanium) |
这些简短的指令说来就话长了。第一个 volatile
读在第 2 行。Java 内存模型保证 JVM 会在第二次 volatile
读之前,按程序顺序将第一个 volatile
读传递给 CPU 。但这还不够,因为 CPU 仍然可以乱序执行这些操作。为了维护 Java 内存模型的一致性,JVM 使用带参数的 ld.acq
(load acquire
)来注释(annotate
)第一个 volatile
读操作。通过使用 ld.acq
,编译器可以确保第 2 行上的读操作在后续的读操作之前完成。这样,问题就解决了。
注意,这影响的是读,而不是写。这里需要介绍一下单向内存屏障和双向内存屏障。
- 单向内存屏障:对读 或 写强制排序。
ld.acq
就是一个例子。 - 双向内存屏障:对读 和 写强制排序。
一致性是双向的。(Consistency is a two way street. )如果另一个线程没有将写操作和写操作分开(separate
),那么读线程在两次读操作之间插入一个内存屏障有什么用呢?为了让线程间进行通信,它们都必须遵守协议(protocol
),就像网络中的节点,或者团队中的人。如果一个线程不遵守协议,那么其他线程的工作(effort
)就没有意义。在 Dekker's algorithm
中最后两行代码(即两个 volatile
写操作)对应的汇编指令中,我们会看到一个内存屏障。
$ java -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=hsdis-print-bytes -XX:CompileCommand=print,WriterReader.write WriterReader
1 | (Itanium) |
在第 4 行,我们可以看到第二个写操作用显式内存屏障进行了注释(annotate
)。通过使用带参数的 st.rel
(或 store release
),编译器可以确保第一个写操作在第二个写操作之前是可见的。这就达成了协议的双向性(This completes both sides of the protocol),因为第一个写操作发生在第二个写操作之前。
指令 st.rel
是单向内存屏障,就像 ld.acq
一样。但是,在第 5 行,编译器还添加了一个双向内存屏障。指令 mf
(memory fence
),是 Itanium 2 指令集中的一个完全内存屏障(fully fence
)。
3 内存屏障是硬件特性
本文不打算对所有内存屏障做全面概述,这太费时费力了。重要的是要认识到,内存屏障的指令在不同的硬件架构中有相当大的差异。下面是在多核 CPU Intel Xeon 上获取的连续 volatile
写操作的汇编指令。本文中余下所有汇编指令都是基于 Intel Xeon 的,除非另有说明。
1 | (Intel Xeon x86) |
基于 x86 Xeon 的汇编指令第 11 行和第 12 行,我们看到 volatile
写操作。第二个写操作后有一个 mfence
指令(第 13 行),这是一个显式的双向内存屏障。
下面是基于 SPARC
的连续 volatile
写操作。
1 | (SPARC) |
在第 5 行和第 6 行可以看到 volatile
写操作。第二个写操作后跟随有 membar
指令(第 7 行),这也是一个显式的双向内存屏障。
x86
和 SPARC
的指令流与 Itanium
的指令流之间有一个重要的区别。JVM 在 x86
和 SPARC
上在连续的写操作后设置了内存屏障,但是在这两个写操作之间没有设置内存屏障。然而,Itanium
的指令流在两个写操作之间有一个内存屏障。
为什么 JVM 在不同的硬件架构中表现不同?
因为每种硬件架构都有一个内存模型,每个内存模型都有自己的一套一致性保证(consistent guarantee
)体系。比如 x86
或 SPARC
的内存模型,有非常强大的一致性保证。其他内存模型,如 Itanium
、PowerPC
或 Alpha
,则较为宽松(relaxed
)。比如,x86
和 SPARC
不会重排序连续的写操作,所以不需要内存屏障。而 Itanium
、PowerPC
和 Alpha
会对连续的写操作进行重排序,因此 JVM 必须在写操作之间设置一个内存屏障。
也就是说,JVM 使用内存屏障来消除 Java 内存模型和硬件的内存模型之间的差异。
4 隐式内存屏障
显式的指令 fence
不是序列化(serialize
)内存操作的唯一方法。让我们来看看 Counter
类这个例子。
1 | class Counter{ |
Counter
类中有一个经典的“读 - 修改 - 写”操作。因为这三个操作一定是原子操作(即组合起来就不是原子操作), 所以不能用 volatile
修饰静态字段 counter
,得用 synchronized
修饰方法 inc()
。我们可以使用以下命令编译 Counter
类并查看方法 inc()
生成的汇编指令。Java 内存模型为 synchronized
区域的退出操作(exiting of synchronized regions
) 提供了与 volatile
内存操作(volatile memory operations
)相同的可见性语义(visibility semantics
),因此我们会看到另一种内存屏障。
$ java -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=hsdis-print-bytes -XX:-UseBiasedLocking -XX:CompileCommand=print,Counter.inc Counter
1 | (Intel Xeon) |
不出意外,synchronized
生成的指令数量比 volatile
的多。多出部分可以在第 18 行找到,但 JVM 并没有插入显式内存屏障。相反,JVM 在第 10 行和第 25 行使用了两次带 lock
前缀的 cmpxchg
指令。(解释 cmpxchg
指令的语义超出了本文的范围。)值得注意的是,lock cmpxchg
不仅自动执行写操作,它还会刷新挂起(flush pending
)的读和写操作。写操作将在所有后续内存操作之前都可见。如果我们使用 java.util.concurrent.atomic
来重构并运行 Counter
类,我们可以看到同样的技巧(trick
)。
1 | import java.util.concurrent.atomic.AtomicInteger; |
$ java -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=hsdis-print-bytes -XX:CompileCommand=print,*AtomicInteger.incrementAndGet Counter
1 | (Intel Xeon) |
在第 14 行,我们再次看到写操作有 lock
前缀。这将确保在所有后续内存操作之前,变量的新值对其他线程都可见。
5 内存屏障可被消除
JVM 知道如何消除不必要的内存屏障。
如果硬件内存模型的一致性保证(consistency guarantee
)强于等于 Java 内存模型的一致性保证,这种情况下就比较简单,JVM 只会插入 no op
,而不是实际的内存屏障。例如,x86
和 SPARC
硬件的内存模型的一致性保证足够强大,在读取 volatile
变量时就无需设置内存屏障。
还记得在 Itanium
上用来分隔两个读操作的显式单向内存屏障(ld.acq
)吗?没错,在 x86
上 Dekker's algorithm
中连续的 volatile
读操作的汇编指令没有内存屏障。
在 x86
上,对共享内存的连续读操作。(A read followed by a read of shared memory on x86.)
1 | (x86 Dekker) |
volatile
读操作位于第 3 行和第 14 行。它们都没有配以内存屏障。换句话说,在 x86
上(或者在 SPARC
上)执行 volatile
读操作时,唯一的性能损失就是不能对指令重排优化,指令本身与普通读操作没有什么不同。
另外,单向内存屏障的开销自然要比双向的低。当 JVM 知道单向内存屏障已经足够时,它就不会使用双向内存屏障,本文中的第一个示例证明了这一点。我们看到 Itanium
上两个连续的 volatile
读操作中的第一个使用一个单向内存屏障(ld.acq
)进行注释(annotate
)。如果使用显式的双向内存屏障对读操作进行注释)进行注释(annotate
),程序仍然是正确的,但延迟开销(latency cost
)会增大。
6 动态编译
静态编译器在构建时所知道的事情,动态编译器在运行时都会知道,甚至更多。更多的信息意味着更多的优化可能。例如,让我们看看 JVM 在单处理器上运行时如何使用内存屏障。下面的指令流是 Dekker
算法中两个连续的 volatile
写操作的运行时编译结果。环境是 VMWare WorkStation 里的单处理器模式 x86
镜像。
1 | (x86) |
在单处理器系统中,JVM 为所有内存屏障插入 no op
,因为内存操作已经序列化(serialize
)了。写操作(第 10 行和第 11 行)后不会有内存屏障。JVM 对 atomic
类进行了类似的优化(第 14 行)。下面是在相同的 VMWare
镜像中, AtomicInteger.incrementAndGet()
的运行时编译结果。
1 | (x86) |
注意第 14 行中的 cmpxchg
指令。前面我们看到编译器给这个指令添加了一个 lock
前缀。在没有 SMP(symmetric multiprocessing
)的情况下,JVM 避免了这种开销,这是静态编译无法做到的。
7 收尾
内存屏障是多线程编程的必要条件。它可以分为不同类型,有显式、隐式之分,也有单向、双向之分。JVM 利用内存屏障实现跨平台的 Java 内存模型。我希望本文能够帮助有经验的 JVM 开发人员更深入地了解他们的代码的工作原理。
8 参考
- Intel 64 and IA-32 Architectures Software Developer’s Manuals
- IA-64 Application Instruction Set Architecture Guide
- Java Concurrency in Practice by Brian Goetz
- JSR-133 Cookbook by Doug Lea
- Mutual exclusion with Dekker’s Algorithm
- Examining generated code with PrintAssembly
- The Kenai Project - a disassembler plugin
- hsdis - a disassembler plugin
以上!
「Java」内存屏障和 Java 并发
https://alexinst.github.io/Java/memory-barriers-and-java-concurrency/