并发知识 -- 内存屏障

本文最后更新于: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 的内存语义。

内存屏障具体实现方式

  1. 可见性是通过对 CPU 的 Store Buffer 和 Invalid Queue 中标记的数据进行处理实现的,具体的方式移步

  2. 禁止重排序我认为是在 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 变量的开销。