内存屏障又称之为内存栅栏(Memory Fences),是一系列的 CPU 指令,它的主要作用是保证特定操作的执行顺序,保证并发执行的有序性。在编译器和 CPU 都进行指令的重排优化时,可以通过在指令中间插入一个内存屏障,告诉编译器和 CPU,禁止在内存屏障指令之前(或之后)执行指令重排序。

重排序

为了提高性能,编译器和 CPU 常常会对指令进行重排序。重排序主要分为两类:编译器重排序和 CPU 重排序。
image.png
Java源码到最终指令序列经过的重排序

编译器重排序

编译器重排序是指在代码编译阶段进行指令重排,不改变程序执行结果的情况下,为了提升效率,编译器对指令进行乱序的编译。编译器重排序(Re-Order)的目的为:与其等待阻塞指令(如等待缓存刷入)完成,不如先去执行其他指令。与 CPU 乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。

CPU 重排序

流水线**(Pipeline)**乱序执行**(Out-of-Order Execution)**是现代 CPU 基本都具有的特性。机器指令在流水线中经历取指令、译码、执行、访存、写回等操作。为了 CPU 的执行效率,在不影响语义的情况下,流水线都是并行处理的。处理次序Process Ordering,机器指令在 CPU 实际执行时的顺序)和程序次序Program Ordering,程序代码的逻辑执行顺序)是允许不一致的,只要满足**As-if-Serial**规则即可。显然,这里的不影响语义依旧只能保证指令间的显式因果关系,无法保证隐式因果关系,即无法保证语义上不相关但是在程序逻辑上相关的操作序列按序执行。

CPU 重排序包括两类:指令重排序和内存系统重排序。

  1. 指令级重排序:在不影响程序执行结果的情况下,CPU 内核采用 ILP(Instruction-Level Parallelism,指令级并行运算)技术来将多条指令重叠执行,主要是为了提升效率。如果指令之间不存在数据依赖性, CPU 就可以改变语句的对应机器指令的执行顺序,叫作指令级重排序。
  2. 内存系统重排序:对于现代的 CPU 来说,在 CPU 内核和主存之间都具备一个高速缓存,高速缓存的作用主要是减少 CPU 内核和主存的交互( CPU 内核的处理速度要快得多),在 CPU 内核进行读操作时,如果缓存没有的话就从主存取,而对于写操作都是先写在缓存中,最后再一次性写入主存,原因是减少跟主存交互时 CPU 内核的短暂卡顿,从而提升性能。也就是说,内存系统重排序可以理解为写入主内存的顺序和指令的执行顺序不完全一致。故而内存系统重排序可能会导致一个问题——数据不一致。

as-if-serial 规则

由于重排序会导致数据不一致问题,这就不能保证程序运行的正确性。为了解决这个问题,需要先了解指令重排序的规则。

无论如何重排序,都必须保证代码在单线程下运行正确。这就是 as-if-serial 规则。换言之,单线程中,只要操作之间没有数据依赖性,编译器和 CPU 可以进行任意重排序,因为这不会影响程序执行的结果,代码看起来像是一行一行从头到尾串行执行。

如下示例中:

  1. int a = 1; // ①
  2. int b = 2; // ②
  3. int c= a + b; // ③

其中 ③ 和 ①、② 之间都存在数据依赖性,因此 ③ 和 ①、② 之间并不会发生重排序。但是 ①、② 之间并没有任何数据依赖关系,因此可能发生重排序。

为了保证 as-if-serial 规则,Java 异常机制也会为指令重排序做一些特殊处理。例如下面的代码中:

  1. public static void main(String[] args) {
  2. int x = 1, y;
  3. try {
  4. x = 2; // ①
  5. y = 2 / 0; // ②
  6. } catch (Exception e) { // ③
  7. } finally {
  8. System.out.println("x = " + x);
  9. }
  10. }

语句 ①(x=2)和语句 ②(y=0/0)之间没有数据依赖关系,语句 ② 可能会被重排序在 ① 之前执行。重排之后,语句 ① 尚未执行,语句 ② 已经抛出异常,因而重排后会导致语句 ① 得不到执行,最终 x 得到错误结果 1。

为了保证最终不至于输出 x=1 的错误结果,JIT 在重排序时会在 catch 语句中插入错误补偿代码,补偿执行语句 ②,将 x 赋值为 2,将程序恢复到发生异常时应有的状态。这种做法的确将异常捕捉的和处理的底层逻辑变得非常复杂,但是 JIT 的优化原则是,尽力保障正确的运行逻辑,哪怕以 catch 块逻辑变得复杂为代价。

:::info JIT(Just In Time,即时编译器)。JVM 读入 .class 文件的字节码后,默认情况下是解释执行的。但是对于运行频率很高(如大于5000次)的字节码,JVM 采用了 JIT 技术,将直接编译为机器指令,以提高性能。 :::

在多核 CPU 并发执行的场景中,由于逻辑复杂,编译器和 CPU 并不能清楚各个指令序列之间的依赖关系,因此并不能在并发的情况下保证执行的有序性,还是会可能乱序执行的问题,从而导致程序运行结果错误。

**as-if-Serial** 规则只能保障单内核指令重排序之后的执行结果正确,不能保障多内核以及跨 CPU 指令重排序之后的执行结果正确。

内存屏障

硬件层面的内存屏障

为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Fences)。内存屏障是一个让 CPU 高速缓存的内存状态对其他 CPU 内科可见的一项技术,也是一项保障跨 CPU 内核有序执行指令的技术。

:::info 编译器的内存屏障是为为了告诉编译器不要对指令进行重排序。当编译结束之后,这种内存屏障就消失了,CPU 并不会感知到编译器中内存屏障的存在。 :::

硬件层常用的内存屏障分为三种:读屏障(Load Barrier)、写屏障(StoreBarrier)和全屏障(Full Barrier)

读屏障

读屏障让高速缓存中相应的数据失效。在指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主存加载数据。并且,读屏障会告诉 CPU 和编译器,先于这个屏障的指令必须先执行。读屏障既使得当前 CPU 内核对共享变量的更改对所有 CPU 内核可见,又阻止了一些可能导致读取无效数据的指令重排。

写屏障

指令后插入写屏障指令能让高速缓存中的最新数据更新到主存,让其他线程可见。并且,写屏障会告诉 CPU 和编译器,后于这个屏障的指令必须后执行。

全屏障

全屏障是一种全能型的屏障,具备读屏障和写屏障的能力。

硬件层屏障的作用

  1. 阻止屏障两侧的指令重排序

编译器和 CPU 可能为了使性能得到优化而对指令重排序,但是插入一个硬件层的内存屏障相当于告诉 CPU 和编译器先于这个屏障的指令必须先执行,后于这个屏障的指令必须后执行。

  1. 强制让高速缓存的数据失效

硬件层的内存屏障强制把高速缓存中的最新数据写回主存,让高速缓存中相应的脏数据失效。一旦完成写入,任何访问这个变量的线程将会得到最新的值。

JDK 中的内存屏障

在理论层面,CPU 内存屏障基本可以分为四种:

  1. LoadLoad:禁止读与读重排序
  2. LoadStore:禁止读与写重排序
  3. StoreLoad:禁止写与读重排序
  4. StoreStore:禁止写与写重排序

示例:

  1. Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕
  2. Load1; LoadStore; Store2,在 Store2 及后续写入操作被执行前,保证 Load1 要读取的数据被读取完毕
  3. Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的,在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
  4. Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见

但是从 JDK 8 开始,Java 在 Unsafe 类中只提供了三个内存屏障函数:

  1. public final class Unsafe {
  2. public native void loadFence();
  3. public native void storeFence();
  4. public native void fullFence();
  5. }

这3个函数与内存屏障的关系如下:

  1. loadFence = LoadLoad + LoadStore
  2. storeFence = StoreStore + LoadStore
  3. fullFence = loadFence + storeFence + StoreLoad

也就是说:

  1. loadFence 保证在后续的 load 和 store 指令执行之前,保证之前的读取操作执行完毕
  2. storeFence 保证在后续的 store 指令执行之前,之前的 load 和 store 指令的执行结果可见
  3. 保证前后的指令的执行顺序