易失性关键字和 CPU 缓存一致性协议

The Volatile Keyword and CPU Cache Coherence Protocol

CPU已经通过一些协议(如MESI)保证了缓存的一致性。 为什么我们还需要 volatile 在某些语言中(如 java)来保持多线程之间的可见性。

可能的原因是这些协议在启动时未启用,必须由某些指令触发,例如 LOCK

如果真的那样,为什么 CPU 启动时不启用协议?

Volatile 可防止 3 种不同类型的问题:

  • 可见度
  • 重新排序
  • 原子性

我假设 X86..

首先,X86 上的缓存始终是一致的。因此不会发生在一个 CPU 将某个变量的存储提交到缓存后,另一个 CPU 仍会加载该变量的旧值。这是MESI协议的域。

假设 Java 字节码中的每个 put 和 get 都被翻译(而不是优化掉)到 CPU 上的存储和加载,那么即使没有 volatile,每个 get 也会看到最近放入同一地址。

这里的问题是编译器(在本例中为 JIT)有很大的自由来优化代码。例如,如果它检测到在循环中读取了相同的字段,它可以决定将该变量提升到循环之外,如下所示。

 for(...){
       int tmp = a;
       println(tmp);
 }

吊装后:

 int tmp = a;
 for(...){
       println(tmp);
 }

如果该字段仅被 1 个线程触及,这很好。但是如果字段被另一个线程更新,第一个线程将永远看不到变化。使用 volatile 可以防止此类可见性问题,这实际上是以下行为:

  • C 风格易失性
  • 在 JSR-133 引入 Java 内存模型之前 Java volatile。
  • 具有不透明访问模式的 VarHandle。

那么volatile还有一个很重要的方面; volatile 防止加载和存储到某些 CPU 执行的指令流中的不同地址被重新排序。 JIT 编译器和 CPU 有很大的自由度来重新排序加载和存储。尽管在 X86 上,由于存储缓冲区,只有较旧的存储可以用较新的负载重新排序到不同的地址。

想象一下下面的代码:

int a;
volatile int b;

thread1:
    a=1;
    b=1;

thread2:
    if(b==1) print(a);

b 易变的事实阻止 a=1 的存储在存储 b=1 之后跳转。它还可以防止 a 的负载在 b 的负载之前跳入。因此,线程 2 保证在读取 b=1.

时看到 a=1

所以使用volatile,可以保证非volatile字段对其他线程可见。

如果您想了解 volatile 的工作原理,我建议您深入研究 Java 内存模型,正如 Margeret Bloom 已经指出的那样,该模型以同步和先发生规则表示。我已经给出了一些底层细节,但是在 Java 的情况下,最好使用这个高级模型而不是从硬件的角度考虑。仅以 hardware/fences 的方式思考仅供专家使用,不可移植且非常脆弱。