Java多线程-05丨内存屏障

Posted by jiefang on October 30, 2019

内存屏障

内存屏障(Memory Barrier)与内存栅栏(Memory Fence)是同一个概念,不同的叫法。

内存栅栏

X86平台来说,有几种主要的内存屏障:

  • lfence,是一种Load Barrier 读屏障。在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
  • sfence, 是一种Store Barrier 写屏障。在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存
  • mfence, 是一种全能型的屏障,具备ifencesfence的能力
  • Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。

Lock前缀实现了类似的能力:

  • 它先对总线/缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。
  • 在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。Lock后的写操作会让其他CPU相关的cache line失效,从而从新从内存加载最新的数据。这个是通过缓存一致性协议(MESI)做的。

标准

两个指令:

  • Store屏障:将处理器缓存的数据刷新到内存中。Store屏障的作用是执行flush处理器缓存的操作,说白了就是把自己当前处理器更新的变量的值,都刷新到高速缓存(或者主内存)里去。
  • Load屏障:将内存存储的数据拷贝到处理器的缓存中。Load屏障的作用是执行refresh处理器缓存的操作,说白了就是对别的处理器更新过的变量,从其他处理器的高速缓存(或者主内存)加载数据到自己的高速缓存来,确保自己看到的是最新的数据。
  • Acquire屏障:复合屏障,相当于LoadLoad屏障 + LoadStore屏障;
  • Release屏障:复合屏障,相当于StoreLoad屏障 + StoreStore屏障;
屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。

内存屏障硬件实现

Store屏障:如果加了Store屏障之后,就会强制性要求你对一个写操作必须阻塞等待到其他的处理器返回invalidate ack之后,对数据加锁,然后修改数据到高速缓存中,必须在写数据之后,强制执行flush操作,他的效果,要求一个写操作必须刷到高速缓存(或者主内存),不能停留在写缓冲里;

Load屏障:如果加了Load屏障之后,在从高速缓存中读取数据的时候,如果发现无效队列里有一个invalidate消息,此时会立马强制根据那个invalidate消息把自己本地高速缓存的数据,设置为I(过期),然后就可以强制从其他处理器的高速缓存(或者主内存)中加载最新的值了;

StoreStore屏障:会强制让写数据的操作全部按照顺序写入写缓冲器里,他不会让你第一个写到写缓冲器里去,第二个写直接修改高速缓存了;

StoreLoad屏障:他会强制先将写缓冲器里的数据写入高速缓存中,接着读数据的时候强制清空无效队列,对里面的validate消息全部过期掉高速缓存中的条目,然后强制从主内存里重新加载数据;

内存屏障在Java中的体现

volatile

  • volatile写之前,所有变量的读写操作都已完成。
  • volatile写之后,volatile变量读写操作都不会重排序到其前面。
  • volatile读之后,所有变量读写操作都不会重排序到其前面。
  • volatile读之前,所有volatile读写操作都已完成。

JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

final

编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

CAS

在CPU架构中依靠lock信号保证可见性并禁止重排序。lock前缀是一个特殊的信号,执行过程如下:

  • 对总线和缓存上锁。
  • 强制所有lock信号之前的指令,都在此之前被执行,并同步相关缓存。
  • 执行lock后的指令(如cmpxchg)。
  • 释放对总线和缓存上的锁。
  • 强制所有lock信号之后的指令,都在此之后被执行,并同步相关缓存。

因此,lock信号虽然不是内存屏障,但具有mfence的语义(当然,还有排他性的语义),与内存屏障相比,lock信号要额外对总线和缓存上锁,成本更高。

  • 总线锁定

当一个处理器要操作共享变量时,在 BUS 总线上发出一个 Lock 信号,其他处理就无法操作这个共享变量了。

缺点很明显,总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。

  • 缓存锁定

后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进行了操作,其他处理器会有个嗅探机制,将其他处理器的该共享变量的缓存失效,待其他线程读取时会重新从主内存中读取最新的数据,基于 MESI 缓存一致性协议来实现的。

现代的处理器基本都支持和使用的缓存锁定机制。

注意:

有如下两种情况处理器不会使用缓存锁定:

(1)当操作的数据跨多个缓存行,或没被缓存在处理器内部,则处理器会使用总线锁定。

(2)有些处理器不支持缓存锁定,比如:Intel 486 和 Pentium 处理器也会调用总线锁定。

解密CAS底层指令

其实,掌握以上内容,对于 CAS 机制的理解相对来说算是比较清楚了。

当然,如果感兴趣,也可以继续深入学习用到了哪些硬件 CPU 指令。

底层硬件通过将 CAS 里的多个操作在硬件层面语义实现上,通过一条处理器指令保证了原子性操作。这些指令如下所示:

(1)测试并设置(Tetst-and-Set)

(2)获取并增加(Fetch-and-Increment)

(3)交换(Swap)

(4)比较并交换(Compare-and-Swap)

(5)加载链接/条件存储(Load-Linked/Store-Conditional)

前面三条大部分处理器已经实现,后面的两条是现代处理器当中新增加的。而且根据不同的体系结构,指令存在着明显差异。

在IA64,x86 指令集中有 cmpxchg 指令完成 CAS 功能,在 sparc-TSO 也有 casa 指令实现,而在 ARM 和 PowerPC 架构下,则需要使用一对 ldrex/strex 指令来完成 LL/SC 的功能。在精简指令集的体系架构中,则通常是靠一对儿指令,如:load and reserve 和 store conditional 实现的,在大多数处理器上 CAS 都是个非常轻量级的操作,这也是其优势所在。

sun.misc.Unsafe 中 CAS 的核心方法:

1
2
3
4
5
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

这三个方法可以对应去查看 openjdk 的 hotspot 源码:

源码位置:hotspot/src/share/vm/prims/unsafe.cpp

1
2
3
4
5
6
7
#define FN_PTR(f) CAST_FROM_FN_PTR(void*, &f)

{CC"compareAndSwapObject", CC"("OBJ"J"OBJ""OBJ")Z",  FN_PTR(Unsafe_CompareAndSwapObject)},

{CC"compareAndSwapInt",  CC"("OBJ"J""I""I"")Z",      FN_PTR(Unsafe_CompareAndSwapInt)},

{CC"compareAndSwapLong", CC"("OBJ"J""J""J"")Z",      FN_PTR(Unsafe_CompareAndSwapLong)},

上述三个方法,最终在 hotspot 源码实现中都会调用统一的 cmpxchg 函数,可以在 hotspot 源码中找到核心代码。

源码地址:hotspot/src/share/vm/runtime/Atomic.cpp

cmpxchg 函数源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
jbyte Atomic::cmpxchg(jbyte exchange_value, volatile jbyte*dest, jbyte compare_value) {
         assert (sizeof(jbyte) == 1,"assumption.");
         uintptr_t dest_addr = (uintptr_t) dest;
         uintptr_t offset = dest_addr % sizeof(jint);
         volatile jint*dest_int = ( volatile jint*)(dest_addr - offset);
         // 对象当前值
         jint cur = *dest_int;
         // 当前值cur的地址
         jbyte * cur_as_bytes = (jbyte *) ( & cur);
         // new_val地址
         jint new_val = cur;
         jbyte * new_val_as_bytes = (jbyte *) ( & new_val);
          // new_val存exchange_value,后面修改则直接从new_val中取值
         new_val_as_bytes[offset] = exchange_value;
         // 比较当前值与期望值,如果相同则更新,不同则直接返回
         while (cur_as_bytes[offset] == compare_value) {
          // 调用汇编指令cmpxchg执行CAS操作,期望值为cur,更新值为new_val
             jint res = cmpxchg(new_val, dest_int, cur);
             if (res == cur) break;
             cur = res;
             new_val = cur;
             new_val_as_bytes[offset] = exchange_value;
         }
         // 返回当前值
         return cur_as_bytes[offset];
}

Monitor锁

JVM的内置锁通过操作系统的管程实现。由于管程是一种互斥资源,修改互斥资源至少需要一个CAS操作。因此,锁必然也使用了lock信号,具有mfence的语义。

1
2
3
4
5
6
7
8
9
10
int b = 0;
int c = 0;
synchronized(this) { //->monitorenter
	//Load内存屏障
	//Acquire内存屏障
    int a = b;
    c = 1; //synchronized代码块里面还是可能会发生指令重排
	//Release内存屏障
} //-> monitorexit
//Store内存屏障

原子性

加锁和释放锁,基于ObjectMonitor实现。

可见性

  • 在monitorexit指令之后,会有一个Store屏障,让线程把自己在同步代码块里修改的变量的值都执行flush处理器缓存的操作,刷到高速缓存(或者主内存)里去;
  • 然后在monitorenter指令之后会加一个Load屏障,执行refresh处理器缓存的操作,把别的处理器修改过的最新值加载到自己高速缓存里来;

所以说通过Load屏障和Store屏障,就可以让synchronized保证可见性

有序性

  • 在monitorenter指令之后,Load屏障之后,会加一个Acquire屏障,这个屏障的作用是禁止读操作和读写操作之间发生指令重排序。
  • 在monitorexit指令之前,会加一个Release屏障,这个屏障的作用是禁止写操作和读写操作之间发生重排序。

所以说,通过 Acquire屏障和Release屏障,就可以让synchronzied保证有序性,只有synchronized内部的指令可以重排序,但是绝对不会跟外部的指令发生重排序。

内存屏障

内存屏障

volatile与内存屏障总结

synchronized 实现原理与内存屏障