为了解决 CPU 访问主存时主存读写性能的短板,在 CPU 中增加了高速缓存,但这带来了可见性问题。而 Java 的 volatile 关键字可以保证共享变量的主存可见性,也就是将共享变量的改动值立即刷新回主存。在正常情况下,系统操作并不会校验共享变量的缓存一致性,只有当共享变量用 volatile 关键字修饰了,该变量所在的缓存行才被要求进行缓存一致性的校验。
volatile 底层原理
通过 volatile 关键字的汇编代码分析 volatile 的底层原理。参考如下代码:
public class VolatileVar {
volatile int var = 0;
public void setVar(int var) {
System.out.println("setVar = " + var);
this.var = var;
}
}
涉及到的汇编指令如下:
由于共享变量 var 加了 volatile 关键字,因此在汇编指令中,操作 var 之前多出一个 lock 前缀指令 lock addl
,该 lock 前缀指令有三个功能:
- 将当前 CPU 缓存行的数据立即写回主内存
在对 volatile 修饰的共享变量进行写操作时,其汇编指令前用 lock 前缀修饰。lock 前缀指令使得在执行指令期间,CPU 可以独占共享内存(即主存)。对共享内存的独占,老的 CPU(如 Intel 486)通过总线锁方式实现。由于总线锁开销比较大,因此新版 CPU(如 IA-32、Intel 64)通过缓存锁实现对共享内存的独占性访问,缓存锁(缓存一致性协议)会阻止两个 CPU 同时修改共享内存的数据。
- lock 前缀指令会引起在其他 CPU 中缓存了该内存地址的数据无效
写回操作时要经过总线传播数据,而每个 CPU 通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当 CPU 发现自己缓存行对应的内存地址被修改时,就会将当前 CPU 的缓存行设置为无效状态,当 CPU 要对这个值进行修改的时候,会强制重新从系统内存中把数据读到 CPU 缓存。
- lock 前缀指令禁止指令重排
lock 前缀指令的最后一个作用是作为内存屏障(Memory Barrier
)使用,可以禁止指令重排序,从而避免多线程环境下程序出现乱序执行的现象。
volatile 不具备原子性
volatile 能保证数据的可见性和有序性,但不能完全保证数据的原子性,对于 volatile 类型的变量进行复合操作(如++),其仍存在线程不安全的问题。
public class VolatileTest {
private volatile long value;
private static VolatileTest test = new VolatileTest();
private static CountDownLatch latch = new CountDownLatch(10);
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> test.calculate()).start();
}
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(test.value);
}
private void calculate() {
for (int i = 0; i < 1000; i++) {
value++;
}
latch.countDown();
}
}
程序运行结果不是 10000,因此可以证明 i++ 不是原子操作。
volatile 变量的复合操作不具备原子性原理
首先回顾一下 JMM 对变量进行读取和写入的流程:
图 - JMM 对变量进行读取和写入的操作流程
对于非 volatile 修饰的普通变量而言,在读取变量时,JMM 要求保持 read、load 有相对顺序即可。例如,若从主存读取 i、j 两个变量,可能的操作是 read i=>readj=>load j=>load i,并不要求 read、load 操作是连续的。
但是对于 volatile 变量而言,具有两个重要语义:
- 使用 volatile 修饰的变量在变量值发生改变时,会立刻同步到主存,并使其他线程的变量副本失效
- 禁止指令重排序:用 volatile 修饰的变量在硬件层面上会通过在指令前后加入内存屏障来实现,编译器级别是通过下面的规则实现的
为了实现 volatile 的这些语义, JMM 对 volatile 变量会有特殊的约束:
- 使用 volatile 修饰的变量其 read、load、use 都是连续出现的,所以每次使用变量的时候都要从主存读取最新的变量值,替换私有内存的变量副本值(如果不同的话)
- 其对同一变量的 assign、store、write 操作都是连续出现的,所以每次对变量的改变都会立马同步到主存中
虽然 volatile 修饰的变量可以强制刷新内存,但是其并不具备原子性。虽然其要求对变量的(read、load、use)、(assign、store、write)必须是连续出现,但是在不同 CPU 内核上并发执行的线程还是有可能出现读取脏数据的时候。
假设有两个线程 A、B 分别运行在 Core1、Core2 上,并假设此时的 value 为0,线程 A、B 也都读取了 value 值到自己的工作内存。现在线程 A 将 value 变成 1 之后,完成了 assign、store 的操作,假设在执行 write 指令之前,线程 A 的 CPU 时间片用完,线程 A 被空闲,但是线程 A 的 write 操作没有到达主存。由于线程 A 的 store 指令触发了写的信号,线程 B 缓存过期,重新从主存读取到 value 值,但是线程 A 的写入没有最终完成,线程 B 读到的 value 值还是0。线程 B 执行完成所有的操作之后,将 value 变成 1 写入主存。线程 A 的时间片重新拿到,重新执行 store 操作,将过期了的 1 写入主存。
图 - 线程 A、B 并发操作 value 时可能发生脏数据写入的流程
对于复合操作,volatile 变量无法保障其原子性,如果要保证复合操作的原子性,就需要使用锁。并且,在高并发场景下,volatile 变量一定需要使用 Java 的显式锁结合使用。
什么时候使用 volatile
- 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是 assign—store—write 三步操作,这三步操作不是原子性的,而 volatile 不保证原子性
- 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile 的