Java内存模型2

2020/10/14 Java虚拟机 共 6837 字,约 20 分钟
梦境迷离

本文是为编译器开发人员提供的 JSR-133 Cookbook,为程序开发人员提供的JMM入门请参考 Java内存模型

前言:自从最初编写以来已有10多年了,许多处理器和语言内存模型规范和问题已经变得更加清晰和易于理解。许多还没有。虽然本指南保持准确,但其中一些不断演变的细节并不完整。要获得更详细的信息,请参阅Peter Sewell和Cambridge Relaxed Memory Concurrency Group的工作。

这是实现JSR-133指定的新Java内存模型(JMM)的非官方指南。它提供了有关为什么存在各种规则的最简短的背景,而不是专注于它们在指令重新排序、多处理器屏障指令和原子操作方面对编译器和JVM的影响。它包括一组符合JSR-133的推荐配方。本指南是“非官方的”,因为它包含对特定处理器属性和规格的解释。我们不能保证解释是正确的。而且,处理器规格和实现可能会随时间变化。

重排序

对于编译器开发人员,JMM主要由禁止对访问字段(其中“字段”包括数组元素)和监视器(锁)的某些指令进行重排序的规则组成。

Volatiles 和 Monitors

可以将volatiles和Monitors的主要JMM规则视为带有单元格的矩阵,指示您无法对与特定字节码序列相关的指令进行重排序。该表本身不是JMM规范;它只是查看其对编译器和运行时系统的主要影响的一种有用方法。

可重排序---
-Normal Load、Normal StoreVolatile Load、MonitorEnterVolatile Store、MonitorExit
Normal Load、Normal Store  No
Volatile Load、MonitorEnterNoNoNo
Volatile store、MonitorExit NoNo
  • Normal Load指令包括:对非volatile字段的读取,getfield,getstatic和array load;
  • Normal Store指令包括:对非volatile字段的存储,putfield,putstatic和array store;
  • Volatile load指令包括:对多线程环境的volatile变量的读取,getfield,getstatic;
  • Volatile store指令包括:对多线程环境的volatile变量的存储,putfield,putstatic;
  • MonitorEnter指令(包括进入同步块synchronized方法)是用于多线程环境的锁对象;
  • MonitorExit指令(包括离开同步块synchronized方法)是用于多线程环境的锁对象。

总结如下:(下面总结不含MonitorExit和MonitorEnter,这两个在同一单元格时则表明它们的规则是相同的)

  1. 禁止非volatile字段的读写操作与volatile字段的的写操作的重排序
  2. 禁止volatile字段的读操作与任何字段的读写操作的重排序
  3. 禁止volatile字段的写操作与volatile字段的读写操作的重排序

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

任意数量的指令操作都可被表示成这个表格中的第一个操作或者第二个操作(这里因为Markdown不支持合并单元格,所以使用 ”-“ 占位,左边的表示操作1,上边的表示操作2)。例如在单元格[Normal Store、Volatile Store]中,有一个No,就表示任何非volatile字段的store指令操作(Normal Store)不能与后面任何一个Volatile store指令重排,如果出现任何这样的重排会使多线程程序的运行发生变化。

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

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

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

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

MonitorEnter和MonitorExit指令

  • monitorenter(同步代码块的字节码反编译后的指令名称)。每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
    1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
    2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
    3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
  • monitorexit(同步代码块的字节码反编译后的指令名称)。执行monitorexit的线程必须是objectref所对应的monitor的所有者。
    1. 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。Synchronized的语义底层是通过一个monitor的对象来完成(通过ACC_SYNCHRONIZED标记隐式依赖monitor对象),其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出IllegalMonitorStateException异常的原因。
  • 虚拟机若实现了Structured locking,则还需要保证下面两条规则(1.单次数量(即:加1减1成对出现)和2.总数量(即:无论加多少,也必须减多少,最终为0)):
    1. 无论方法调用是正常完成还是突然完成,线程在方法调用期间对监视器执行”进入”的数量必须等于线程在方法调用期间对监视器执行”退出”的数量。
    2. 虚拟机若实现了Structured locking,自从方法调用以来,由方法调用引起的监视器”退出”线程的数量不得超过由线程在监视器上执行的监视器”进入”的数量。

如果objectref为null,则monitorenter会引发NullPointerException。

final 字段

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

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

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

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

这些规则意味着带有final字段的对象的load本身必须是synchronized,volatile,final或者来自类似的load指令,从而,最终会在构造函数中对初始化store进行排序,并在构造函数之外进行后续使用。

内存屏障

编译器和处理器都必须遵守重排序规则。不需要特别的努力来确保单处理器保持适当的顺序,因为它们都保证“按顺序”一致性。但是在多处理器上,要保证一致性,通常需要发出屏障指令。即使编译器优化了字段访问(例如,因为未使用加载的值),也必须仍然生成屏障,就像访问仍然存在一样。(请参阅下面的有关独优化障碍的信息。)

屏障分类

屏障在这里就是栅栏/障碍的意思,要理解下面的东西,首先必须清数据流,CPU(寄存器)<-> 缓存 <-> 主存 (Java),只要刷新到主存后,程序才能读取。

几乎所有处理器都至少支持通常称为Fence的粗粒度屏障指令,这保证了在屏障之前启动(initiated)的所有loads和stores将被严格排序在屏障之后启动的任何loads或stores之前。

内存屏障的一个特性需要逐渐适应,它们是在内存访问之间应用的。尽管在某些处理器上为屏障指令指定了名称,但使用的正确/最佳屏障取决于它分隔的访问类型。这是屏障类型的常见分类,可以很好地映射到现有处理器上的特定指令(有时是无操作):

加载(load)是读的意思,存储(store)是写的意思,这里为了顺口都翻译了,有的地方则直接使用了load和store两词。Load1和Load2表示两个独立的读操作、Store1和Store2表示两个独立的写操作

  • LoadLoad
    • 顺序:Load1; LoadLoad; Load2。 确保在加载Load2访问的数据和后续所有的加载指令之前先加载Load1的数据。通常,在执行推测性加载(和/或)乱序处理的处理器中需要显式的LoadLoad屏障,在这些处理中等待load指令可以绕过等待store。在保证始终保持load顺序的处理器上,此屏障等于无操作。

即:在读取Load2访问的数据及之后所有读取指令之前,Load1的数据必须已经读取到主存。

  • StoreStore
    • 顺序:Store1; StoreStore;Store2。 确保在与Store2关联的数据和所有后续存储指令之前,Store1的数据对其他处理器可见(即已刷新到内存)。通常,在其他情况下不能保证从写缓冲区(和/或)缓存到其他处理器或主存储器的刷新严格排序的处理器上需要StoreStore屏障。

即:在Store2关联的数据以及之后所有的存储指令之前,Store1的数据必须对其他处理器可见,即数据必须已经刷新到主存了。

  • LoadStore
    • 顺序:Load1; LoadStore; Store2。 确保在Store2关联的数据和所有后续存储指令之前已加载Load1的数据。仅在那些等待store指令可以绕过load的乱序处理器上才需要LoadStore屏障。

即:在Store2关联的数据以及之后所有的存储指令之前,Load1的数据必须已经读取到主存。

  • StoreLoad
    • 顺序:Store1; StoreLoad; Load2。 确保在加载Load2和所有后续加载指令所访问的数据之前,使Store1的数据对其他处理器可见(即已刷新到主存储器)。StoreLoad屏障使用Store1的数据值而不是从较新的store到由不同处理器执行的相同位置的数据值来防止错误地后续load。因此,在下面讨论的处理器上,严格来说,StoreLoad仅在将store与屏障之前store的相同位置的后续load分离时才是必需的。几乎所有最新的多处理器都需要StoreLoad屏障,并且通常是最昂贵的屏障。它们昂贵的部分原因是必须禁用通常绕过缓存的机制,以满足来自写缓冲区的load。这可以通过让缓冲区完全刷新以及其他可能的暂停来实现。

即:在读取Load2访问的数据以及之后所有的加载指令之前,Store1的数据必须对其他处理器可见,即必须已经刷新到主存了。

事实证明,在下面讨论的所有处理器上,执行StoreLoad的指令也会获得其他三种屏障效果,因此StoreLoad可以用作通用(但通常很昂贵)的Fence。(这只是一个经验,但不是必须的。)相反并非如此。通常,发出其他障碍的任何组合都不等于StoreLoad的情况。

下表显示了这些障碍如何与JSR-133排序规则相对应。

需要屏障----
-Normal LoadNormal StoreVolatile Load、MonitorEnterVolatile Store、MonitorExit
Normal Load   LoadStore
Normal Store   StoreStore
Volatile Load、MonitorEnterLoadLoadLoadStoreLoadLoadLoadStore
Volatile Store、MonitorExit  StoreLoadStoreStore

同上面重排序表,左边”-“表示操作1,上面”-“表示操作2。

加上特殊的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
  }
}

数据依赖性和屏障

某些处理器上对LoadLoad和LoadStore屏障的需求与它们对依赖指令的排序保证相互作用。在某些(大多数)处理器上,处理器会根据先前的加载值对加载或存储进行排序,而无需明确的屏障。这通常在两种情况下出现,间接:

    Load x; Load x.field

和控制:

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

特别是不遵守间接排序的处理器,对于最初通过共享引用获取的引用,final字段需要一些屏障:

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

相反,如下所述,确实尊重数据依赖性的处理器提供了一些机会来优化需要另外发布的LoadLoad和LoadStore屏障指令。(但是,依赖关系不会自动消除任何处理器上对StoreLoad屏障的需求。)

与原子指令的交互

TODO

多处理器

TODO

方法

TODO

JMM对外向Java程序提供的一些工具与平台无关,但其本身依赖于各平台处理器的架构及其实现。

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

文档信息

Search

    Table of Contents