volatile 关键字的作用和原理是什么?
简化版
volatile 为单个变量提供可见性与特定有序性:对它的写 happens-before 随后读取到该写的 volatile 读,可用于状态发布和 DCL 引用。它不会把 count++ 这类读—改—写变成原子操作,也不是一把互斥锁。JMM 规定语义,JIT 再用目标 CPU 的内存屏障或带顺序保证的指令实现,不能简单等同于“每次强制读写物理主内存”。
详细版
volatile 写与后续 volatile 读建立 happens-before,再结合程序顺序和传递性,可以安全发布写之前完成的普通字段更新。所有线程还会以一致的同步顺序观察同一个 volatile 变量的读写。
它约束编译器与处理器不能执行会破坏这套语义的重排序,但不会禁止所有优化。单次 volatile 读写具有相应原子与可见效果,复合表达式仍由多个动作组成:两个线程都读到 0,再各写 1,最终就可能只有 1。
适合场景包括停止标志、不可变配置快照发布和正确 DCL;需要复合不变量、累加扣减或 check-then-act 时,应使用锁或原子类。
完整版教学
一、可见性不是“立刻刷新”的墙上时钟承诺
JMM 允许线程和编译器采用寄存器、缓存与代码优化,只要最终行为符合内存模型。普通变量存在数据竞争时,另一个线程没有同步边就不能推导何时看到写入,甚至循环读取可能被优化成重复使用旧值。
boolean running = true;
// T1: while (running) { doWork(); }
// T2: running = false;
这里没有 happens-before 链,程序本身就有数据竞争。把 running 声明为 volatile 后,T2 的 volatile 写与 T1 后续 volatile 读建立同步关系;它保证内存语义,而不是承诺“在现实时间 1 纳秒内被调度并停止”。
二、volatile 写怎样发布前面的普通写
线程 A 先构造配置并写普通字段,再执行 ready=true 的 volatile 写;线程 B 读到 ready=true 后读取配置。程序顺序、volatile 规则和传递性把这些动作串成完整的 happens-before 链。
A: 写 config 字段 → volatile 写 ready
↓ happens-before
B: volatile 读 ready → 读取 config 字段
因此 volatile 常用来发布已经构造完成、之后不再随意改变的对象快照。如果构造期间先把 this 泄露出去,或发布后多个线程继续无同步修改对象内部字段,仅给引用加 volatile 也不能修复所有竞态。
三、为什么 count++ 仍会丢更新
count++ 至少包含读旧值、计算加一、写回三个逻辑动作。假设 volatile count 初始为 0,两个线程可能发生如下交错:
| 时刻 | 线程 A | 线程 B | count |
|---|---|---|---|
| T1 | 读到 0 | 0 | |
| T2 | 读到 0 | 0 | |
| T3 | 写回 1 | 1 | |
| T4 | 写回 1 | 1 |
2 个线程执行了 2 次自增,期望值为 2,最终却可能只有 1。每次 volatile 读写都可见并不矛盾,因为缺失的是整个读—改—写序列的原子性;应改用 AtomicInteger.incrementAndGet() 或锁保护。
四、DCL 为什么需要 volatile
双重检查锁把无锁快速路径和初始化时的同步结合起来:
class Singleton {
private static volatile Singleton instance;
static Singleton getInstance() {
Singleton result = instance;
if (result == null) {
synchronized (Singleton.class) {
result = instance;
if (result == null) {
result = new Singleton();
instance = result;
}
}
}
return result;
}
}
没有 volatile 时,引用发布与构造写入之间缺少安全发布保证,另一个线程可能观察到非 null 引用却看不到完整初始化结果。volatile 写把构造期间的写发布出去,后续读到该引用的线程才能依据 happens-before 使用对象。
局部变量 result 还减少了 volatile 字段的重复读取。更简单的单例通常可使用枚举或静态初始化持有者,避免手写 DCL 的复杂度。
五、volatile 约束的是哪些重排序
“禁止指令重排”是过度简化。JMM 只禁止会让其他线程观察到不符合 volatile 语义的重排:volatile 写具有 release 类效果,之前的相关操作不能被挪到发布之后;volatile 读具有 acquire 类效果,后续相关操作不能被挪到读取之前。
普通写不能越过 volatile 写向后逃逸
普通读不能越过 volatile 读向前提取
JIT 会根据 x86、ARM 等目标架构选择屏障或具备相应顺序的指令。不同 CPU 的缓存协议和内存模型不同,所以“固定插入四种屏障”“每次刷回物理主内存”都不是跨平台源码层保证。
记忆钩子:JMM 先规定 happens-before,JIT 和 CPU 再负责实现;面试先讲语义,再讲屏障,别把 MESI 或主内存刷新当成 Java 规范。
六、volatile 与锁、原子类怎样选择
| 需求 | volatile | 原子类 | 锁 |
|---|---|---|---|
| 单一状态标志 | 合适 | 可用但常没必要 | 可用但较重 |
| 单变量原子更新 | 不够 | 合适 | 合适 |
| 多字段不变量 | 不够 | 整体封装后才可能 | 合适 |
| 互斥执行临界区 | 不提供 | 不直接提供 | 提供 |
| 阻塞/条件等待 | 不提供 | 不提供 | Monitor/Condition |
“一个线程写、多个线程读”是常见用法,但不是唯一判据。真正判据是写入是否能作为独立状态发布、是否依赖旧值,以及多个字段之间是否存在必须同时成立的约束。
七、性能成本与伪共享
volatile 不会像互斥锁那样让所有读者串行,但热点写仍会造成缓存行失效和核间通信。若两个互不相关的 volatile 计数恰好位于同一缓存行,两个核心频繁写各自字段也可能产生伪共享。
假设两个线程各更新 1,000 万次独立计数,放在同一缓存行时缓存行所有权会来回转移,吞吐可能显著下降。是否发生及下降多少依赖对象布局、CPU 和 JDK,应用应通过 JMH、硬件计数器或生产剖析验证,不能凭字段相邻就下结论。
只读 volatile 的成本通常低于高频写,但在紧密循环中仍可能限制编译器复用值。优化前先确认它是不是热点以及语义能否安全改变。
八、常见误区与追问
- 误区:volatile 写能保证其他线程在固定时间内立刻运行并看到值。 它提供内存可见性顺序,不控制线程调度和墙上时钟延迟。
- 误区:volatile 会禁止变量周围的所有编译器和 CPU 优化。 它只限制会破坏规定内存语义的重排序。
- 误区:
volatile int count能让count++线程安全。 自增是复合读改写,仍会发生丢更新。 - 追问:volatile 怎样实现安全发布? 写前的操作通过程序顺序和 volatile 写—读规则传递到读线程后续操作。
- 追问:volatile 能保护引用对象内部所有后续修改吗? 不能;发布后可变字段的并发修改仍需各自同步策略。
- 追问:为什么 DCL 的引用必须是 volatile? 它阻止不安全发布,并让构造写入对读到非 null 引用的线程可见。
- 追问:volatile 比 synchronized 一定快吗? 两者语义不同;热点 volatile 写也有一致性成本,不能用它替代必须互斥的临界区。
九、加强记忆
volatile 要按“发布、读取、边界”记:写是 release 类发布,读是 acquire 类获取,二者通过 happens-before 把前面的普通写传递给后续读,因此适合状态标志、配置快照和 DCL 引用。它只保护单次变量访问的规定语义,不把 count++、条件检查或多字段更新变成原子事务。底层屏障由 JIT 按 CPU 架构选择,不能机械解释成每次刷新物理主内存。遇到复合不变量用锁,单变量原子更新用 Atomic 类,热点统计再评估 LongAdder 与伪共享成本。