问题
1. volatile 作用
- 防止重排序
-
2. volatile 实现原理
可见性实现原理。lock 指令、缓存一致性
- 有序性实现原理。volaile 的 Happens-Before 原则、禁止重排序。
3. volatile 应用场景
- 状态标志位。
-
4. 既然有了 MESI 协议,是不是就可以不需要 volatile 可见性语义了 ?
当然不是,还有三个问题:
并不是所有的硬件架构都提供了相同的一致性保证。JVM 需要 volatile 统一语义。就算是 MESI,也只解决了 CPU 缓存层问题。
可见性问题不仅仅局限于 CPU 缓存内,JVM 自己维护的 JMM 模型也存在可见性问题。volatile 语义也可以解决 JMM 模型的可见性问题。
volatile 的作用
防止重排序
从一个单例模式讲起
public class Singleton {
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
实例化一个对象
实例化一个对象主要分为三步:
分配内存空间。
- 初始化对象。
- 将内存空间的地址赋值给对应的引用。
但由于编译系统或 CPU 会对指令重排序,所以上面可能会变成以下过程:
- 分配内存空间。
- 将内存空间的地址赋值给对应的引用。
- 初始化对象。
如果是这样的话,就会存在未初始化的对象提早暴露,从而导致不可预料的结果。因此,我们需要将变量设置为带有 volatile
类型的变量,禁止指令重排序。
实现可见性
可见性问题主要是指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是:每个线程拥有自己的一个高速缓存区,也称工作内存。volatile 关键字能有效解决这个问题。
保证原子性:单次读/写
volatile 不能保证完全的原子性,只能保证单次的读/写操作具有原子性。共享的 long 和 double 建议使用 volatile 关键字,因为这样能保证任何情况下对 long 和 double 的单次读/写都具有原子性。
volatile 的实现原理
可见性实现
基于内存屏障(Memory Barrier)实现。内存屏障又称内存栅栏,是一个 CPU 指令。编程器和 CPU 为了优化程序执行性能,会对指令进行重排序,虽然过程不像代码写的那样,但是结果是正确的。可是,在并发情况下,指令重排序容易引发程序 BUG,因此通过插入特定类型的内存屏障来禁止编程器和 CPU 重排序。
比如下面这一段代码:
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}
经过 hsdis 和 jitwatch 工作可以得到编译后的汇编代码:
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
0x000000000295158c: lock cmpxchg %rdi,(%rdx) // #1 在volatile修饰的共享变量进行写操作的时候
// 会多出lock前缀的指令
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......
代码 #1
处多出了 lock 前缀指令,含义是总线锁,Lock 会触发硬件缓锁定机制,有两种:总线锁和缓存一致性协议。早期 CPU 技术比较落后,才使用总线锁来保证缓存一致性,总线相当于只有一条车道的高速公路,因此,谁获得了锁,谁就能内存,但是这样效率太低了。因此,缓存一致性协议应运而生,现在使用最广泛的是 MESI 缓存一致性协议。在多核处理器下发生以下两种情况:
- 将当前处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其它 CPU 缓存相同的数据行失效。
内存速度和 CPU 相关几个数量级,因此,处理器运算单元不会直接读取内存值,而是通过 L1、L2 和 L3 等 CPU 缓存交互。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀指令,将这个变量所在的缓存行的数据写回到内存中。
高速缓存一致性协议 MESI
MESI 协议就是为了保证各个处理器的缓存一致性。CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
- 读操作:缓存行数据被修改。当当前处理器的缓存行置为无效状态(状态 I)。
-
Lock
在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个
LOCK#
信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。有序性实现
volatile 也有 happens-before 规则:对一个 volatile 变量的写,happens-before 于任意后续对这个 volatile 变量的读。
//假设线程A执行writer方法,线程B执行reader方法 class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; // 1 线程A修改共享变量 flag = true; // 2 线程A写volatile变量 } public void reader() { if (flag) { // 3 线程B读同一个volatile变量 int i = a; // 4 线程B读共享变量 …… } } }
当线程 A 修改变量
flag
后,线程 B 能够迅速感知。volatile 禁止重排序
JMM 提供内存屏障防止编译器和 CPU 对指令的重排序。Java 编译器在生成指令时在适当的位置插入内存屏障指令来禁止重排序,
有以下四种屏障类型:
屏障类型 | 指令示例 | 说明 |
---|---|---|
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及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令。 全能屏障,但是开销相对较大 |
- Store:将处理器缓存的数据写回(刷新)到内存中。
- Load:将内存存储的数据拷贝到处理器缓存中。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
x86架构的内存屏障
Store Barrier
sfence指令实现了Store Barrier,相当于StoreStore Barriers。
强制所有在sfence指令之前的store指令,都在该sfence指令执行之前被执行,发送缓存失效信号,并把store buffer中的数据刷出到CPU的L1 Cache中;所有在sfence指令之后的store指令,都在该sfence指令执行之后被执行。即,禁止对sfence指令前后store指令的重排序跨越sfence指令,使所有Store Barrier之前发生的内存更新都是可见的。
这里的“可见”,指修改值可见(内存可见性)且操作结果可见(禁用重排序)。下同。
内存屏障的标准中,讨论的是缓存与内存间的相干性,实际上,同样适用于寄存器与缓存、甚至寄存器与内存间等多级缓存之间。x86架构使用了MESI协议的一个变种,由协议保证三层缓存与内存间的相关性,则内存屏障只需要保证store buffer(可以认为是寄存器与L1 Cache间的一层缓存)与L1 Cache间的相干性。下同。
Load Barrier
lfence指令实现了Load Barrier,相当于LoadLoad Barriers。
强制所有在lfence指令之后的load指令,都在该lfence指令执行之后被执行,并且一直等到load buffer被该CPU读完才能执行之后的load指令(发现缓存失效后发起的刷入)。即,禁止对lfence指令前后load指令的重排序跨越lfence指令,配合Store Barrier,使所有Store Barrier之前发生的内存更新,对Load Barrier之后的load操作都是可见的。
Full Barrier
mfence指令实现了Full Barrier,相当于StoreLoad Barriers。
mfence指令综合了sfence指令与lfence指令的作用,强制所有在mfence指令之前的store/load指令,都在该mfence指令执行之前被执行;所有在mfence指令之后的store/load指令,都在该mfence指令执行之后被执行。即,禁止对mfence指令前后store/load指令的重排序跨越mfence指令,使所有Full Barrier之前发生的操作,对所有Full Barrier之后的操作都是可见的。
volatile 应用
- 状态标记位
- Double Check Lock
-
Java 内存模型
JMM 的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量等操作的底层细节。此处的变量意为共享变量。为了获得较高的执行性能,JMM 并没有限制执行引擎绑定某个特定的 CPU 或缓存,与之进行交互。因此,涉及到缓存一致性问题就需要一个同步方案。JMM 模型做了以下规定: 所有变量都存储在主内存(Main Memory)中。
- 每个线程有自己的工作内存(Working Memory)。工作内存中保留了该线程使用到的变量的主内存的副本。工作内存只是一个逻辑概念,包含缓存、写缓存区以及相关寄存器。
- 线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
- 不同线程之间不能直接访问或交换变量。线程间变量值的传递必须经过主内存来完成。
上面翻译就是每个线程访问某个共享变量的时候,需要将值拷贝到自己的工作内存中。如果修改了,就需要写回主内存,但是里面涉及到缓存一致性问题,于是,在主内存和线程工作内存之间抽象了一层 JVM 内存交互协议,各线程读取和写回操作时需要依靠这个协议共同保证主内存的共享变量处于一致状态。