The JSR-133 Cookbook for Compiler Writers [译]
JSR-133 定义了新的 JMM 的规范,增强了 volatile 语义和 final 语义等。The JSR-133 Cookbook for Compiler Writers 是一份非正式的指南,本文对其进行了翻译,以方便查阅。可能存在翻译不准确甚至错误的情况,仅供参考,请对比原文查看。
由 Doug Lea 撰写,JMM 邮件列表 成员提供帮助。
前言:自本文最初撰写至今的 10 多年来,许多处理器和语言内存模型的规范及问题(specifications and issues)变得越来越清晰和易于理解,当然也有一些并非如此。虽然本指南在持续维护以保持准确性,但是其中涉及的一些不断演化的细节仍是不完善的。要了解更多的扩展内容,请重点参考 Peter Sewell 和 Cambridge Relaxed Memory Concurrency Group 的工作。
这是一份非正式指南,用于说明由 JSR-133 描述的新的 Java 内存模型 (Java Memory Model)。本文在尽可能简单的场景下描述了各种规则存在的原因,而不是关注于它们对指令重排序、多处理器屏障指令和原子操作的影响。本文包含了一系列遵守 JSR-133 规范的指南(recommended recipes )。该指南之所以是“非正式”的,是因为它包含了对特定处理器的特性和规范的解释,我们无法保证这些解释是全部正确的。另外,处理器的规范和实现也可能会随着时间而发生变化(注:随着技术的发展可能会有一定变化)。
重排序(Reorderings)
对于编译器开发者来说,JMM 主要由禁止特定指令重排序的规则组成,这些指令包括:访问字段(这些字段包括数组元素)的指令、监视器指令(锁)。
Volatiles 和 Monitors
JMM 针对 Volatiles 和 Monitors 制定的规则可以通过一个表格来表示,每个单元格表示不能对特定字节码序列的指令重排序。该表格本身不是 JMM 规范,它只是一个用来查看编译器和运行时系统结果的工具。
是否可以重排序 | 第二个操作 | |||
第一个操作 | 普通读 普通写 | Volatile 读 MonitorEnter | Volatile 写 MonitorExit | |
普通读 普通写 | 否 | |||
Volatile 读 MonitorEnter | 否 | 否 | 否 | |
Volatile 写 MonitorExit | 否 | 否 |
其中:
- 普通读指令是指:getfield、getstatic、非 volatile 字段的 array load
- 普通写指令是指:putfield、putstatic、非 volatile 字段的 array store
- Volatile 读指令是指:多线程环境下使用 getfield、getstatic 指令读 volatile 字段
- Volatile 写指令包括:多线程环境下使用 putfield、putstatic 指令写 volatile 字段
- MonitorEnters(包括进入同步代码块)用于多线程环境下锁对象的访问
- MonitorExits(包括退出同步代码块)用于多线程环境下锁对象的访问
普通读和普通写规则类似,同样情况的还有 Volatile 读和 MonitorEnter、Volatile 写和 MonitorExit,因此它们被放在同一个单元格(后续的表格中则需要分开表示)。我们这里只考虑可以原子读写的变量,也就是说,不包括位字段(bit fields)、未对齐访问(unaligned accesses)或者超过平台最大字(larger than word sizes)的访问。
表中第一个操作和第二个操作之间可能存在任意数量的其他操作。例如:[普通写,Volatile写] 单元格中的”否”表示一个非 volatile 写指令不能和任意的后续 volatile 写指令重排序,否则可能会对多线程程序语义产生影响。
JSR-133 规范描述的 volatiles 规则和监视器(monitors)规则仅适用于可能会被多线程访问的情况。如果编译器可以以某种方式(通常需要很大的代价)证明锁仅被一个线程访问,则可以将锁消除掉。类似地,如果一个 volatile 变量被证明仅被一个线程访问,那么它可以作为一个普通变量出现。还可以进行更细粒度的分析和优化,例如,那些在特定的时间间隔内,可证明无法被多个线程访问的变量。
空白的单元格表示:如果变量的访问不违反 Java 的基本语义,那么允许进行重排序(在 JLS 中有详细说明)。例如,尽管表格中没有明确说明,但是也不允许将 load 指令和后续对同一个地址操作的 store 指令重排序。如果 load 指令和 store 指令操作的是不同地址 ,则不限制重排序,并且编译器在进行各种转换和优化时更倾向于这么做。这包括通常被认为不会进行重排序的情况。例如,重复使用已加载字段的计算结果,而不是重新加载该字段值并计算结果,这相当于进行了重排序。然而,JMM 规范允许进行一些转换,以消除可避免的依赖,进而允许重排序。
在所有情况下,即使程序员没有正确地同步访问,重排序也必须保持最小的 Java 安全特性:所有观察到的字段值,要么是默认的 0 或 null 这种”预构造”值,要么是由某个线程写入的值。对于所有堆内存中的对象,通常需要在构造函数执行之前对其进行归零,并且不允许把用于归零的 store 操作和其他的 load 操作重排序。一个好的实现方法是:在进行垃圾回收时对要回收的内存归零。更多用于处理安全保证问题的规则,请查阅 JSR-133 规范。
这里描述的规则和特性适用于对 Java 级别字段( Java-level fields)的访问。实际上,它们还会与访问内部记录的字段和数据进行交互,例如对象头、GC 表和动态生成的代码。
Final 字段(Final Fields)
相对于加锁和 volatile 来说,对 final 字段的读(Loads)和写(Stores) 可以认为是”普通”的访问,但是增加了两个额外的重排序规则:
针对在构造函数内对 final 字段的写操作,如果该字段是引用类型,那么如下两个操作不能重排序:1)对该字段的任何写操作 2)随后在构造函数外对持有该 final 字段的对象的写操作(该操作会将 final 字段提前暴露给其他线程)。例如,不能对如下指令重排序:
1
2
3x.finalField = v; // 写对象的 final 字段
... ;
sharedRef = x; // 暴露该对象的引用例如在内联构造函数时会生效(这里 “…” 跨越了构造函数的逻辑结束位置):不能将构造函数内对 final 字段的写操作移到构造函数外的写操作后面(这里指的是可能会将 final 字段所在对象提前暴露给其他线程的写操作)。类似地,对于如下指令,不能将前两个指令和第三个赋值操作指令重排序。
1
2
3
4v.afield = 1;
x.finalField = v; // 对 final 字段的写
... ;
sharedRef = x; // 读取包含 final 字段的对象如下两个操作不能重排序:1)初次对 final 字段的读操作(一个线程内的第一次读取该 final 字段)2)初次对包含该 final 字段的对象的读操作。该规则应用到如下指令上:
1
2
3x = sharedRef; // 读取包含 final 字段的对象
... ;
i = x.finalField; // 读取 final 字段编译器不会对这些指令重排序,因为它们之间存在依赖关系,但是这条规则可能会对某些处理器产生影响。
这些规则意味着,要保证 Java 程序员对 final 字段使用的可靠性,对持有 final 字段的共享对象的读操作有一定要求,它应当是 synchronized、volatile、final 或者来自类似的读操作。这样最终使得构造函数内的初始化写操作与构造函数外的后续使用操作保持有序。
内存屏障(Memory Barriers)
编译器和处理器都必须遵守重排序规则。在单处理器(注:这里是指单线程)的情况下,不需要任何额外的操作便能保持正确的顺序,因为需要保证”as-if-sequential”一致性。但是对于多处理器来说,保证一致性通常需要增加内存屏障指令。即使可以优化掉字段的访问(例如因为未使用加载到的值),编译器仍然需要生成内存屏障,就好像字段访问仍然存在一样(当然下面会看到,可以单独将内存屏障优化掉)。
内存屏障只与内存模型中的高级概念(例如 “acquire” 和 “release”)间接相关。内存屏障本身不是”同步屏障”。内存屏障与一些垃圾收集器中使用的”写屏障”无关。内存屏障指令只直接控制 CPU 与其缓存的交互,以及它的写缓冲区(持有等待刷新到内存的存储)和/或它的用于等待加载或推测执行指令的缓冲。这些影响可能导致缓存、主内存和其他处理器之间的进一步交互。但是 JMM 并没有规定处理器之间的通信方式,只要求数据最终变成全局生效,对所有处理器可见,并且当其可见时,可以加载到对应的值。
分类(Categories)
几乎所有的处理器都至少支持一个粗粒度的屏障指令(通常称为 Fence),它保证了严格的有序性:在 Fence 之前的所有读操作(load)和写操作(store)先于在 Fence 之后的所有读操作(load)和写操作(store)执行完。对于任何的处理器来说,这通常都是最耗时的指令之一(它的开销通常接近甚至超过原子操作指令)。大多数处理器还支持更细粒度的屏障指令。
内存屏障的一个特性是它们通常用于内存访问指令之间。尽管某些处理器给出了屏障指令的名称,但是如何选择正确的/最佳的屏障,取决于屏障本身隔离的访问类型。如下是一个常见的屏障类型分类,它可以很好地对应到现有处理器上的特定指令(有时是无操作 no-ops):
LoadLoad 屏障
指令
Load1; LoadLoad; Load2
保证了 Load1 先于 Load2 和后续所有的 load 指令加载数据。通常情况下,在执行预测读(speculative loads)和/或乱序处理(out-of-order processing)的处理器上需要显式的 LoadLoad 屏障,其中等待读(load)指令可以绕过等待写(sotre)指令。在始终保证读顺序(load ordering)的处理器上,这些屏障相当于无操作(no-ops)。StoreStore 屏障
指令
Store1; StoreStore; Store2
保证了 Store1 的数据先于 Store2 及后续 store 指令的数据对其他处理器可见(刷新到内存)。通常情况下,在不保证严格按照顺序从写缓冲区(write buffers)和/或者缓存(caches)刷新到其他处理器或者主内存的处理器上,需要使用 StoreStore 屏障。LoadStore 屏障
指令
Load1; LoadStore; Store2
保证了 Load1 的加载数据先于 Store2 及后续 store 指令刷新数据到主内存。只有在乱序(out-of-order)处理器上,等待写指令(waiting store instructions)可以绕过读指令(loads)的情况下,才会需要使用 LoadStore 屏障。StoreLoad 屏障
指令
Store1; StoreLoad; Load2
保证了 Store1 的数据对其他处理器可见(刷新数据到主内存)先于 Load2 及后续的 load 指令加载数据。StoreLoad 屏障可以防止后续的读操作(load)错误地使用了 Store1 写的数据,而不是使用来自另一个处理器的更近(more recent)的对同一位置(same location)的写。因此,在下面讨论的处理器上,只有需要将对同一个位置的写操作(stores)和随后的读操作(loads)分开时,才严格需要 StoreLoad 屏障。StoreLoad 屏障通常是开销最大的屏障,几乎所有的现代处理器都需要该屏障。之所以开销大,部分原因是它需要禁用绕过缓存(cache)从写缓冲区读取数据(loads from write-buffers)的机制。这可以通过让缓冲区完全刷新,外加暂停其他操作来实现。
在下面讨论的所有处理器中,事实证明,执行 StoreLoad 指令同时也获得了其他三个屏障的效果,因此,StoreLoad 可以作为一个通用目的(但是通常开销比较大)的屏障(Fence),这是一个经验上的事实,而不是必须这么做。这种组合反之则不成立,通过组合其他屏障通常不能获得与 StoreLoad 屏障相同的效果。
下表展示了这些屏障如何与 JSR-133 排序规则相对应的:
需要的屏障 | 第二个操作 | |||
第一个操作 | 普通读 | 普通写 | Volatile 读 MonitorEnter | Volatile 写 MonitorExit |
普通读 | LoadStore | |||
普通写 | StoreStore | |||
Volatile 读 MonitorEnter | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile 写 MonitorExit | StoreLoad | StoreStore |
1 | x.finalField = v; |
如下是一个设置屏障的例子:
1 | class X { |
数据依赖和屏障(Data Dependency and Barriers)
在一些处理器上,是否需要 LoadLoad 或 LoadStore 屏障与有依赖指令的顺序保证特性有关。在某些(大多数)处理器上,如果一个读操作指令(load)或者写操作指令(store)依赖于前一个读操作(load)指令的值,处理器会保证这两个操作的顺序,而不需要显示地设置屏障。这通常出现在两种情况下,一种是间接依赖:
1 | Load x; |
另一种是控制依赖:
1 | Load x; |
对于不遵守间接排序的处理器,final 字段的访问尤其需要设置屏障,以便通过共享引用获取最初设置的 final 字段引用:
1 | x = sharedRef; // 共享引用的读操作(Load) |
相反地,正如下面讨论的,针对必须放置 LoadLoad 和 LoadStore 屏障的情况,遵守数据依赖的处理器提供了一些将屏障优化掉的机会(但是,任何处理器都不会自动移除 StoreLoad 屏障)。
与原子指令的相互作用(Interactions with Atomic Instructions)
不同处理器上需要的屏障种类与 MonitorEnter 和 MonitorExit 的实现存在相互影响。加锁和/或解锁通常需要使用原子条件更新操作 CompareAndSwap(CAS)或者 LoadLinked/StoreConditional(LL/SC),这些操作的语义是:在一个 volatile 读操作之后跟着一个 volatile 写操作。虽然 CAS 或 LL/SC 已经最低限度地满足需求,一些处理器还支持其他的原子指令(例如无条件交换指令),有时可以使用它代替原子条件更新或与原子条件更新一起使用。
在所有处理器上,原子操作可以防止正在读取/更新的内存出现写后读问题(否则,标准的循环-直到成功结构将不会以期望的方式工作)。和隐式地在目标位置使用 StoreLoad 屏障相比,原子指令是否提供了更通用的屏障属性?这在不同的处理器上表现也不一样。在某些处理器上,这些指令本质上还是执行的 MonitorEnter/Exit 所需的屏障。在另外的一些处理器上,这些屏障的部分或者全部必须明确地进行设置。
这里需要将 Volatiles 和 Monitors 区分开来,以便搞清楚它们的作用:
需要的屏障 | 第二个操作 | |||||
第一个操作 | 普通读 | 普通写 | Volatile 读 | Volatile 写 | MonitorEnter | MonitorExit |
普通读 | LoadStore | LoadStore | ||||
普通写 | StoreStore | StoreExit | ||||
Volatile 读 | LoadLoad | LoadStore | LoadLoad | LoadStore | LoadEnter | LoadExit |
Volatile 写 | StoreLoad | StoreStore | StoreEnter | StoreExit | ||
MonitorEnter | EnterLoad | EnterStore | EnterLoad | EnterStore | EnterEnter | EnterExit |
MonitorExit | ExitLoad | ExitStore | ExitEnter | ExitExit |
再加上特殊的 final 字段规则,需要设置一个 StoreStore 屏障:
1 | x.finalField = v; // final 字段的写操作(Store) |
在这个表格中,”Enter” 与 “Load” 相同,”Exit” 与 “Store” 相同,除非被原子指令的用途和性质覆盖。特别地:
- EnterLoad 在进入任何需要执行 load 指令的同步代码块或同步方法时都需要该指令。它与 LoadLoad 相同,除非在 MonitorEnter 中使用原子指令,并且它本身提供了至少具有 LoadLoad 属性的屏障,在这种情况下它相当于无操作(a no-op)。
- StoreExit 在退出任何需要执行 store 指令的同步代码块或同步方法时都需要该指令。它与 StoreStore 相同,除非在 MonitorExit 中使用原子指令,并且原子指令本身提供至少具有 StoreStore 属性的屏障,在这种情况下它相当于无操作(a no-op)。
- ExitEnter 与 StoreLoad 相同,除非在 MonitorExit 和/或 MonitorEnter 中使用原子指令,并且其中至少有一个提供了至少具有 StoreLoad 属性的屏障,在这种情况下它相当于无操作(a no-op)。
其他类型比较特殊,在编译时和/或者当前处理器执行时,这些指令不太可能减少至无操作。例如,对于嵌套的 MonitorEnters 指令,如果中间没有 load 指令或者 store 指令,那么就使用 EnterEnter 来隔离。如下是一个例子,展示了大多数类型的使用:
1 | class X { |
Java 级别对原子条件更新操作的访问将在 JDK1.5 中通过 JSR-166 (并发工具包) 提供,因此编译器将需要发布相关代码,使用了上表的一个变体,它展示了 MonitorEnter 和 MonitorExit 的使用——在语义上,有时在实践中,这些 Java 级别的原子更新和使用锁的效果一样。
多处理器(Multiprocessors)
以下是 MP 中常用的处理器列表,以及提供相关信息的文档链接(有些需要免费注册后才能查看手册)。这不是一个详尽的列表,但是它包括了我所知道的当前和不远的将来在 Java 实现中使用的所有多处理器。下面的列表和处理器属性存在不确定性,在某些情况下,我只是报告我读到的东西,也可能会存在误解。关于与 JMM 相关的一些属性,一些参考手册描述的不是特别清楚,请帮助完善它。
关于未列出的机器屏障和相关属性的硬件规范信息,还有一些不错的参考文档:Hans Boehm’s atomic_ops library、Linux Kernel Source、Linux Scalability Effort。Linux 内核所需的屏障与这里讨论的屏障可以直接对应,并且已经移植到大多数处理器中。有关不同处理器支持的基础模型的描述,请查阅 Sarita Adve et al, Recent Advances in Memory Consistency Models for Hardware Shared-Memory Systems 和 Sarita Adve and Kourosh Gharachorloo, Shared Memory Consistency Models: A Tutorial。
sparc-TSO
TSO (Total Store Order) 模式下的 Ultrasparc 1, 2, 3 (sparcv9),Ultra3s 只支持 TSO 模式。(Ultra1/2 中的 RMO 模式从未使用过,所以可以忽略)。请查阅 UltraSPARC III Cu User’s Manual and The SPARC Architecture Manual, Version 9 。
x86 (and x64)
Intel 486 以上的处理器,包括 AMD 和其他处理器。在 2005 - 2009 年有过一系列的重新规范,但是当前的规范与 TSO 基本一致,主要不同之处在于支持不同的缓存模式,以及对一些特殊情况的处理,例如未对齐访问和特殊格式的指令等。请查阅 The IA-32 Intel Architecture Software Developers Manuals: System Programming Guide 和 AMD Architecture Programmer’s Manual Programming。
ia64
Itanium。请查阅 Intel Itanium Architecture Software Developer’s Manual, Volume 2: System Architecture。
ppc (POWER)
所有的版本有相同的基本内存模型,但是一些内存屏障指令的名字和定义随着时间而改变。如下列出的是自 Power4 后的最新版本,详细信息请查阅架构手册。请查阅 MPC603e RISC Microprocessor Users Manual、MPC7410/MPC7400 RISC Microprocessor Users Manual 、Book II of PowerPC Architecture Book、PowerPC Microprocessor Family: Software reference manual、Book E- Enhanced PowerPC Architecture、EREF: A Reference for Motorola Book E and the e500 Core。有关内存屏障的讨论,请查看 IBM article on power4 barriers 和 IBM article on powerpc barriers。
arm
Version 7+。请查阅 ARM processor specifications。
alpha
21264x 和(我认为)其他所有的处理器。请查阅 Alpha Architecture Handbook。
pa-risc
HP pa-risc 实现,请查阅 pa-risc 2.0 Architecture 手册。
如下是这些处理器支持的屏障和原子操作:
处理器 | LoadStore | LoadLoad | StoreStore | StoreLoad | Data dependency orders loads? | 原子条件 | 其他原子操作 | Atomics provide barrier? |
---|---|---|---|---|---|---|---|---|
sparc-TSO | 无操作 | 无操作 | 无操作 | membar (StoreLoad) | 是 | CAS: casa | swap, ldstub | full |
x86 | 无操作 | 无操作 | 无操作 | mfence or cpuid or locked insn | 是 | CAS: cmpxchg | xchg, locked insn | full |
ia64 | combine with st.rel or ld.acq | ld.acq | st.rel | mf | 是 | CAS: cmpxchg | xchg, fetch add | target + acq/rel |
arm | dmb (see below) | dmb (see below) | dmb-st | dmb | indirection only | LL/SC: ldrex/strex | target only | |
ppc | lwsync (see below) | hwsync (see below) | lwsync | hwsync | indirection only | LL/SC: ldarx/stwcx | target only | |
alpha | mb | mb | wmb | mb | 否 | LL/SC: ldx_l/stx_c | target only | |
pa-risc | 无操作 | 无操作 | 无操作 | 无操作 | 是 | build from ldcw | ldcw | (NA) |
说明(Notes)
- 表格中列出的一些屏障指令比实际需要的特性更强,但是这似乎是代价最小的(获取所需效果)实现方式。
- 上面列出的屏障指令是为使用普通程序内存设计的,但是对于 IO 和系统任务使用的其他特殊形式/模式的缓存和内存来说,这些屏障不是必须的。例如在 x86-SPO 上,StoreStore 屏障(”sfence”)需要和 WC 缓存模式(WriteCombining caching mode)一起使用,这样设计用于系统级别的块传输等场景。操作系统为程序和数据使用写回(Writeback)模式,这样就不需要 StoreStore 屏障了。
- 在 x86 上,任何 lock 前缀的指令都可以作为 StoreLoad 屏障使用(在 linux 内核中的使用形式是无操作的
lock; addl $0,0(%%esp)
)。支持 “SSE2” 扩展的版本(Pentium4和更高版本)支持 mfence 指令,使用该指令似乎更合适,除非必须使用像 CAS 这样的带 lock 前缀的指令。cpuid 指令同样可以作为 StoreLoad 屏障,但是它的速度较慢。 - 在 ia64 上,LoadStore、LoadLoad 和 StoreStore 屏障被合并为特殊格式的 load 和 store 指令——而不再是单独的指令。
ld.acq
作为load; LoadLoad+LoadStore
,st.rel
作为LoadStore+StoreStore; store
。这两种都没有提供 StoreLoad 屏障,需要通过分开的 mf 屏障实现该功能。 - 在 ARM 和 ppc 上,在数据依赖关系存在的情况下,有机会使用 non-fence-based 指令序列替换 load fence。这些指令序列及应用案例在 Cambridge Relaxed Memory Concurrency Group 的工作中有描述。
- sparc membar 屏障指令支持所有的(四种)屏障模式,还支持组合模式。但是在 TSO 中只需要 StoreLoad 模式。在一些 UltraSparcs 中,不管是何种模式,任何 membar 指令都能生成 StoreLoad 的效果。
- 支持 “流式 SIMD” SSE2 扩展的 x86 处理器仅在与这些流式指令连接时才需要 LoadLoad “lfence”。
- 尽管 pa-risc 规范并没有强制规定,所有的 HP pa-risc 实现都是顺序一致的,所以不需要内存屏障指令。
- 在 pa-risc 上唯一的原子原语是 ldcw,是 test-and-set 的一种形式。您将需要使用类似 HP 白皮书之自旋锁 这种技术来构建原子条件更新。
- CAS 和 LL/SC 在不同的处理器上有多种形式,仅在字宽方面有区别,至少要包括 4 字节和 8 字节版本。
- 在 sparc 和 x86 上,CAS 有隐式的前置和后置全 StoreLoad 屏障。sparc v9 体系结构手册说明 CAS 不需要 post-StoreLoad 屏障特性,但是 ultrasparcs 芯片手册指出存在该特性。
- 在 ppc 和 alpha 上,只有在对指定的内存位置进行读/写操作(loaded/stored)时,LL/SC 才会有隐式的屏障,但是没有更通用的屏障属性。
- 在对指定的内存位置进行读/写操作(loaded/stored)时,ia64 平台的 cmpxchg 指令也会有隐式的屏障,但是还需要一个可选的 .acq (post-LoadLoad+LoadStore)或 .rel (pre-StoreStore+LoadStore)修饰符。cmpxchg.acq 这种形式可用于 MonitorEnter,cmpxchg.rel 可用于 MonitorExit。在那些不能保证出口(exits)和入口(enters)匹配的情况下,可能还需要一个 ExitEnter(StoreLoad)屏障。
- Sparc、x86 和 ia64 平台支持无条件交换指令(swap, xchg)。 Sparc 的 ldstub 是一个单字节的测试-并设置(test-and-set)指令。ia64 的 fetchadd 指令返回前一个值并且加上指定的值。在 x86 上,一些指令(例如 add-to-memory)可以加上 lock 前缀,从而使得它们具有原子性。
指南(Recipes)
单处理器(Uniprocessors)
如果可以确保生成的代码只在单处理器上运行,那么可以跳过本节的其余部分。因为单处理器保持了明显的顺序一致性,所以不需要设置屏障,除非对象内存以某种方式与可异步访问的 IO 内存共享。在使用特殊的 java.nio 缓存映射时可能会出现这种情况,但是可能只会影响内部的 JVM 支持代码,而不是 Java 代码。另外,可以想象得到,如果上下文切换不能保证足够的同步性,那么会需要一些特殊的屏障。
插入屏障(Inserting Barriers)
屏障指令用于在执行程序期间发生的不同类型的访问之间。很难找到一个”最佳”的位置使得最大限度地减少执行屏障的总数。编译器通常无法判断给定的读(load)或者写(store)操作是否会出现在另一个需要屏障的操作的前面或者后面,例如在一个 volatile 写后面跟着一个 return 操作。最简单的保守策略是:在为任何给定的 load、store、lock 或者 unlock 生成代码时,假定对应的访问需要”最重”的屏障:
在每一个 volatile 写之前设置一个 StoreStore 屏障
(在ia64上,你必须将它和大多数的屏障合并成对应的读(load)或者写(store)指令。)针对带有 final 字段的类,在构造函数返回之前、所有的写之后设置 StoreStore 屏障
在每一个 volatile 写之后设置一个 StoreLoad 屏障
请注意,你也可以在每一个 volatile 读之前设置一个 StoreLoad 屏障,但是对于典型的使用 volatile 的程序来说,读操作数量远大于写操作,这样会使得程序更慢。另外,如果可能的化,可以使用原子指令(例如 x86 的 XCHG 指令)实现 volatile 写进而消除屏障。如果原子指令比 StoreLoad 屏障的开销更低,这种实现方式会更高效。在每一个 volatile 读之后设置 LoadLoad 和 LoadStore 屏障
在维持数据依赖顺序性的处理器上,如果下一条访问指令依赖于前面 load 的值,则无需设置屏障。特别地,在对一个 volatile 引用的读操作(load)之后,如果后续的指令是 null 检查或者是对该引用的字段的读操作(load),则不需要设置屏障。
在每一个 MonitorEnter 之前或者 MonitorExit 之后设置 ExitEnter 屏障
(如上所述,如果 MonitorExit 或 MonitorEnter 使用原子指令,则 ExitEnter 是一个空操作(no-op),该原子指令提供了相当于存储 StoreLoad 屏障的功能。类似地,在其余步骤中涉及 Enter 和 Exit 的其他操作也是如此。)在每一个 MonitorEnter 后设置 EnterLoad 和 EnterStore 屏障
在每一个 MonitorExit 前设置 StoreExit 和 LoadExit 屏障
如果处理器没有内置支持非直接读的顺序性(ordering on indirect loads),那么在 final 字段的每一次读操作(load)前插入一个 LoadLoad 屏障。(一些可替代的策略在这份 JMM 邮件列表 和 linux 数据依赖屏障的描述 中有讨论到)
上述屏障很多都可以简化为空操作。实际上,它们中的大多数都简化为空操作,但是在不同的处理器和锁机制下实现的方式也不一样。举个简单的例子,在 x86 或 sparc-TSO 平台使用 CAS 锁实现 JSR-133 的基本一致性,相当于在 volatile 写之后放置了一个 StoreLoad 屏障。
移除屏障(Removing Barriers)
上面的保守策略可能在很多程序中都执行得很好。volatile 的主要性能问题在于与写操作关联的 StoreLoad 屏障,不过它的影响相对比较小,因为在并发程序中使用 volatile 的主要原因是为了避免在读取时使用锁,通常读操作远大于写操作。然而该策略至少可以通过以下方式加以改进:
移除多余的屏障。上面的表格说明,屏障可以按照如下规则消除:
原始操作 => 转换后 第一个操作 ops 第二个操作 => 第一个操作 ops 第二个操作 LoadLoad [no loads] LoadLoad => [no loads] LoadLoad LoadLoad [no loads] StoreLoad => [no loads] StoreLoad StoreStore [no stores] StoreStore => [no stores] StoreStore StoreStore [no stores] StoreLoad => [no stores] StoreLoad StoreLoad [no loads] LoadLoad => StoreLoad [no loads] StoreLoad [no stores] StoreStore => StoreLoad [no stores] StoreLoad [no volatile loads] StoreLoad => [no volatile loads] StoreLoad 类似的消除可以用于与锁的交互,但是这取决于锁是如何实现的。关于在循环、调用和分支存在的情况下如何消除屏障,就留给读者作为练习了。:-)
重排序代码(在允许的约束范围内)以便移除一些不再需要的 LoadLoad 屏障和 LoadStore 屏障(因为存在数据依赖使得处理器阻止了这种重排序)。
移动屏障在指令流中放置的位置,以便改善调度效率,只要当需要他们的时候仍会在适当的间隔位置出现。
移除不存在多线程依赖的屏障,例如 volatile 变量被证明只对一个线程可见。同样地,当可以证明线程只会对变量进行写操作(store)或读操作(load)时,可以移除使用到的屏障。这些通常都需要大量的分析。
杂记(Miscellany)
JSR-133 还讨论了一些其他的问题,在一些很特殊的场景下可能也需要使用屏障:
- Thread.start() 需要用到屏障,从而使得在调用点对调用者可见的所有写(store),对于启动后的线程同样可见。相反地,Thread.join() 也需要使用屏障,以确保调用者可以看到结束线程的所有写(store)。这些屏障通常由实现这些构造所需的同步生成。
- Static final 初始化需要 StoreStore 屏障,这通常包含在遵守 Java 类加载和初始化规则所需的机制中。
- 确保默认的 0/null 初始字段值通常需要设置屏障、同步和/或垃圾收集器中的低级(low-level)缓存控制。
- 需要特别关注在构造函数外或静态初始化方法外“魔法般地”设置 System.in、System.out 和 System.err 的 JVM 私有例程,因为它们是 JMM final 字段规则的特殊遗留情况。
- 类似地,设置 final 字段的 JVM 内部反序列化代码通常需要一个 StoreStore 屏障。
- Finalization 方法可能需要屏障(在垃圾回收器内部)以确保 Object.finalize 代码在对象不可引用前看到对所有字段的写(store)。这通常通过在引用队列中添加和删除引用时使用同步来保证。
- 对 JNI 例程的调用和从 JNI 例程返回可能需要屏障,尽管这看起来像是实现的质量问题。
- 大多数处理器都有其他同步指令,主要用于 IO 和 OS 操作。这些不会直接影响 JMM 问题,但可能涉及 IO、类加载和动态代码生成。
鸣谢(Acknowledgments)
感谢如下人员纠错和提出建议:
Bill Pugh, Dave Dice, Jeremy Manson, Kourosh Gharachorloo, Tim Harris, Cliff Click, Allan Kielstra, Yue Yang, Hans Boehm, Kevin Normoyle, Juergen Kreileder, Alexander Terekhov, Tom Deneau, Clark Verbrugge, Peter Kessler, Peter Sewell, Jan Vitek, Richard Grisenthwaite
最后修改时间: Tue Mar 22 07:11:36 EDT 2011
- 2019-03-13
并发编程中经常会使用到 volatile 关键字,使用该关键字修饰的共享变量可以保证多线程之间的可见性,也就是说当一个线程修改了变量的值,另一个线程能够读到修改后的值。
- 2019-02-17
单例模式确保一个类只有一个实例,并且为该实例提供全局的访问方式。其实现方式有多种,每种实现方式均有自己的特点。