1 - CH01-指令重排

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

Volatile 与监视器

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

能否重排下个操作下个操作下个操作
第一个操作Nomal Load/Nornal StoreVolatile Load/Moitor EnterVolatile Store/Monitor Exit
Nomal Load/Nornal StoreNO
Volatile Load/Moitor EnterNONONO
Volatile Store/Monitor ExitNONO

术语说明:

  • Normal Load 指令包括:对非 volatile 字段的读取,getField/getStatic/arrayLoad。
  • Normal Store 指令包括:对非 volatile 字段的存储,putFiled/putStatic/arrayStore。
  • Volatile Load 指令包括:对多线程环境的 volatile 变量的读取,getField/getStatic。
  • Volatile Store 指令包括:对多线程环境的 volatile 变量的存储,putField/putStatic。
  • Monitor Enter(包括进入同步块 synchronized 方法)是用于多线程环境的锁对象。
  • Monitor Exist(包括离开同步块 synchronized 方法)是用于多线程环境的锁对象。

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

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

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

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

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

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

final 字段

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

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

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

x.afield = 1;
x.finalField = v;
...;
sharedRef = x;
  1. 一个 final 字段的初始化 load 指令不能与包含该字段的对象的初始化 load 指令进行重排。在下面的情况中,这条规则就会生效:x = shareRef;...;x=x.finalField。由于这两条指令是依赖的,编译器不能对这样的指令进行重排。但是,这条规则会对某些处理器有影响。

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

2 - CH02-内存屏障

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

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

内存屏障的种类

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

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

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, LoadStore, Load2

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

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

排序规则

下表显示了这些屏障如何符合 JSR 133 的排序规则:

需要的屏障下个操作下个操作下个操作下个操作
第一个操作Nomal LoadNornal StoreVolatile Load Moitor EnterVolatile Store Monitor Exit
Nomal LoadLoadStore
Nornal StoreStoreStore
Volatile Load Moitor EnterLoadLoadLoadStoreLoadLoadLoadStore
Volatile Store Monitor ExitStoreLoadStoreStore

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

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

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

Class X {
  int a, b;
  volatil 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
  }
}

数据依赖与屏障

一些处理器为了保证依赖指令的交互次序需要使用 LoadLoad 和 LoadStore 屏障。在一些(大部分)处理器中,一个 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;

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

与原子指令交互

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

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

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

需要的屏障下个操作下个操作下个操作下个操作下个操作下个操作
第一个操作Nomal LoadNornal StoreVolatile LoadVolatile StoreMoitor EnterMonitor Exit
Nomal LoadLoadStoreLoadStore
Nornal StoreStoreStoreStoreExit
Volatile LoadLoadLoadLoadStoreLoadLoadLoadStoreLoadEnterLoadExit
Volatile StoreStoreLoadStoreStoreStoreEnterStoreExit
Moitor EnterEnterLoadEnterStoreEnterLoadEnterStoreEnterEnterEnterExit
Monitor ExitExitLoadExitStoreExitEnterExitExit

同样,特殊的 final 字段规则需要一个 StoreLoad 屏障:

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

在该表中,“Enter” 与 “Load” 相同,“Exit” 与 “Store” 相同,除非被原子性指令的使用和特性覆盖。特别是:

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

在编译时不起作用或者导致处理器上不产生操作的指令比较特殊。例如,当没有交替的 laod 和 store 指令时,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),因此编译器需要发布相应的代码,综合使用上表中对 MonitorEnter 和 MonitorExist 的方式,从语义上说,有时在实践中,这些 Java 中的原子更新操作,就如同他们被锁所包围一样。

3 - CH03-多处理器

本文总结了在多处理器(MPs)中常用的的处理器列表。这不是一个完全详细的列表,但已经包括了我所知道的在当前或者将来Java 实现中所使用的多核处理器。

略。

4 - CH04-开发指南

单处理器

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

插入屏障

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

  • 在每条 volatile store 指令之前插入一个 StoreStore 屏障。
  • 如果一个类包含 final 字段,在该类每个构造器的全部 store 指令之后、return 指令之前插入一个 StoreStore 屏障。
  • 在每条 volatile store 指令之后插入一条 StoreStore 屏障。注意,虽然也可以在每条 volatile load 指令之前插入一个 StoreStore 屏障,但对于使用 volatile 的典型程序来说则会更慢,因为读操作会大大超过写操作。或者如果可以的话,将 volatile store 实现成一条原子指令,就可以省略掉屏障操作。如果原子指令比 StoreLoad 屏障成本低,这种方式就会更加高效。
  • 在每条 volatile load 指令之后插入 LoadLoad 和 LoadStore 屏障。在持有数据依赖的处理器上,如果下一条存取指令依赖于 volatile load 出来的值,就不需要插入屏障。特别是,在 load 一个 volatile 引用之后,如果后续指令是 null 检查或 load 此引用所指对象中的某个字段,此时就无需屏障。
  • 在每条 MonitorEnter 指令之前或在每条 MonitorExit 指令之后插入一个 ExitEnter 屏障。(根据上面的讨论,如果 MonitorExit 或 MonitorEnter 使用了相当于 StoreLoad 屏障的原子指令,ExitEnter 可以是个空操作(no-op)。其余步骤中,其他涉及 Enter 和 Eixt 的屏障也是如此。)
  • 在每条 MonitorEnter 指令之后插入 EnterLoad 和 EnterStore 屏障。
  • 在每条 MonitorExit 指令之后插入 StoreExit 和 LoadExit 屏障。
  • 如果在未内置直接间接 load 顺序的处理器上,可以在 final 字段的每条 load 指令之前插入一个 LoadLoad 屏障。

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

移除屏障

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

  1. 移除冗余的屏障。可以根据前面章节的表格来消除屏障:
Original=>Transformed
1stops2nd=>1stops2nd
LoadLoadno loadsLoadLoad=>no loadsLoadLoad
LoadLoadno loadsStoreLoad=>no loadsStoreLoad
StoreStoreno storesStoreStore=>no storesStoreStore
StoreStoreno storesStoreLoad=>no storesStoreLoad
StoreLoadno loadsLoadLoad=>StoreLoadno loads
StoreLoadno storesStoreStore=>StoreLoadno loads
StoreLoadno volatile loadsStoreLoad=>no volatile loadsStoreLoad

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

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

杂记

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

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