一个关键字,三种承诺
volatile 大概是 Java 里被误用最多的关键字。它只有三个词的说明——「可见、有序、不原子」——但每个词都要配上具体的字节码、平台实现和反例才能说得清。
这一节按三个问题展开:
- 它在字节码里长什么样?
- 它禁止了哪些重排,靠什么手段?
- 它到底不保证什么?
第 1 问的答案会让人意外:字节码里完全看不到 volatile 的痕迹。第 2 问的答案取决于 CPU 架构。第 3 问的答案决定了绝大多数 volatile 误用的根源。
先把结论列出来,后面逐条给证据:
| 承诺 | volatile 是否提供 | 典型用途 |
|---|---|---|
| 写后对其它线程可见 | 是 | 状态标志、停止信号 |
| 禁止与前后指令重排 | 是 | DCL 单例、发布不可变对象 |
| 复合操作的原子性 | 否 | 计数、累加、check-then-act |
| 引用所指对象内部字段的一致性 | 否 | 缓存、快照对象 |
字节码里没有 volatile
写一个最小类,把 volatile 字段和普通字段放在一起:
public class VolatileFlags {
private volatile boolean running = true;
private int plain;
public void setPlain(int v) { this.plain = v; }
public void setRunning(boolean r) { this.running = r; }
public int getPlain() { return plain; }
}
用 javap -c -p 看这两个 setter:
public void setPlain(int);
Code:
0: aload_0
1: iload_1
2: putfield #13 // Field plain:I
5: return
public void setRunning(boolean);
Code:
0: aload_0
1: iload_1
2: putfield #7 // Field running:Z
5: return
两条字节码完全一样,都是 aload_0 / iload_1 / putfield,没有屏障指令,没有特殊 opcode。volatile 的信息不在指令里,而在字段的访问标志上:
private volatile boolean running;
descriptor: Z
flags: (0x0042) ACC_PRIVATE, ACC_VOLATILE
private int plain;
descriptor: I
flags: (0x0002) ACC_PRIVATE
ACC_VOLATILE 的值是 0x0040,与 ACC_PRIVATE 的 0x0002 相或就是 0x0042。JVM 在解释执行时读这个标志决定要不要走内存屏障路径;在 JIT 编译时读这个标志决定要不要在生成的机器码里插屏障。
这解释了一个常见困惑:为什么用反编译工具看 volatile 字段的赋值,看不出它和普通赋值有什么区别。因为差异在语义层和执行层,不在指令层。
屏障插在哪里
HotSpot 定义了四种内存屏障,volatile 的读写会按规则在前后插入:
| 屏障 | 作用 |
|---|---|
| LoadLoad | 保证前面的读先于后面的读完成 |
| StoreStore | 保证前面的写先于后面的写完成 |
| LoadStore | 保证前面的读先于后面的写完成 |
| StoreLoad | 保证前面的写先于后面的读完成,最重的屏障 |
一个 volatile 写之前插 StoreStore,之后插 StoreLoad;一个 volatile 读之后插 LoadLoad 和 LoadStore。
对应到「volatile 写不能和前面的普通写重排」,就是 StoreStore 在起作用;「volatile 读不能和后面的普通读重排」,就是 LoadLoad。
屏障在 CPU 层面未必是真实指令。x86 是强内存模型,只有 StoreLoad 需要 mfence 或带 lock 前缀的指令,其余三种在 x86 上都是空操作。ARM 是弱内存模型,需要 dmb 系列指令。所以同一段 Java 代码在 M1 和 x86 服务器上生成的指令数不同,语义却一致——这是 JMM 承诺的。
本机没有安装 hsdis,-XX:+PrintAssembly 拿不到反汇编,所以这里不贴机器码。想自己验证,需要先把 hsdis 库放进 $JAVA_HOME/lib/server/,再加 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 运行。没有 hsdis 时更可靠的验证方式是用 JCStress 做行为级测试——语义不依赖具体指令,行为测试才是判据。
可见性:为什么循环停不下来
第 3.1 节演示过没有 volatile 的循环停不下来的现象。这里补上它为什么发生。
public class SpinWait {
private static boolean running = true;
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() -> {
while (running) {
// 空转
}
System.out.println("stopped");
});
worker.start();
Thread.sleep(100);
running = false;
}
}
主线程已经写了 running = false,worker 线程却可能永远看不到。原因有两层:
- JIT 提升:编译器发现循环体内没有对
running的写,可以把它读一次存进寄存器,循环条件退化为「检查寄存器」。 - 缓存:即使每次重新读,读取也可能命中 CPU 缓存,而写方的更新还在写缓冲里没刷出去。
JMM 不允许编译器做第一种优化吗?允许。因为程序里没有任何同步点,编译器可以假设没有其它线程会改这个字段。volatile 的作用就是告诉编译器「这个字段随时可能被外部改变,不许缓存它的值」。
加上 volatile 后现象消失,不是因为「每次都读主存」(现代 CPU 没有主存这个概念),而是因为屏障禁止了寄存器提升,并且缓存一致性协议保证写方的更新最终对所有核可见。
有序性:DCL 为什么需要 volatile
双重检查锁(DCL)是最经典的 volatile 应用场景:
public final class Config {
private static Config instance;
private final Map<String, String> values;
private Config() {
this.values = load();
}
public static Config getInstance() {
if (instance == null) { // 第一次检查
synchronized (Config.class) {
if (instance == null) { // 第二次检查
instance = new Config(); // 危险点
}
}
}
return instance;
}
}
instance = new Config() 不是一步操作,它至少拆成三步:
- 分配内存;
- 调用构造器初始化字段(
values被赋值); - 把引用写到
instance。
第 2 步和第 3 步之间没有数据依赖,编译器和 CPU 都可以把它们重排成 1-3-2。此时另一个线程在第一次检查处看到 instance != null,直接返回这个对象,然后访问 instance.values——拿到的是还没有初始化的 null。
注意这个错误在什么样的机器上都可能发生,而且极难复现。x86 的 StoreStore 屏障语义会让 2 和 3 的重排在多数情况下不出现,所以很多人在 x86 上写了十年 DCL 也没出过问题。换到 ARM 服务器,或者换个 JIT 版本,问题就冒出来了。
修复是把 instance 声明成 volatile:
private static volatile Config instance;
volatile 写之前的 StoreStore 屏障保证构造器的写先于引用写完成;volatile 读之后的 LoadLoad 屏障保证读到引用后,构造器写入的字段也已经可见。
更简单的写法是静态持有者:
private static final class Holder {
static final Config INSTANCE = new Config();
}
public static Config getInstance() {
return Holder.INSTANCE;
}
类初始化由 JVM 保证线程安全,且延迟到首次访问。前面 VolatileFlags.dclStyle 的字节码就是这种写法:
public void dclStyle();
Code:
0: getstatic #17 // Field Holder.INSTANCE:LHolder;
3: astore_1
4: return
只有一条 getstatic,没有任何同步指令。类初始化的锁由 JVM 在 getstatic 内部处理,字节码里看不见。
原子性:volatile 计数器为什么还丢更新
这是最重要的一条边界。volatile int count; count++; 看起来「每次都读写最新值」,实际上 count++ 是三步:
- 读
count的当前值; - 加一;
- 写回。
volatile 只保证第 1 步读到的是最新的、第 3 步写完立刻可见。它不能让第 1 步到第 3 步变成一个不可分割的整体。两个线程可以同时读到 100,各自加一,各自写回 101。
实测。8 个线程各做 10 万次自增,期望 80 万:
static volatile int vCount;
static int plainCount;
static int syncCount;
// 三种实现分别在 8 个线程里跑 100_000 次
run("volatile", () -> vCount++);
run("plain", () -> plainCount++);
run("synchronized", VolatileCounter::syncInc);
本机 JDK 25 上连跑三次的结果:
volatile -> 132754 (expect 800000), 38 ms
plain -> 138596 (expect 800000), 4 ms
synchronized -> 800000 (expect 800000), 106 ms
---
volatile -> 127606 (expect 800000), 37 ms
plain -> 188270 (expect 800000), 4 ms
synchronized -> 800000 (expect 800000), 131 ms
---
volatile -> 126739 (expect 800000), 36 ms
plain -> 214327 (expect 800000), 4 ms
synchronized -> 800000 (expect 800000), 29 ms
三个结论:
volatile计数器只跑到 12.7 万~13.3 万,丢了八成以上的更新。它比普通int好看,纯粹是因为每次写都要过屏障,线程之间的时间窗口错开了,碰撞变少;这属于「错得少一点」,不是「对」。- 普通
int每次跑出来的数都不一样,进一步说明结果完全不可预测。 synchronized三次都是准确的 800000。
耗时数据只能看数量级,不能当基准:本机当时系统负载很高(load averages 在 70 以上),微秒级差异不可信。但正确性结论与负载无关。
这就是为什么计数器要用 AtomicInteger、AtomicLong 或 LongAdder,而不是 volatile。AtomicInteger.incrementAndGet 用一次 CAS 把读-改-写合成一个原子操作;LongAdder 在高竞争下进一步分片。第 8 章会展开 LongAdder 的 Cell 结构。
三种「看起来是原子」的错觉
错觉一:volatile 的 check-then-act
if (!initialized) { doInit(); initialized = true; },initialized 声明为 volatile。两个线程可以同时通过 !initialized 检查,doInit() 执行两次。volatile 保证读写可见,不保证检查和赋值之间没有别人插进来。正确写法是 CAS:if (compareAndSet(false, true)) { doInit(); },或者用 synchronized。
错觉二:volatile 引用不代表内容安全
private volatile List<String> cache = new ArrayList<>();
public void add(String s) {
cache.add(s); // 写的是 ArrayList 内部数组,不是 volatile 字段
}
volatile 修饰的是「cache 这个引用」,不是「cache 指向的 ArrayList」。往列表里加元素修改的是对象内部字段,没有任何 volatile 语义。多线程 add 依然会坏。
要让「引用 + 内容」一起安全,得让内容不可变,用整体替换代替原地修改:先复制旧列表、追加新元素,再用一次 volatile 写把新引用发布出去。写时复制(CopyOnWrite)就是这个思路,第 6 章会讲它的开销。
错觉三:volatile 数组元素不是 volatile
private volatile String[] slots = new String[16];
public void put(int i, String v) {
slots[i] = v; // 数组元素没有 volatile 语义
}
volatile 修饰数组引用,slots[i] 的读写是普通读写。要元素级语义得用 AtomicReferenceArray 或 VarHandle 的数组视图。
这三条错觉的共同点:volatile 的作用范围是「字段本身」,不会传播到字段指向的对象内部。
该用什么:四选一的判断表
| 需求 | 首选 | 备选 | 不选 |
|---|---|---|---|
| 状态标志、停止信号 | volatile boolean | AtomicBoolean | 普通 boolean |
| 单例发布 | volatile + DCL 或静态持有者 | 枚举 | 无 volatile 的 DCL |
| 计数器 | LongAdder | AtomicLong | volatile int |
| 引用整体替换 | volatile + 不可变对象 | AtomicReference | volatile + 可变对象 |
| 需要多个字段一起变 | synchronized 或不可变快照 | AtomicReference<Snapshot> | 多个 volatile 字段 |
| 有竞争的长临界区 | ReentrantLock | synchronized | 自旋 CAS |
最后一行值得单独说明。两个 volatile 字段 a、b 在同一个方法里依次赋值,读方可能看到新的 a 和旧的 b——两次 volatile 写之间不是原子的。要「要么都看到新的,要么都看到旧的」,得把两个值打包进一个不可变对象,用单个 volatile 引用发布。这是「快照式更新」的基本手法。
在 libraryd-conc 里落地
借阅服务的生命周期状态用 volatile 就够,因为它只需要「可见的开关」:
public final class ServiceState {
private volatile boolean running = true;
private volatile boolean draining;
public boolean isRunning() {
return running;
}
public void startDrain() {
draining = true; // 先置排空标志
running = false; // 再停止接收
}
public boolean isDraining() {
return draining;
}
}
这里有两个 volatile 字段,顺序有讲究:先 draining = true 再 running = false。因为 StoreStore 屏障保证两个写的顺序,读方看到 running == false 时一定能看到 draining == true。反过来写就会出现「已停止但没标记为排空」的中间态。
这正是「volatile 写之间有序」的一个正当用法。但要注意,它依赖的是写方的顺序保证;写方如果是多条线程并发改这两个字段,顺序就没了。
书目缓存则用「不可变快照 + volatile 引用」:
public final class CatalogCache {
private volatile Map<String, Book> snapshot = Map.of();
public Book find(String isbn) {
return snapshot.get(isbn); // 无锁读,读到的是一份完整快照
}
public void reload(Map<String, Book> fresh) {
snapshot = Map.copyOf(fresh); // 整体替换
}
}
读方永远拿到一份完整的、不可变的 Map,不需要加锁。写方用 Map.copyOf 保证发布出去的对象不会再被修改。这个模式在配置热更新、路由表刷新里很常见。
常见误用清单
| 写法 | 问题 | 修复 |
|---|---|---|
volatile int i; i++; | 复合操作非原子 | AtomicInteger / LongAdder |
if (!flag) { work(); flag = true; } | check-then-act | CAS 或加锁 |
volatile List 后直接 add | 修饰引用不修饰内容 | 不可变对象整体替换 |
volatile String[] a; a[i] = v | 元素无 volatile 语义 | AtomicReferenceArray |
多个 volatile 字段表示一个状态 | 组合不可原子 | 打包成不可变快照 |
DCL 里漏写 volatile | 引用可能先于构造完成可见 | 加 volatile 或改静态持有者 |
用 volatile 替代锁保护长临界区 | 只保证可见,不保证互斥 | 用锁 |
以为 volatile 每次都读「主存」 | 现代 CPU 无此机制 | 理解屏障语义 |
小结
volatile在字节码里体现为ACC_VOLATILE标志,指令与普通字段赋值完全相同;语义由 JVM 和 JIT 在屏障层面实现。- 它提供可见性和禁止重排,不提供复合操作的原子性。8 线程实测里 volatile 计数器只跑到期望值的 16% 左右。
- DCL 必须用
volatile,因为引用写可能先于构造器完成;静态持有者类更简单。 volatile的作用范围只到字段本身,不会传播到指向对象的内部,也不会传播到数组元素。- 需要多个字段一起变,用不可变快照或锁;需要原子累加,用
AtomicLong或LongAdder。
下一节拆 synchronized 的字节码,看监视器是怎么在指令层面进入和退出的。
阅读导航:上一节 3.3 final 字段与安全发布 | 下一节 4.2 synchronized 的字节码与对象头
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。