易失性关键字和 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 的方式思考仅供专家使用,不可移植且非常脆弱。
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 的方式思考仅供专家使用,不可移植且非常脆弱。