并发知识 -- 内存屏障
本文最后更新于:5 天前
⚡CPU 提供多种内存屏障
-
LoadLoad: 相当于
读屏障
: 任何读屏障前的读操作都会先于该屏障后的读操作完成 -
LoadStore: 任何该屏障前的读操作都会先于该屏障后的写操作完成
-
StoreLoad: 任何该屏障前的写操作都会先于该屏障后的读操作完成
对于 StoreLoad 实际上是一个性能开销最大的屏障,因为 StoreLoad 必须同时兼顾读和写,并且对于这两个操作必须全部完成
StoreLoad 相当于一个全屏障,其他内存屏障能够做到的它都能够做到
可能会觉得 LoadStore 也是要兼顾读写,但是它只是保证读在写之前完成,但是并不关心写在什么时候完成
-
StoreStore: 相当于
写屏障
: 任何写屏障前的写操作都会先于该屏障后的写操作完成
⚡为什么内存屏障能保证可见性和有序性呢?
原因其实就在问题里,为了保证可见性,我们使用内存屏障保证数据的可见,为了保证有序性,我们使用内存屏障限制指令重排
对于保证数据可见性,在上一篇博客里面其实一直使用的都是内存屏障的可见性的功能,使得数据一定可见,但是没有提到内存屏障还有一个限制指令重排序的重要功能,例如:
void foo() {
a = 1;
smp_wmb()
b = 1;
}
void bar() {
while (b == 0) continue;
smp_rmb()
assert(a == 1)
}
这是之前加入了两个内存屏障,使得 CPU0 先刷新 store buffer 中的数据, CPU1 也类似,先处理 Invalid Queue 的指令,但是,其实如果不加这两个内存屏障,CPU 也有可能对其指令进行指令重排序,也可能使我们得到不一致的结果,例如:
假设上面的代码中内存屏障没有限制指令重排序的功能,那么代码 foo 可能会被优化成先去执行 b =1 后去执行 a = 1,那么即使在 b = 1 之前加入了写屏障,也失去了效果,因为这个写屏障之前没有任何操作,写屏障相当于作废了,从而当另一个 CPU 执行 while 循环并退出的时候,读到 a 的值就是 0 (第一个 CPU 还没有执行到 a = 1 这条指令,或者就算执行到了,也在 Store Buffer 里面,并没有刷新到 Cache Line),造成数据不一致的情况
而如果 foo 函数的 b =1 前面加入了 StoreStore 屏障,这样 a = 1 就不可能就会在 b =1 后面执行,因为 a = 1 是一个写操作,如果把 a = 1 放到 b = 1 之后执行,按么就违背了 StoreStore 屏障。类似的,在 b = 1 后面要加入 StoreLoad 屏障,保证前面的读操作在 volatile 写之前完成,由此可以看出,内存屏障也限制了指令重排。
当然,可能会有疑问,如果没有在 b = 1 前面加入 StoreStore 屏障,而仅仅是在 b = 1 后面加入 StoreLoad 屏障,那么 a = 1 还是有可能会被重排序到后面去,导致结果不正确。所以,为了保证程序的有序性和可见性,Java 中 volatile 关键字要在写之前和写之后加入这些内存屏障。
当然,可能还有疑问,如果在 b = 1 之前加入 StoreStore 屏障就可以实现禁止指令重排,那么为什么还要在 b = 1 之后加入 StoreLoad 内存屏障呢?答案是,对于编译器来说,发现一个最优布置来最小化插入屏障的总数是几乎不可能的,所以JVM 采用 保守策略,每次 volatile 变量读写之前和之后都会加入内存屏障以保证 volatile 的内存语义。
⚡内存屏障具体实现方式
-
可见性是通过对 CPU 的 Store Buffer 和 Invalid Queue 中标记的数据进行处理实现的,具体的方式移步
-
禁止重排序我认为是在 CPU 执行的规定,看到内存屏障就不再取后面的指令。
⚡内存屏障在 JVM 的应用
volatile 关键字的应用上面已经提到
⚡Atomic 的 LazySet
Atomic 下的 LazySet 操作。拿最常见的 AtomicInteger 为例,里面的状态 value 是个 volatile 的 int,普通的 set 就是将这个状态修改为目标值,修改后因为有 Barrier 的关系会让其它 CPU 可见。而 lazySet 与 set 对比是这样:
public final void set(int newValue) {
value = newValue;
}
public final void lazySet(int newValue) {
unsafe.putOrderedInt(this, valueOffset, newValue);
}
对于 unsafe.putOrderedInt() 的内容 Java 完全没给出解释,但从添加 lazySet()这个功能的地方:Bug ID: JDK-6275329 Add lazySet methods to atomic classes,能看出来其作用是在写入 volatile 状态前增加 StoreStore Barrier。它只保证本次写入不会重排到前面写入之前,但本次写入什么时候能刷写到内存是不做要求的,从而是一次轻量级的写入操作,在特定场景能优化性能。
⚡ConcurrentLinkedQueue 下的黑科技
比如现在有 a b c d 四个 volatile 变量,如果无脑执行:
a = 1;
b = 2;
c = 3;
d = 4;
会在每个语句中间加上 Barrier。直接上面这样写可能还好,都是 StoreStore 的 Barrier,但如果写 volatile 之后又有一些读 volatile 操作,可能 Barrier 就会提升至最重的 StoreLoad Barrier,开销就会很大。而如果对开始的 a b c 写入都是用写普通变量的方式写入,只对最后的 d 用 volatile 方式更新,即只在 d = 4前带上写 Barrier,保证 d = 4 被其它 CPU 看见时,a、b、c 的值也能被别的 CPU 看见。这么一来就能减少 Barrier 的数量,提高性能。
上面介绍的 unsafe.putObject 的方法,用来将一个 volatile 变量以普通变量方式更新,即不使用 Barrier。用这个 putObject 就能做到上面提到的优化。
ConcurrentLinkedQueue 是 Java 标准库提供的无锁队列,它里面也用到了这个黑科技。因为是链表,所以里面有个叫 Node 的类用来存放数据,Node 连起来就构成链表。Node 内有个被 volatile 修饰的变量指向 Node 存放的数据。Node 的部分代码如下:
private static class Node<E> {
volatile E item;
volatile Node<E> next;
Node(E item) {
UNSAFE.putObject(this, itemOffset, item);
}
....
}
因为 Node 被构造出来后它得通过 cas 操作队尾 Node 的 next 引用接入链表,接入成功之后才需要被其它 CPU 看到,在 Node 刚构造出来的时候,Node 内的 item 实际不会被任何别的线程访问,所以看到 Node 的构造函数可以直接用 putObject 更新 item,等后续 cas 操作队列队尾 Node 的 next 时候再以 volatile 方式更新 next,从而带上 Barrier,更新完成后 next 的更新包括 Node 内 item 的更新就都被别的 CPU 看到了。从而减少操作 volatile 变量的开销。
本博客所有文章除特别声明外,均采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 。转载请注明出处!