1. volatile 简介
volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制。Java 内存模型告诉我们各个线程会将共享变量从主内存中拷贝到工作内存,然后执行引擎会基于工作内存中的数据进行操作处理。线程在工作内存进行操作后何时会写到主内存中?这个时机对普通变量是没有规定的。
:::info
即被 volatile 修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象「可见性」。
:::
2. volatile 实现原理
在生成汇编代码时,会在 volatile 修饰的共享变量进行写操作的时候多处一个 Lock 前缀的指令,它主要有两个方面的影响:
- 将当前处理器缓存行的数据写回系统内存;
- 这个写回内存的操作会使得其他 CPU 里缓存了该内存地址的数据无效
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性(MESI)协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:
- Lock 前缀的指令会引起处理器缓存写回内存;
- 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
- 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
这样 volatile 变量就可以通过**缓存一致性协议(MESI)**
保证每个线程都能获得最新值,即满足数据的「可见性」。
MESI:CPU 每个 cache line 标记的四种状态
- Modified
- Exclusion
- Shared
- Invalid
3. volatile 内存语义实现
我们都知道,为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。
JMM内存屏障分为四类见下图,
java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现 volatile的 内存语义,JMM 会限制特定类型的编译器和处理器重排序,JMM 会针对编译器制定 volatile 重排序规则表:
为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障;
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障;
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障;
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
需要注意的是:volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;确保 Store1 的数据一定刷回主存,对其他 cpu 可见,先于Store2 以及后续指令
StoreLoad屏障:防止上面的 volatile 写与下面可能有的 volatile 读/写 重排序。确保 Store1 指令的数据一定刷回主存,对其他 cpu 可见,先于Load2 以及后续指令的数据装载
LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序。确保 Load1 数据的装载先于 Load2 后所有装载指令,他的意思,Load1 对应的代码和 Load2 对应的代码,是不能指令重排的。
LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序,确保 Load1 指令的数据装载,先于 Store2 以及后续指令
下面以两个示意图进行理解,图片摘自相当好的一本书《java并发编程的艺术》。
4. 一个示例
我们现在已经理解 volatile 的精华了,文章开头的那个问题我想现在我们都能给出答案了。更正后的代码为:
public class VolatileDemo {
private static volatile boolean isOver = false;
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (!isOver) ;
}
});
thread.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
isOver = true;
}
}
注意不同点,现在已经将 isOver 设置成了 volatile 变量,这样在main线程中将 isOver 改为了 true 后,thread 的工作内存该变量值就会失效,从而需要再次从主内存中读取该值,现在能够读出 isOver 最新值为 true 从而能够结束在 thread 里的死循环,从而能够顺利停止掉 thread 线程。
缓存行
数据读取是以缓存行为单位进行读取的。存行越大,局部性空间效率越高,但读取时间慢;缓存行越小,局部性空间效率越低,但读取时间快。所以取一个折中值,目前多用:**64**
字节
缓存行在 java 中的应用
也可改为用注解的方式:
JDK 1.8 之后,很多地方用了 **@sun.misc.Contended**
注解。是为了让 x 这个变量和其他变量不位于同一行,这样不会相互影响,导致缓存行失效再重新从主存读取,提高系统运行效率。