Skip to content

JUC 指令重排与内存屏障

javahongxi edited this page Jul 25, 2019 · 1 revision

指令重排

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

Volatile与监视器

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

能否重排 第二个操作
第一个操作 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字段的读取,getfield,getstatic和array load;
  • Normal Store指令包括:对非volatile字段的存储,putfield,putstatic和array store;
  • Volatile load指令包括:对多线程环境的volatile变量的读取,getfield,getstatic;
  • Volatile store指令包括:对多线程环境的volatile变量的存储,putfield,putstatic;
  • 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指令进行重排。但是你可以对两个不同的内存地址上的load和store指令进行重排,而且往往在很多编译器转换和优化中会这么做。这其中就包括了一些往往不认为是指令重排的例子,例如:重用一个基于已经加载的字段的计算后的值而不是像一次指令重排那样去重新加载并且重新计算。然而,JMM规范允许编译器经过一些转换后消除这些可以避免的依赖,使其可以支持指令重排。

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

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

Final 字段

Final字段的load和store指令相对于有锁的或者volatile字段来说,就跟Normal load和Normal 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本身是synchronized,volatile,final或者来自类似的load指令,从而确保java程序员对与final字段的正确使用,并最终使构造函数中初始化的store指令和构造函数外的store指令排序。

内存屏障

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

内存屏障仅仅与内存模型中“获取”、“释放”这些高层次概念有间接的关系。内存屏障并不是“同步屏障”,内存屏障也与在一些垃圾回收机制中“写屏障(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指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。

StoreLoad Barriers

序列: Store1; StoreLoad; Load2

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

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

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

需要的屏障 第二步
第一步 Normal Load Normal Store Volatile Load MonitorEnter Volatile Store MonitorExit
Normal Load LoadStore
Normal Store StoreStore
Volatile Load MonitorEnter LoadLoad LoadStore LoadLoad LoadStore
Volatile Store MonitorExit StoreLoad StoreStore

另外,特殊的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指令或者一个依赖于之前加载值的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实现交互。锁或者解锁通常必须使用原子条件更新操作CompareAndSwap(CAS)指令或者LoadLinked/StoreConditional (LL/SC),就如执行一个volatile store之后紧跟volatile load的语义一样。CAS或者LL/SC能够满足最小功能,一些处理器还提供其他的原子操作(如,一个无条件交换),这在某些时候它可以替代或者与原子条件更新操作结合使用。

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

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

需要的屏障 第二步
第一步 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;

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

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

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

首页

Java核心技术 🔥

Netty 🔥

RocketMQ深入研究 🔥

Dubbo源码导读

微服务架构

Redis

其他

杂谈

关于

Clone this wiki locally
You can’t perform that action at this time.