主题
volatile:可见性、有序性与使用边界
本文是 Java 并发系统学习系列的 L2 核心篇。前置:并发三大问题与 JMM 入门、synchronized 深入。 学完可以配合面试题食用:02 volatile 可见性与重排序、12 DCL 单例为什么要 volatile
volatile 解决什么问题,不解决什么
上一篇讲 JMM 时提到:每个线程有自己的工作内存,读写共享变量默认先走工作内存,于是出现两个经典 bug——改了别人看不见(可见性),以及代码执行顺序和写的顺序不一致(有序性)。volatile 就是 JVM 提供的最轻量的纠正手段,它对被修饰的变量承诺两件事:
- 可见性:写一个 volatile 变量时,工作内存里的值会立即刷回主内存,并让其他线程缓存该变量的副本失效;后续读必须重新从主内存加载。
- 有序性:编译器和处理器不会把 volatile 读写前后的指令任意重排(下面细说靠什么挡住)。
但它不承诺原子性。count++ 是"读-改-写"三步,volatile 只保证每一步读到的值是新的,挡不住两个线程同时读到同一个值各加一次。验证很简单:
java
public class VolatileNotAtomic {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10_000; i++) {
count++; // volatile 挡不住竞态:两个线程可能同时读到同一个旧值
}
};
Thread t1 = new Thread(task), t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(count); // 几乎必然 < 20000
}
}所以一句话定位:volatile 是"轻量级同步",只管可见与有序,不管复合操作的原子。需要原子性时用 synchronized、AtomicXxx 或 LongAdder。
内存屏障:volatile 的底层机制
JVM 层面对 volatile 的有序性保证,落到底层是内存屏障(memory barrier)指令。可以把它理解成插在指令流里的栅栏,规定栅栏前后的内存操作不能跨越。常见的四种:
- LoadLoad:load1; LoadLoad; load2 —— load2 及之后的读不能提前到 load1 之前
- StoreStore:store1; StoreStore; store2 —— store1 之前的写必须先落好,store2 才能执行
- LoadStore:load1; LoadStore; store2 —— store2 不能提前越过 load1
- StoreLoad:store1; StoreLoad; load2 —— 最重的一个,写必须全部可见后才能读
HotSpot 按 JMM 的保守策略,在 volatile 操作前后插入屏障:
mermaid
flowchart LR
subgraph 普通读写区A[volatile 写之前的普通操作]
A1[普通读/写]
end
A1 -- StoreStore 屏障 --> W[volatile 写]
W -- StoreLoad 屏障 --> L[volatile 读]
L -- LoadLoad + LoadStore 屏障 --> B1[后续普通读/写]- volatile 写之前插 StoreStore:保证前面的普通写不会被重排到 volatile 写之后
- volatile 写之后插 StoreLoad:保证这个写对所有后续读可见(这也是写比读贵的原因,StoreLoad 在 x86 上对应
lock addl,代价接近完整内存栅栏) - volatile 读之后插 LoadLoad + LoadStore:后续读写都不能提前到这次读之前
另外还有一个重要的派生规则——happens-before:volatile 写 happens-before 后续对同一变量的读。结合传递性,写之前的所有普通写也能被读之后的线程看到。这就是"一次性发布"能成立的根基:把对象写进 volatile 字段前赋好的值,读到的线程都能看到完整状态。
DCL 单例:为什么要 volatile
volatile 最著名的应用场景是双重检查锁(DCL)单例。先看标准写法:
java
public class Singleton {
private static volatile Singleton instance; // 去掉 volatile 就是隐患版
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:无锁快路径
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:拿锁后再确认
instance = new Singleton();
}
}
}
return instance;
}
}问题出在 instance = new Singleton() 这一行。它不是原子的,大体分三步:
- 分配内存
- 调用构造函数初始化对象
- 把 instance 指向那块内存
其中 2 和 3 可能被重排成 1→3→2。如果线程 A 执行到 3 之后、2 之前被切走,线程 B 在第一次检查时看到 instance 非 null,直接返回——拿到的是一个字段还没初始化的半成品对象,用起来就是各种诡异 NPE。用状态图看这段时序:
mermaid
sequenceDiagram
participant A as 线程A(持锁)
participant B as 线程B(无锁)
A->>A: 1. 分配内存
A->>A: 3. instance 指向内存(重排后先执行)
Note over B: 第一次检查 instance != null,直接返回
B-->>B: 拿到未执行构造函数的对象 💥
A->>A: 2. 执行构造函数(晚了)加 volatile 后,对 instance 的写有 StoreStore 屏障兜底:构造函数的写完成之前,instance 的赋值不能对外可见,B 线程不可能读到半初始化状态。注意 volatile 在这里买的不是可见性而是有序性——很多人以为 DCL 加 volatile 是怕 null 值不可见,其实锁本身已经保证了可见性,真正缺的是禁止 2、3 重排。
什么时候该用 volatile:场景清单与边界
volatile 适合"一写多读、单变量状态"的场景,逐个过一遍:
- 状态标志位:一个线程置位、其他线程只读。比如后台任务的 stop 标志,没有 volatile 的话工作线程可能永远读不到 true,循环退不出去。这是最典型、最安全的使用。
- 一次性发布(one-time publication):配置对象在启动时构建一次、之后只读。用 volatile 引用发布,配合 happens-before,读线程能看到构造时的全部状态。注意前提是对象之后不可变或不会再被并发修改,否则只保证"看到完整初始版",后续修改的原子性 volatile 管不了。
- 双读双写(独立观察):多个线程写同一个 volatile 变量的不同观测值、读方取"最新即可"。比如多个温度传感器各自
latestTemp = 读数;,展示端只读最后一个。能用的前提是写竞争不会造成逻辑错误——丢一次中间值无所谓。 - 边界之外:任何复合操作(i++、check-then-act、先读再写依赖旧值)都不行;多个 volatile 变量之间的一致性约束也不行——A、B 两个 volatile 字段要"同时变",volatile 保证不了,得回到锁或
AtomicReference打包成不可变对象。
判断口诀:"写的动作本身不依赖当前值、且新值一旦写定就有效"的变量,才配得上 volatile。
动手实操
把上面两个关键点跑一遍。先是状态标志位(可见性的正确用法):
java
public class VolatileFlagDemo {
// 换成普通 boolean 试试:循环大概率退不出来(JIT 把 run 提升为本地缓存)
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long count = 0;
while (running) { // volatile 读,每次都拿最新值
count++;
}
System.out.println("worker stopped, looped " + count);
});
worker.start();
Thread.sleep(1000);
running = false; // volatile 写,1 秒后通知 worker
worker.join();
}
}再把 DCL 的半初始化问题用代码"看到"——用一个故意暴露中间状态的对象模拟:
java
public class HalfInitDemo {
static class Config {
int a = 0;
Config() {
a = 42; // 构造函数里的赋值
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
}
}
// 去掉 volatile 后多跑几次,B 线程可能打出 a=0(读到构造未完成的引用)
private static volatile Config config;
public static void main(String[] args) throws InterruptedException {
Thread a = new Thread(() -> {
synchronized (HalfInitDemo.class) {
if (config == null) config = new Config();
}
});
Thread b = new Thread(() -> {
while (config == null) { } // 自旋等引用非空
System.out.println("B sees a = " + config.a);
});
a.start(); b.start();
a.join(); b.join();
}
}用 java -XX:+PrintCompilation 观察 JIT 编译日志,配合 -Xint(纯解释执行,无 JIT 优化)对比 FlagDemo 的行为差异,能直观感受到可见性问题是由编译器/缓存优化引入的,不是 Java 语义的 bug。
常见误区与小结
- 误区一:volatile 让 i++ 线程安全。 不,i++ 是读-改-写三步,只保证可见挡不住交错,该用 AtomicInteger。
- 误区二:DCL 加 volatile 是为了 null 可见。 锁已保证可见性,volatile 买的是禁止"赋值先于构造"的重排。
- 误区三:两个 volatile 变量能保持一致。 不能,各自独立可见,跨变量不变式需要锁或打包成单个不可变对象。
- 误区四:volatile 比锁快所以处处用它替锁。 语义不同不可替换;且 StoreLoad 屏障并不免费,热点路径上 volatile 写的开销是普通写的数倍。
小结:volatile 是 JMM 面向程序员的轻量工具,理解它的正道是记住"可见 + 有序、不原子"六个字,再看内存屏障如何兑现承诺、happens-before 如何传导可见性。它在学习路径上位于 JMM 之后、AQS 之前,是读懂无锁结构(如 AQS 的 state)的 prerequisite。下一篇进入 AQS 源码走读,看 JUC 如何用 volatile + CAS 搭出整套同步器骨架。
参考
- Java 语言规范 JLS §17.4:Java Memory Model(happens-before 与 volatile 语义的权威定义)
- Doug Lea, The Java Language Specification 同步章节 / JSR-133 FAQ(中文译版流传较广,重排例子很直观)
- OpenJDK HotSpot 源码
orderAccess系列(各平台内存屏障的落地实现)