volatile的内存语义:禁止指令重排序与JMM可见性保证
volatile的内存语义:禁止指令重排序与JMM可见性保证
适读人群:理解Java基础并发、想彻底搞清volatile的后端工程师 | 阅读时长:约16分钟
开篇故事
2021年初,我接手一个分布式任务调度系统的遗留代码。有个单例的TaskScheduler类,用了最朴素的双重检查锁(DCL),代码大概长这样:
private static TaskScheduler instance;
public static TaskScheduler getInstance() {
if (instance == null) {
synchronized (TaskScheduler.class) {
if (instance == null) {
instance = new TaskScheduler();
}
}
}
return instance;
}然后线上偶发NPE,堆栈指向TaskScheduler初始化完成后的某个方法调用。最诡异的是,NPE只在高并发启动场景下出现,而且是必现的——压测到100个并发线程同时调用getInstance(),大概每跑100次就会出现一次。
小王来找我,说这代码逻辑上没问题啊,双重检查,加了锁,instance != null的时候才返回,怎么会NPE?
我直接问他:"instance字段有没有加volatile?"
他愣了一下,说没有。
这就是问题所在。instance = new TaskScheduler()这行代码在JVM层面不是原子的,它会被拆成三个操作:分配内存、初始化对象、将引用赋值给instance。JIT编译器和CPU可能对后两步进行指令重排序,导致instance先不为null(指向已分配但未初始化完成的内存),另一个线程拿到这个"半成品"对象,调用方法时NPE。
加上volatile关键字,三分钟解决了这个困扰了团队两个月的偶发NPE。
这件事让我决定把volatile的内存语义彻底搞清楚,不能只停在"禁止缓存"这个层次。
一、volatile解决什么问题
1.1 硬件层面的问题根源
现代CPU有多级缓存(L1/L2/L3 Cache),每个核心有自己的L1/L2缓存。当多个线程在不同核心上运行时:
- 线程A在核心1修改了变量x(写入核心1的L1缓存)
- 线程B在核心2读取变量x,读到的可能是核心2缓存中的旧值
这就是可见性问题。
此外,编译器和CPU为了提升性能,会对指令进行重排序:
- 编译器重排序:编译阶段重新排列指令顺序
- CPU流水线重排序:乱序执行(Out-of-Order Execution)
- 内存系统重排序:Store Buffer和Load Buffer的写-读顺序问题
这就是有序性问题。
1.2 volatile的两个保证
volatile关键字在Java内存模型(JMM)层面提供了两个语义保证:
保证1:可见性 对volatile变量的写操作,对后续所有读操作可见。不存在"读到旧值"的情况。
保证2:禁止指令重排序(有序性)volatile写操作前面的所有普通读写,不能被重排序到volatile写后面。 volatile读操作后面的所有普通读写,不能被重排序到volatile读前面。
注意:volatile不保证原子性。i++这种操作,即使i是volatile,在并发下依然会丢失更新。
二、JMM内存语义深度解析
2.1 内存屏障(Memory Barrier)
JVM在实现volatile语义时,通过插入内存屏障来防止指令重排序。HotSpot JVM在x86架构上的实现:
| 操作 | 插入的屏障 | 含义 |
|---|---|---|
| volatile写之前 | StoreStore屏障 | 普通写→volatile写,不能重排序 |
| volatile写之后 | StoreLoad屏障 | volatile写→后续读写,不能重排序(这是最昂贵的屏障) |
| volatile读之后 | LoadLoad屏障 | volatile读→后续读,不能重排序 |
| volatile读之后 | LoadStore屏障 | volatile读→后续写,不能重排序 |
在x86上,StoreLoad屏障用lock addl $0x0,(%rsp)(或mfence)指令实现,这会导致CPU将Store Buffer中的数据立即刷新到Cache,同时使其他核心的Cache行无效。
2.2 happens-before中的volatile规则
JMM的happens-before(先行发生)规则中,volatile变量规则是第6条:
对一个volatile变量的写操作,happens-before于后续对这个volatile变量的读操作。
结合传递性,这意味着:线程T1对volatile变量写之前的所有操作,都happens-before线程T2读这个volatile变量之后的所有操作。
这正是DCL中volatile起作用的原因:
T1执行:
1. 分配内存 ─┐
2. 初始化对象 ─┤ 这三步happens-before 步骤3(volatile写)
3. instance = ref (volatile写)
T2执行:
4. 读 instance (volatile读) ─┐ happens-before
5. 使用instance.方法() ─┘因为volatile写happens-before volatile读,加上传递性,T1的步骤1-2都happens-before T2的步骤5。T2不可能看到未初始化完成的对象。
2.3 volatile不能保证原子性
这是最常见的误解:
volatile int count = 0;
// 多线程并发执行这行代码,结果依然不正确
count++; // 等价于 count = count + 1,读-改-写,非原子count++对应的字节码是:
getfield(读count)iconst_1iaddputfield(写count)
volatile只保证每次getfield拿到最新值,每次putfield立即刷回主内存,但无法保证读-改-写三步是原子的。两个线程可能都读到同一个旧值,各自加1后写回,结果count只增加了1而不是2。
需要原子性,用AtomicInteger或synchronized。
三、完整代码实现
3.1 DCL的正确写法与错误写法对比
package com.laozhang.concurrent.volatile_demo;
/**
* 双重检查锁定(DCL)的正确与错误写法
*
* 测试环境:JDK 11.0.18
* 验证方法:用javap -v命令查看字节码,确认volatile字段有ACC_VOLATILE标志
*
* 错误版本可能出现的问题:
* - 线程A正在执行 instance = new Singleton()
* - JIT重排序后,先执行"引用赋值",再执行"对象初始化"
* - 线程B读到instance != null,但对象字段还是默认值(int为0,对象引用为null)
* - 线程B调用instance的方法,NPE
*/
public class DCLDemo {
// ===== 错误写法:缺少volatile =====
private static DCLDemo wrongInstance;
public static DCLDemo getWrongInstance() {
if (wrongInstance == null) { // 第一次检查(无锁)
synchronized (DCLDemo.class) {
if (wrongInstance == null) { // 第二次检查(持锁)
wrongInstance = new DCLDemo(); // 危险!可能重排序
}
}
}
return wrongInstance;
// 可能返回未完全初始化的对象
}
// ===== 正确写法:volatile保证有序性 =====
private volatile static DCLDemo correctInstance;
public static DCLDemo getCorrectInstance() {
if (correctInstance == null) { // 第一次检查(无锁)
synchronized (DCLDemo.class) {
if (correctInstance == null) { // 第二次检查(持锁)
correctInstance = new DCLDemo(); // volatile写,禁止重排序
}
}
}
return correctInstance;
}
// 模拟有多个字段的对象
private final String name;
private final int value;
private final long[] data;
private DCLDemo() {
this.name = "Initialized";
this.value = 42;
this.data = new long[1024];
// 初始化耗时操作
for (int i = 0; i < data.length; i++) {
data[i] = i * i;
}
}
public String getName() { return name; }
public int getValue() { return value; }
}3.2 用volatile实现生产者-消费者的状态标志
package com.laozhang.concurrent.volatile_demo;
import java.util.concurrent.TimeUnit;
/**
* 用volatile实现线程间的状态标志传递
*
* 这是volatile最典型的正确用法:
* 一个线程写,多个线程读;只需要可见性,不需要原子性
*
* 注意:这里的cancelled字段用volatile是正确的,
* 因为只有一个线程写(调用cancel()),多个线程读(任务线程检查)
*/
public class TaskWithVolatileFlag {
/**
* 任务状态标志 - volatile保证可见性
* 若不加volatile,任务线程可能永远看不到主线程对cancelled的修改
* (JIT可能将cancelled的读优化为寄存器缓存)
*/
private volatile boolean cancelled = false;
/**
* 运行标志 - 同样需要volatile
*/
private volatile boolean running = false;
private Thread workerThread;
public void start() {
running = true;
workerThread = new Thread(() -> {
System.out.println("[Worker] 任务开始执行");
long count = 0;
// 关键:每次循环都从主内存读取cancelled(因为volatile)
// 如果不加volatile,JIT可能编译为:
// if (!cancelled) { while(true) { ... } }
// 永远不再检查cancelled的变化
while (!cancelled) {
// 模拟业务逻辑
count++;
if (count % 1_000_000 == 0) {
System.out.println("[Worker] 已处理 " + count + " 条记录");
}
}
System.out.println("[Worker] 收到取消信号,退出。共处理:" + count + " 条");
running = false;
}, "task-worker");
workerThread.start();
}
public void cancel() {
System.out.println("[Main] 发出取消信号");
cancelled = true; // volatile写,立即对所有线程可见
}
public boolean isRunning() {
return running; // volatile读
}
public static void main(String[] args) throws InterruptedException {
TaskWithVolatileFlag task = new TaskWithVolatileFlag();
task.start();
// 让任务跑3秒
TimeUnit.SECONDS.sleep(3);
// 取消任务
task.cancel();
// 等待任务完全退出
int waitCount = 0;
while (task.isRunning() && waitCount < 100) {
TimeUnit.MILLISECONDS.sleep(10);
waitCount++;
}
System.out.println("[Main] 任务已" + (task.isRunning() ? "超时未退出" : "正常退出"));
}
}3.3 验证volatile禁止重排序的测试代码
package com.laozhang.concurrent.volatile_demo;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 验证volatile禁止重排序的经典测试:Dekker重排序测试
*
* 若x、y不加volatile,在没有内存屏障的CPU(如ARM)上,
* 两个线程可能同时看到 r1==0 && r2==0,这在顺序一致性模型下是不可能的,
* 说明发生了指令重排序或内存可见性问题。
*
* x86因为自带TSO(全序存储)模型,通常不会复现;ARM/Power可以复现。
* 通过JVM强制不内联(-XX:-Inline)或用字节码操作可以在x86触发编译器重排序。
*/
public class ReorderingTest {
// 不加volatile的情况
static int x = 0, y = 0;
static int r1, r2;
// 加volatile的情况
static volatile int vx = 0, vy = 0;
static int vr1, vr2;
private static final int ITERATIONS = 1_000_000;
public static void main(String[] args) throws InterruptedException {
testWithoutVolatile();
testWithVolatile();
}
static void testWithoutVolatile() throws InterruptedException {
AtomicInteger reorderCount = new AtomicInteger(0);
for (int iter = 0; iter < ITERATIONS; iter++) {
x = 0; y = 0; r1 = 0; r2 = 0;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
Thread t1 = new Thread(() -> {
try { start.await(); } catch (Exception e) {}
x = 1;
r1 = y; // 如果没有屏障,可能读到0(因为y=1被重排序到r2=x之后)
done.countDown();
});
Thread t2 = new Thread(() -> {
try { start.await(); } catch (Exception e) {}
y = 1;
r2 = x; // 如果没有屏障,可能读到0(因为x=1被重排序到r1=y之后)
done.countDown();
});
t1.start();
t2.start();
start.countDown();
done.await();
// r1==0 && r2==0 意味着发生了重排序
if (r1 == 0 && r2 == 0) {
reorderCount.incrementAndGet();
}
}
System.out.printf("不加volatile:%d次迭代中发生重排序 %d 次(%.2f%%)%n",
ITERATIONS, reorderCount.get(),
100.0 * reorderCount.get() / ITERATIONS);
}
static void testWithVolatile() throws InterruptedException {
AtomicInteger reorderCount = new AtomicInteger(0);
for (int iter = 0; iter < ITERATIONS / 100; iter++) { // volatile测试慢,减少迭代
vx = 0; vy = 0; vr1 = 0; vr2 = 0;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
Thread t1 = new Thread(() -> {
try { start.await(); } catch (Exception e) {}
vx = 1; // volatile写
vr1 = vy; // volatile读
done.countDown();
});
Thread t2 = new Thread(() -> {
try { start.await(); } catch (Exception e) {}
vy = 1; // volatile写
vr2 = vx; // volatile读
done.countDown();
});
t1.start();
t2.start();
start.countDown();
done.await();
if (vr1 == 0 && vr2 == 0) {
reorderCount.incrementAndGet();
}
}
System.out.printf("加volatile:%d次迭代中发生重排序 %d 次(理论上应为0)%n",
ITERATIONS / 100, reorderCount.get());
}
}四、踩坑实录
坑1:volatile数组的元素写不具有volatile语义
报错现象: 把数组声明为volatile int[] arr,多线程修改数组元素后,其他线程读到的还是旧值。
原因分析: volatile int[] arr的volatile语义只作用于数组引用本身,不作用于数组元素。arr[i] = value是对数组元素的写,不是对arr引用的写,不会插入内存屏障。
volatile int[] arr = new int[10];
// 错误理解:以为arr[0]的写有volatile语义
arr[0] = 1; // 这不是volatile写!
// 正确做法1:用AtomicIntegerArray
AtomicIntegerArray atomicArr = new AtomicIntegerArray(10);
atomicArr.set(0, 1); // 真正的volatile写
// 正确做法2:用Unsafe的putIntVolatile(不推荐)
// 正确做法3:用VarHandle(JDK 9+)解法: 使用AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray,或JDK 9+的VarHandle。
坑2:long和double的32位JVM写操作不是原子的
报错现象: 在32位JVM上,volatile long的读写没问题,但普通long字段在多线程下出现"字撕裂"(读到高32位是旧值、低32位是新值的混合)。
原因分析: JMM规定,对于32位JVM,long和double(64位数据)的读写可能被分成两个32位操作,不保证原子性。但volatile long和volatile double的读写必须是原子的。
现在64位JVM已经是标准了,HotSpot的64位实现对long/double的读写实际上是原子的,但这不是JMM的规范保证,只是具体实现。
解法: 多线程共享的long/double字段,加volatile或用synchronized。
坑3:volatile读的性能代价被低估
报错现象: 某个核心热点方法里有大量volatile字段读取,性能profiling发现比预期慢很多。
原因分析: volatile读在x86上代价较低(只需要防止LoadLoad和LoadStore重排序,x86硬件天然保证),但volatile写代价较高(需要StoreLoad屏障,触发lock addl指令刷新Store Buffer)。
在ARM/AArch64上,volatile读也需要dmb ishld指令,代价更高。
基准测试数据(JMH,JDK 11,x86 Xeon E5):
- 普通字段读:约0.3ns/op
- volatile字段读:约1.2ns/op(约4倍开销)
- volatile字段写:约15ns/op(约50倍开销,因为StoreLoad屏障)
解法: 如果只需要可见性且读多写少,volatile是合适的。如果写操作也很频繁,考虑AtomicXxx类(使用Unsafe的lazySet减少屏障插入)或换用LongAdder。
坑4:指令重排序在JIT热身后才出现
报错现象: 本地测试能稳定复现并发bug,但加了大量日志后bug消失了;或者,压测初期没问题,QPS上去之后才出bug。
原因分析: JVM的JIT编译器是分层编译的(Interpreter → C1 → C2)。解释执行阶段不做激进优化,几乎不会重排序。C2编译后(方法被调用1万次后触发),JIT做了最激进的优化,包括编译器重排序。
所以"本地跑了100次没问题"不代表并发安全,因为100次可能还在解释执行阶段。真正的重排序bug在C2编译后才会频繁出现,而C2编译触发的阈值是-XX:CompileThreshold=10000(默认1万次调用)。
解法: 并发代码的正确性验证要么靠理论分析(happens-before规则),要么用-XX:CompileThreshold=1强制JIT立即编译后做压测。不要靠"跑了几次没问题"来判断正确性。
五、总结与延伸
volatile的本质是JMM对"可见性"和"有序性"的语言层面保证,底层通过内存屏障指令实现。
记住这个核心规则:
- volatile写:写操作发生之前的所有操作,不能排到写之后(StoreStore屏障);写操作之后,插入StoreLoad屏障,刷新到主内存
- volatile读:读操作之后的所有操作,不能排到读之前(LoadLoad + LoadStore屏障);读操作直接从主内存读取
volatile的正确使用场景:
- 状态标志(一个线程写,多个线程读)
- 发布不可变对象(配合final字段)
- DCL单例(本质是发布初始化完成的对象)
- 独立可见的变量(不涉及复合操作)
volatile的错误使用场景:
- 复合操作(
i++、条件更新)→ 用AtomicXxx - 多变量一致性(需要同时更新多个相关字段保持一致)→ 用
synchronized - 数组元素可见性 → 用
AtomicXxxArray或VarHandle
下一篇讲AQS源码,那里有大量volatile的实战使用,能看到更复杂的内存语义应用。
