Skip to content

Instantly share code, notes, and snippets.

@yulewei
Last active Jul 18, 2021
Embed
What would you like to do?
Java内存模型Cookbook

转载自:http://ifeve.com/jmm-cookbook/

原文地址:http://gee.cs.oswego.edu/dl/jmm/cookbook.html

作者:Doug Lea,由 JMM 邮件组的成员提供帮助,dl@cs.oswego.edu

译者:潘曦,丁一,古圣昌,欧振聪,方腾飞; 校对:欧振聪,方腾飞

前言:从最初的探索至今已经有十年了。在此期间,很多关于处理器和语言的内存模型的规范和问题变得更清楚,更容易理解,但还有一些没有研究清楚。本指南一直在修订、完善来保证它的准确性,然而本指南部分内容展开的细节还不是很完整。想更全面的了解,可以特别关注下 Peter Sewell 和 Cambridge Relaxed Memory Concurrency Group 的研究工作。

这是一篇用于说明在 JSR-133 中制定的新 Java 内存模型(JMM)的非官方指南。这篇指南提供了在最简单的背景下各种规则存在的原因,而不是这些规则在指令重排、多核处理器屏障指令和原子操作等方面对编译器和JVM所造成的影响。它还包括了一系列遵循 JSR-133 的指南。本指南是“非官方”的文档,因为它还包括特定处理器性能和规范的解释,我们不能保证所有的解释都是正确的,此外,处理器的规范和实现也可能会随时改变。

指令重排

对于编译器的编写者来说,Java 内存模型(JMM)主要是由禁止指令重排的规则所组成的,其中包括了字段(包括数组中的元素)的存取指令和监视器(锁)的控制指令。

volatile 与监视器

JMM 中关于 volatile 和监视器主要的规则可以被看作一个矩阵。这个矩阵的单元格表示在一些特定的后续关联指令的情况下,指令不能被重排。下面的表格并不是 JMM 规范包含的,而是一个用来观察 JMM 模型对编译器和运行系统造成的主要影响的工具。

Can Reorder 2nd operation
1st operation Normal Load
Normal Store
Volatile Load
MonitorEnter
Volatile Store
MonitorExit
Normal Load
Normal Store


No
Volatile Load
MonitorEnter
No No No
Volatile store
MonitorExit

No No

关于上面这个表格一些术语的说明:

  • Normal Load 指令包括:对非 volatile 字段的读取,getfieldgetstaticarray load
  • Normal Store 指令包括:对非 volatile 字段的存储,putfieldputstaticarray store
  • Volatile load 指令包括:对多线程环境的 volatile 变量的读取,getfieldgetstatic
  • Volatile store 指令包括:对多线程环境的 volatile 变量的存储,putfieldputstatic
  • MonitorEnters 指令(包括进入同步块 synchronized 方法)是用于多线程环境的锁对象;
  • MonitorExits 指令(包括离开同步块 synchronized 方法)是用于多线程环境的锁对象。

在 JMM 中,Normal Load 指令与 Normal store 指令的规则是一致的,类似的还有 Volatile load 指令与 MonitorEnter 指令,以及 Volatile store 指令与 MonitorExit 指令,因此这几对指令的单元格在上面表格里都合并在了一起(但是在后面部分的表格中,会在有需要的时候展开)。在这个小节中,我们仅仅考虑那些被当作原子单元的可读可写的变量,也就是说那些没有位域(bit fields),非对齐访问(unaligned accesses)或者超过平台最大字长(word size)的访问。

任意数量的指令操作都可被表示成这个表格中的第一个操作或者第二个操作。例如在单元格 [Normal Store, Volatile Store] 中,有一个 No,就表示任何非 volatile 字段的 store 指令操作不能与后面任何一个 Volatile store 指令重排, 如果出现任何这样的重排会使多线程程序的运行发生变化。

JSR-133 规范规定上述关于 volatile 和监视器的规则仅仅适用于可能会被多线程访问的变量或对象。因此,如果一个编译器可以最终证明(往往是需要很大的努力)一个锁只被单线程访问,那么这个锁就可以被去除。与之类似的,一个 volatile 变量只被单线程访问也可以被当作是普通的变量。还有进一步更细粒度的分析与优化,例如:那些被证明在一段时间内对多线程不可访问的字段。

在上表中,空白的单元格代表在不违反 Java 的基本语义下的重排是允许的(详细可参考 JLS 中的说明)。例如,即使上表中没有说明,但是也不能对同一个内存地址上的 load 指令和之后紧跟着的 store 指令进行重排。但是你可以对两个不同的内存地址上的 loadstore 指令进行重排,而且往往在很多编译器转换和优化中会这么做。这其中就包括了一些往往不认为是指令重排的例子,例如:重用一个基于已经加载的字段的计算后的值而不是像一次指令重排那样去重新加载并且重新计算。然而,JMM规范允许编译器经过一些转换后消除这些可以避免的依赖,使其可以支持指令重排。

在任何的情况下,即使是程序员错误的使用了同步读取,指令重排的结果也必须达到最基本的 Java 安全要求。所有的显式字段都必须不是被设定成 0null 这样的预构造值,就是被其他线程设值。这通常必须把所有存储在堆内存里的对象在其被构造函数使用前进行归零操作,并且从来不对归零 store 指令进行重排。一种比较好的方式是在垃圾回收中对回收的内存进行归零操作。可以参考 JSR-133 规范中其他情况下的一些关于安全保证的规则。

这里描述的规则和属性都是适用于读取 Java 环境中的字段。在实际的应用中,这些都可能会另外与读取内部的一些记账字段和数据交互,例如对象头,GC 表和动态生成的代码。

final 字段

final 字段的 loadstore 指令相对于有锁的或者 volatile 字段来说,就跟 Normal loadNormal store 的存取是一样的,但是需要加入两条附加的指令重排规则:

  1. 如果在构造函数中有一条 final 字段的 store 指令,同时这个字段是一个引用,那么它将不能与构造函数外后续可以让持有这个 final字段的对象被其他线程访问的指令重排。例如:你不能重排下列语句:

    x.finalField = v; ... ; sharedRef = x;

    这条规则会在下列情况下生效,例如当你内联一个构造函数时,正如“...”的部分表示这个构造函数的逻辑边界那样。你不能把这个构造函数中的对于这个 final 字段的 store 指令移动到构造函数外的一条 store 指令后面,因为这可能会使这个对象对其他线程可见。(正如你将在下面看到的,这样的操作可能还需要声明一个内存屏障)。类似的,你不能把下面的前两条指令与第三条指令进行重排:

    x.afield = 1; x.finalField = v; ... ; sharedRef = x;
  2. 一个 final 字段的初始化 load 指令不能与包含该字段的对象的初始化 load 指令进行重排。在下面这种情况下,这条规则就会生效:

    x = shareRef; ... ; i = x.finalField;

    由于这两条指令是依赖的,编译器将不会对这样的指令进行重排。但是,这条规则会对某些处理器有影响。

    上述规则,要求对于带有 final 字段的对象的 load 本身是 synchronizedvolatilefinal 或者来自类似的 load 指令,从而确保 Java 程序员对与 final 字段的正确使用,并最终使构造函数中初始化的 store 指令和构造函数外的 store 指令排序。

内存屏障

编译器和处理器必须同时遵守重排规则。由于单核处理器能确保与“顺序执行”相同的一致性,所以在单核处理器上并不需要专门做什么处理,就可以保证正确的执行顺序。但在多核处理器上通常需要使用内存屏障指令来确保这种一致性。即使编译器优化掉了一个字段访问(例如,因为一个读入的值未被使用),这种情况下还是需要产生内存屏障,就好像这个访问仍然需要保护。(可以参考下面的优化掉内存屏障的章节)。

内存屏障仅仅与内存模型中“获取”、“释放”这些高层次概念有间接的关系。内存屏障并不是“同步屏障”,内存屏障也与在一些垃圾回收机制中“写屏障(write barriers)”的概念无关。内存屏障指令仅仅直接控制 CPU 与其缓存之间,CPU 与其准备将数据写入主存或者写入等待读取、预测指令执行的缓冲中的写缓冲之间的相互操作。这些操作可能导致缓冲、主内存和其他处理器做进一步的交互。但在 Java 内存模型规范中,没有强制处理器之间的交互方式,只要数据最终变为全局可用,就是说在所有处理器中可见,并当这些数据可见时可以获取它们。

内存屏障的种类

几乎所有的处理器至少支持一种粗粒度的屏障指令,通常被称为“栅栏(Fence)”,它保证在栅栏前初始化的 loadstore 指令,能够严格有序的在栅栏后的 loadstore 指令之前执行。无论在何种处理器上,这几乎都是最耗时的操作之一(与原子指令差不多,甚至更消耗资源),所以大部分处理器支持更细粒度的屏障指令。

内存屏障的一个特性是将它们运用于内存之间的访问。尽管在一些处理器上有一些名为屏障的指令,但是正确的/最好的屏障使用取决于内存访问的类型。下面是一些屏障指令的通常分类,正好它们可以对应上常用处理器上的特定指令(有时这些指令不会导致操作)。

  • LoadLoad 屏障

    序列:Load1; Loadload; Load2

    确保 Load1 所要读入的数据能够在被 Load2 和后续的 load 指令访问前读入。通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明 Loadload 屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。

  • StoreStore 屏障

    序列:Store1; StoreStore; Store2

    确保 Store1 的数据在 Store2 以及后续 Store 指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用 StoreStore 屏障。

  • LoadStore 屏障

    序列:Load1; LoadStore; Store2

    确保 Load1 的数据在 Store2 和后续 Store 指令被刷新之前读取。在等待 Store 指令可以越过 load 指令的乱序处理器上需要使用 LoadStore 屏障。

  • StoreLoad 屏障

    序列:Store1; StoreLoad; Load2

    确保 Store1 的数据在被 Load2 和后续的 Load 指令读取之前对其他处理器可见。StoreLoad 屏障可以防止一个后续的 load 指令不正确的使用了 Store1 的数据,而不是另一个处理器在相同内存位置写入一个新数据。正因为如此,所以在下面所讨论的处理器为了在屏障前读取同样内存位置存过的数据,必须使用一个 StoreLoad 屏障将存储指令和后续的加载指令分开。Storeload 屏障在几乎所有的现代多处理器中都需要使用,但通常它的开销也是最昂贵的。它们昂贵的部分原因是它们必须关闭通常的略过缓存直接从写缓冲区读取数据的机制。这可能通过让一个缓冲区进行充分刷新(flush),以及其他延迟的方式来实现。

在下面讨论的所有处理器中,执行 StoreLoad 的指令也会同时获得其他三种屏障的效果。所以 StoreLoad 可以作为最通用的(但通常也是最耗性能)的一种 Fence。(这是经验得出的结论,并不是必然)。反之不成立,为了达到 StoreLoad 的效果而组合使用其他屏障并不常见。

下表显示这些屏障如何符合 JSR-133 排序规则。

Processor LoadStore LoadLoad StoreStore StoreLoad Data dependency orders loads? Atomic Conditional Other Atomics Atomics provide barrier?
sparc-TSO no-op no-op no-op membar (StoreLoad) yes CAS: casa swap, ldstub full
x86 no-op no-op no-op mfence or cpuid or locked insn yes CAS: cmpxchg xchg,locked insn full
ia64 combine with st.rel or ld.acq ld.acq st.rel mf yes CAS: cmpxchg xchg, fetchadd 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 no LL/SC: ldx_l/stx_c target only
pa-risc no-op no-op no-op no-op yes build from ldcw ldcw (NA)

另外,特殊的 final 字段规则在下列代码中需要一个 StoreStore 屏障

x.finalField = v; StoreStore; sharedRef = x;

如下例子解释如何放置屏障:

class X {
    int a, b;
    volatile int v, u;

    void f() {
        int i, j; 
        i = a; // load a
        j = b; // load b
        i = v; // load v
               // LoadLoad
        j = u; // load u
               // LoadStore
        a = i; // store a
        b = j; // store b
               // StoreStore
        v = i; // store v
               // StoreStore
        u = j; // store u
               // StoreLoad
        i = u; // load u
               // LoadLoad
               // LoadStore
        j = b; // load b
        a = i; // store a
    }
}

数据依赖和屏障

一些处理器为了保证依赖指令的交互次序需要使用 LoadLoadLoadStore 屏障。在一些(大部分)处理器中,一个 load 指令或者一个依赖于之前加载值的 store 指令被处理器排序,并不需要一个显式的屏障。这通常发生于两种情况,间接取值(indirection):

Load x; Load x.field

和条件控制(control)

Load x; if (predicate(x)) Load or Store y;

但特别的是不遵循间接排序的处理器,需要为 final 字段设置屏障,使它能通过共享引用访问最初的引用。

x = sharedRef; ... ; LoadLoad; i = x.finalField;

相反的,如下讨论,确定遵循数据依赖的处理器,提供了几个优化掉 LoadLoadLoadStore 屏障指令的机会。(尽管如此,在任何处理器上,对于 StoreLoad 屏障不会自动清除依赖关系)。

与原子指令交互

屏障在不同处理器上还需要与 MonitorEnterMonitorExit 实现交互。锁或者解锁通常必须使用原子条件更新操作 CompareAndSwap(CAS)指令或者 LoadLinked/StoreConditional (LL/SC),就如执行一个 volatile store 之后紧跟 volatile load 的语义一样。CAS 或者 LL/SC 能够满足最小功能,一些处理器还提供其他的原子操作(如,一个无条件交换),这在某些时候它可以替代或者与原子条件更新操作结合使用。

在所有处理器中,原子操作可以避免在正被读取/更新的内存位置进行写后读(read-after-write)。(否则标准的循环直到成功的结构体(loop-until-success)没有办法正常工作)。但处理器在是否为原子操作提供比隐式的 StoreLoad 更一般的屏障特性上表现不同。一些处理器上这些指令可以为 MonitorEnter/Exit 原生的生成屏障;其它的处理器中一部分或者全部屏障必须显式的指定。

为了分清这些影响,我们必须把 Volatiles 和 Monitors 分开:

Required Barriers 2nd operation
1st operation Normal Load Normal Store Volatile Load Volatile Store MonitorEnter MonitorExit
Normal Load


LoadStore
LoadStore
Normal Store


StoreStore
StoreExit
Volatile Load LoadLoad LoadStore LoadLoad LoadStore LoadEnter LoadExit
Volatile Store

StoreLoad StoreStore StoreEnter StoreExit
MonitorEnter EnterLoad EnterStore EnterLoad EnterStore EnterEnter EnterExit
MonitorExit

ExitLoad ExitStore ExitEnter ExitExit

另外,特殊的 final 字段规则需要一个 StoreLoad 屏障。

x.finalField = v; StoreStore; sharedRef = x;

在这张表里,EnterLoad 相同,ExitStore 相同,除非被原子指令的使用和特性覆盖。特别是:

  • EnterLoad 在进入任何需要执行 Load 指令的同步块/方法时都需要。这与 LoadLoad 相同,除非在 MonitorEnter 时候使用了原子指令并且它本身提供一个至少有 LoadLoad 属性的屏障,如果是这种情况,相当于没有操作。
  • StoreExit 在退出任何执行 store 指令的同步方法块时候都需要。这与 StoreStore 一致,除非 MonitorExit 使用原子操作,并且提供了一个至少有 StoreStore 属性的屏障,如果是这种情况,相当于没有操作。
  • ExitEnterStoreLoad 一样,除非 MonitorExit 使用了原子指令,并且/或者 MonitorEnter 至少提供一种屏障,该屏障具有 StoreLoad 的属性,如果是这种情况,相当于没有操作。

在编译时不起作用或者导致处理器上不产生操作的指令比较特殊。例如,当没有交替的 loadstore 指令时,EnterEnter 用于分离嵌套的 MonitorEnter。下面这个例子说明如何使用这些指令类型:

class X {
   int a;
   volatile int v;
   void f() {
       int i;
       synchronized (this) { // enter EnterLoad EnterStore
           i = a; // load a
           a = i; // store a
       } // LoadExit StoreExit exit ExitEnter
       
       synchronized (this) { // enter ExitEnter
           synchronized (this) { // enter
           } // EnterExit exit
       } // ExitExit exit ExitEnter ExitLoad
       
       i = v; // load v
       
       synchronized (this) { // LoadEnter enter
       } // exit ExitEnter ExitStore
       
       v = i; // store v
       
       synchronized (this) { // StoreEnter enter
       } // EnterExit exit
   }
}

Java 层次的对原子条件更新的操作将在 JDK 1.5 中发布(JSR-166),因此编译器需要发布相应的代码,综合使用上表中使用MonitorEnterMonitorExit 的方式,——从语义上说,有时在实践中,这些 Java 中的原子更新操作,就如同他们都被锁所包围一样。

多处理器

本文总结了在多处理器(MPs)中常用的的处理器列表,处理器相关的信息都可以从链接指向的文档中得到(一些网站需要通过注册才能得到相应的手册)。当然,这不是一个完全详细的列表,但已经包括了我所知道的在当前或者将来 Java 实现中所使用的多核处理器。下面所述的关于处理器的列表和内容也不一定权威。我只是总结一下我所阅读过的文档,但是这些文档也有可能是被我误解了,一些参考手册也没有把 Java 内存模型(JMM)相关的内容阐述清楚,所以请协助我把本文变得更准确。

一些很好地讲述了跟内存屏障(barriers)相关的硬件信息和机器(machines)相关的特性的资料并没有在本文中列出来,如“Hans Boehm 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

Ultrasparc 1, 2, 3 (sparcv9) 都支持全存储顺序模式(TSO: Total Store Orde),Ultra3s 只支持全存储顺序模式(TSO:Total Store Orde)。(Ultra1/2 的 RMO (Relax Memory Order) 模式由于不再使用可以被忽略了) 相关内容可进一步查看 UltraSPARC III Cu User’s Manual 和 The SPARC Architecture Manual, Version 9。

x86 (和 x64)

英特尔 486+,AMD 以及其他的处理器。在 2005 到 2009 年有很多规范出现,但当前的规范都几乎跟 TSO 一致,主要的区别在于支持不同的缓存模式,和极端情况下的处理(如不对齐的访问和特殊形式的指令)。可进一步查看 The IA-32 Intel Architecture Software Developers Manuals: System Programming Guide 和 AMD Architecture Programmer’s Manual Programming。

ia64

安腾处理器。可进一步查看 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

arm 版本 7 以上。请查看 ARM processor specifications alpha 21264x 和其他所以版本。请查看 Alpha Architecture Handbook

pa-risc

HP pa-risc 实现。请查看 pa-risc 2.0 Architecture 手册。

下面是这些处理器所支持的屏障和原子操作:

Processor LoadStore LoadLoad StoreStore StoreLoad Data dependency orders loads? Atomic Conditional Other Atomics Atomics provide barrier?
sparc-TSO no-op no-op no-op membar (StoreLoad) yes CAS: casa swap, ldstub full
x86 no-op no-op no-op mfence or cpuid or locked insn yes CAS: cmpxchg xchg,locked insn full
ia64 combine with st.rel or ld.acq ld.acq st.rel mf yes CAS: cmpxchg xchg, fetchadd 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 no LL/SC: ldx_l/stx_c target only
pa-risc no-op no-op no-op no-op yes build from ldcw ldcw (NA)

说明:

  • 尽管上面一些单元格中所列的屏障指令比实际需要的特性更强,但可能是最廉价的方式获得所需要的效果。
  • 上面所列的屏障指令主要是为正常的程序内存的使用而设计的,IO 和系统任务就没有必要用特别形式/模式的缓存和内存。举例来说,在 x86 SPO 中,StoreStore 屏障指令(“sfence”)需要写合并(WriteCombining)缓存模式,其目的是用在系统级的块传输等地方。操作系统为程序和数据使用写回(Writeback)模式,这就不需要 StoreStore 屏障了。
  • 在 x86 中,任何 lock 前缀的指令都可以用作一个 StoreLoad 屏障。(在 Linux 内核中使用的形式是无操作的 lock 指令,如 addl $0,0(%%esp)。)。除非必须需要使用像 CAS 这样 lock 前缀的指令,否则使用支持 SSE2 扩展版本(如奔腾 4 及其后续版本)的 mfence 指令似乎是一个更好的方案。cpuid 指令也是可以用的,但是比较慢。
  • 在 ia64 平台上,LoadStoreLoadLoadStoreStore 屏障被合并成特殊形式的 loadstore 指令——它们不再是一些单独的指令。ld.acq 就是(load; LoadLoad+LoadStore)和 st.rel 就是(LoadStore+StoreStore; store)。这两个都不提供 StoreLoad 屏障——因此你需要一个单独的 mf 屏障指令。
  • 在 ARM 和 ppc 平台中,就有可能通过 non-fence-based 指令序列取代 load fences。这些序列和以及他们应用的案例在Cambridge Relaxed Memory Concurrency Group 著作中都有描述。
  • sparc membar 指令不但支持所有的 4 种屏障模式,而且还支持组合模式。但是 StoreLoad 模式需要在 TSO 中。在一些UltraSparcs 中,不管任何模式下 membar 指令总是能让 StoreLoad 生效。
  • 在与这些流指令有关的情况中,x86 处理器支持”streaming SIMD” SSE2 扩展只需要 LoadLoadlfence
  • 虽然 pa-risc 规范并不强制规定,但所有 HP pa-risc 的实现都是顺序一致,因此没有内存屏障指令。
  • 唯一的在 pa-risc 上的原始原子操作(atomic primitive)是 ldcw,一种 test-and-set 的形式,通过它你可以使用一些技术建立原子条件更新(atomic conditional updates),这些技术在 HP white paper on spinlocks 中可以找到。
  • 在不同的字段宽度(field width,包括 4 个字节和 8 个字节版本)里,CASLL/SC 在不同的处理器上会使用多种形式。
  • 在 sparc 和 x86 处理器中,CAS 有隐式的前后全 StoreLoad 屏障。sparcv9 架构手册描述了 CAS 不需要 post-StoreLoad 屏障特性,但是芯片手册表明它确实在 ultrasparcs 中存在这个特性。
  • 只有在内存区域进行加载和存储(loaded/stored)时,ppc 和 alpha,LL/SC 才会有隐式的屏障,但它不再有更通用的屏障特性。
  • 在内存区域中进行加载或存储时,ia64 cmpxchg 指令也会有隐式的屏障,但还会额外加上可选的 .acq(post-LoadLoad+LoadStore)或者 .rel(pre-StoreStore+LoadStore)修改指令。cmpxchg.acq 形式可用于 MonitorEntercmpxchg.rel 可用于 MonitorExit。在上述的情况中,exitsenters 在没有被确定匹配的情况下,就需要 ExitEnterStoreLoad)屏障。
  • Sparc, x86 和 ia64 平台支持 unconditional-exchange(swap, xchg)。Sparc ldstub 是一个 one-byte test-and-set。ia64 fetchadd 返回前一个值并把它加上去。在 x86 平台,一些指令(如 add-to-memory)能够使用 lock 前缀的指令执行原子操作。

指南

单处理器

如果能保证正在生成的代码只会运行在单个处理器上,那就可以跳过本节的其余部分。因为单处理器保持着明显的顺序一致性,除非对象内存以某种方式与可异步访问的 IO 内存共享,否则永远都不需要插入屏障指令。采用了特殊映射的 java.nio buffers 可能会出现这种情况,但也许只会影响内部的 JVM 支持代码,而不会影响 Java 代码。而且,可以想象,如果上下文切换时不要求充分的同步,那就需要使用一些特殊的屏障了。

插入屏障

当程序执行时碰到了不同类型的存取,就需要屏障指令。几乎无法找到一个“最理想”位置,能将屏障执行总次数降到最小。编译器不知道指定的load 或 store 指令是先于还是后于需要一个屏障操作的另一个 loadstore 指令;如,当 volatile store 后面是一个 return 时。最简单保守的策略是为任一给定的 loadstorelockunlock 生成代码时,都假设该类型的存取需要“最重量级”的屏障:

  • 在每条 volatile store 指令之前插入一个 StoreStore 屏障。(在 ia64 平台上,必须将该屏障及大多数屏障合并成相应的 loadstore 指令。)
  • 如果一个类包含 final 字段,在该类每个构造器的全部 store 指令之后,return 指令之前插入一个 StoreStore 屏障。
  • 在每条 volatile store 指令之后插入一条 StoreLoad 屏障。注意,虽然也可以在每条 volatile load 指令之前插入一个 StoreLoad 屏障,但对于使用 volatile 的典型程序来说则会更慢,因为读操作会大大超过写操作。或者,如果可以的话,将 volatile store 实现成一条原子指令(例如 x86 平台上的 XCHG),就可以省略这个屏障操作。如果原子指令比 StoreLoad 屏障成本低,这种方式就更高效。
  • 在每条 volatile load 指令之后插入 LoadLoadLoadStore 屏障。在持有数据依赖顺序的处理器上,如果下一条存取指令依赖于 volatile load 出来的值,就不需要插入屏障。特别是,在 load 一个 volatile 引用之后,如果后续指令是 null 检查或 load 此引用所指对象中的某个字段,此时就无需屏障。
  • 在每条 MonitorEnter 指令之前或在每条 MonitorExit 指令之后插入一个 ExitEnter 屏障。(根据上面的讨论,如果MonitorExitMonitorEnter 使用了相当于 StoreLoad 屏障的原子指令,ExitEnter 可以是个空操作(no-op)。其余步骤中,其它涉及 EnterExit 的屏障也是如此。)
  • 在每条 MonitorEnter 指令之后插入 EnterLoadEnterStore 屏障。
  • 在每条 MonitorExit 指令之前插入 StoreExitLoadExit 屏障。
  • 如果在未内置支持间接 load 顺序的处理器上,可在 final 字段的每条 load 指令之前插入一个 LoadLoad 屏障。(此邮件列表linux 数据依赖屏障的描述中讨论了一些替代策略。)

这些屏障中的有一些通常会简化成空操作。实际上,大部分都会简化成空操作,只不过在不同的处理器和锁模式下使用了不同的方式。最简单的例子,在 x86 或 sparc-TSO 平台上使用 CAS 实现锁,仅相当于在 volatile store 后面放了一个 StoreLoad 屏障。

移除屏障

上面的保守策略对有些程序来说也许还能接受。volatile 的主要性能问题出在与 store 指令相关的 StoreLoad 屏障上。这些应当是相对罕见的——将 volatile 主要用于避免并发程序里读操作中锁的使用,仅当读操作大大超过写操作才会有问题。但是至少能在以下几个方面改进这种策略:

  • 移除冗余的屏障。可以根据前面章节的表格内容来消除屏障:
Original => Transformed
1st ops 2nd => 1st ops 2nd
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

类似的屏障消除也可用于锁的交互,但要依赖于锁的实现方式。使用循环,调用以及分支来实现这一切就留给读者作为练习。:-)

  • 重排代码(在允许的范围内)以更进一步移除 LoadLoadLoadStore 屏障,这些屏障因处理器维持着数据依赖顺序而不再需要。
  • 移动指令流中屏障的位置以提高调度(scheduling)效率,只要在该屏障被需要的时间内最终仍会在某处执行即可。
  • 移除那些没有多线程依赖而不需要的屏障;例如,某个 volatile 变量被证实只会对单个线程可见。而且,如果能证明线程仅能对某些特定字段执行 store 指令或仅能执行 load 指令,则可以移除这里面使用的屏障。但是所有这些通常都需要作大量的分析。

杂记

JSR-133 也讨论了在更为特殊的情况下可能需要屏障的其它几个问题:

  • Thread.start() 需要屏障来确保该已启动的线程能看到在调用的时刻对调用者可见的所有 store 的内容。相反,Thread.join() 需要屏障来确保调用者能看到正在终止的线程所 store 的内容。实现 Thread.start()Thread.join() 时需要同步,这些屏障通常是通过这些同步来产生的。
  • static final 初始化需要 StoreStore 屏障,遵守 Java 类加载和初始化规则的那些机制需要这些屏障。
  • 确保默认的 0 / null 初始字段值时通常需要屏障、同步和/或垃圾收集器里的底层缓存控制。
  • 在构造器之外或静态初始化器之外神秘设置 System.in, System.outSystem.err 的 JVM 私有例程需要特别注意,因为它们是 JMM final 字段规则的遗留的例外情况。
  • 类似地,JVM 内部反序列化设置 final 字段的代码通常需要一个 StoreStore 屏障。
  • 终结方法可能需要屏障(垃圾收集器里)来确保 Object.finalize 中的代码能看到某个对象不再被引用之前 store 到该对象所有字段的值。这通常是通过同步来确保的,这些同步用于在 reference 队列中添加和删除 reference。
  • 调用 JNI 例程以及从 JNI 例程中返回可能需要屏障,尽管这看起来是实现方面的一些问题。
  • 大多数处理器都设计有其它专用于 IO 和 OS 操作的同步指令。它们不会直接影响 JMM 的这些问题,但有可能与 IO,类加载以及动态代码的生成紧密相关。

致谢

感谢下列人员的更正与建议: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 和 Richard Grisenthwaite。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment