volatile 到底能保证什么?JMM 与内存屏障深度剖析
面试常考:volatile 可见性、有序性、不保证原子性、内存屏障、happens-before 规则
一、从一个诡异 Bug 说起
java
public class VolatileDemo {
private static boolean running = true; // 没有 volatile
public static void main(String[] args) throws Exception {
new Thread(() -> {
while (running) {
// 空循环,JIT 优化后可能永远读不到 running 的变化
}
System.out.println("线程退出");
}).start();
Thread.sleep(1000);
running = false; // 主线程修改,子线程感知不到
System.out.println("主线程设置 running=false");
}
}
运行这段代码,大概率子线程永远不会退出 。加上 volatile 就好了。为什么?
二、Java 内存模型 (JMM)
JMM 是一种抽象模型,定义了线程和主内存之间的关系:
css
┌──────────────────────────────────────────────────────────────┐
│ JMM 抽象模型 │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────┐ │
│ │ │ │ 主内存 │ │
│ │ 线程A │ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ ┌─────────┐ │ │ │ var1 │ │ var2 │ │ var3 │ │ │
│ │ │工作内存 │ │◀───│ └──────┘ └──────┘ └──────┘ │ │
│ │ │ (副本) │ │读/写│ │ │
│ │ └─────────┘ │ └───────────────────────────────┘ │
│ └─────────────┘ ▲ │
│ ▲ │ │
│ │ ┌─────┴──────┐ │
│ ┌─────┴────┐ │ │ │
│ │ │ ┌────▼───┐ ┌────▼───┐ │
│ │ ┌──────┐ │ │ 线程B │ │ 线程C │ │
│ │ │副本 │ │ │┌──────┐│ │┌──────┐│ │
│ │ └──────┘ │ ││副本 ││ ││副本 ││ │
│ └──────────┘ │└──────┘│ │└──────┘│ │
│ └───────┘ └───────┘ │
└──────────────────────────────────────────────────────────────┘
每个线程有自己的工作内存(CPU 缓存/寄存器的抽象),线程不能直接读写主内存,必须通过工作内存中转。这导致了两个核心问题:
- 可见性:线程A修改了共享变量,线程B 何时能看到?
- 有序性 :编译器和 CPU 为了优化会重排序指令,其他线程看到的执行顺序可能和源码不同
三、volatile 的两大语义
1. 保证可见性
volatile 变量的写操作立即刷新到主内存 ,读操作强制从主内存重新加载,绕过 CPU 缓存。
java
public class VolatileVisibility {
private volatile boolean running = true;
public void stop() {
running = false; // 写: 立即刷到主内存,其他线程立即可见
}
public void run() {
while (running) {
// 读: 每次都从主内存重新加载
// JIT 不会优化掉这个读操作
}
System.out.println("停止");
}
}
底层机制 :在 x86 架构上,volatile 写会生成 lock 前缀指令,该指令会:
- 将当前 Store Buffer 刷新到 L1 Cache
- 通过 MESI 缓存一致性协议使其他 CPU 的缓存行失效
- 其他 CPU 读取时必须从主内存重新加载
2. 保证有序性(禁止重排序)
volatile 禁止编译器和 CPU 对相关指令进行重排序------但不是禁止所有重排序,而是通过内存屏障来约束。
3. 不保证原子性
java
public class VolatileAtomicDemo {
private volatile int count = 0;
public void increment() {
count++; // 不是原子操作!
}
public static void main(String[] args) throws Exception {
VolatileAtomicDemo demo = new VolatileAtomicDemo();
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
pool.submit(() -> {
for (int j = 0; j < 1000; j++) {
demo.increment();
}
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println(demo.count); // 结果远小于100000
}
}
count++ 是三步操作:读→加→写。volatile 只保证每一步可见,但读-改-写不是原子的。
ini
count++ 字节码:
1. getfield count ← 读 (volatile read)
2. iconst_1
3. iadd ← 加
4. putfield count ← 写 (volatile write)
线程A: 读count=0
线程B: 读count=0 ← 都读到0
线程A: 写count=1
线程B: 写count=1 ← 覆盖,丢失一次自增
四、内存屏障
内存屏障是硬件层面的指令,约束编译器和 CPU 的重排序行为。
四种屏障
| 屏障类型 | 指令 | 作用 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | Load2 不能重排到 Load1 之前 |
| StoreStore | Store1; StoreStore; Store2 | Store2 不能重排到 Store1 之前 |
| LoadStore | Load1; LoadStore; Store2 | Store2 不能重排到 Load1 之前 |
| StoreLoad | Store1; StoreLoad; Load2 | Load2 不能重排到 Store1 之前(开销最大) |
volatile 写/读的屏障插入策略
JMM 的 volatile 写屏障策略:
arduino
┌─────────────────────┐
│ volatile write │
├─────────────────────┤
前面普通写 ──▶│ StoreStore 屏障 │ ← 禁止前面的普通写与 volatile 写重排
│ volatile write 执行 │
│ StoreLoad 屏障 │ ← 禁止后面的读与 volatile 写重排
├─────────────────────┤
│ 后续操作 │
└─────────────────────┘
JMM 的 volatile 读屏障策略:
arduino
┌─────────────────────┐
│ volatile read │
├─────────────────────┤
│ volatile read 执行 │
│ LoadLoad 屏障 │ ← 禁止后面的读与 volatile 读重排
后续普通读 ──▶│ LoadStore 屏障 │ ← 禁止后面的写与 volatile 读重排
├─────────────────────┤
│ 后续操作 │
└─────────────────────┘
经典案例:双重检查锁定 (DCL)
java
public class Singleton {
// 必须加 volatile!
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 非原子操作!
}
}
}
return instance;
}
}
为什么必须加 volatile?
instance = new Singleton() 分为三步:
ini
1. 分配内存空间 memory = allocate()
2. 初始化对象 ctorInstance(memory)
3. 将引用指向内存地址 instance = memory
如果重排序为 1→3→2:
线程A: 1. 分配内存
线程A: 3. instance = memory (但对象还没初始化!)
线程B: if(instance != null) return instance ← 拿到未初始化的对象!
volatile 写的 StoreStore 屏障禁止 2 和 3 重排
即初始化完成后才赋值给 instance
五、happens-before 规则
JMM 用 happens-before 关系来定义操作之间的可见性保证。如果一个操作 happens-before 另一个操作,那么前者的结果对后者可见。
八大规则
| 规则 | 描述 |
|---|---|
| 程序顺序规则 | 线程中每个操作 happens-before 该线程的任意后续操作 |
| 监视器锁规则 | 一个锁的 unlock happens-before 后面对同一把锁的 lock |
| volatile 变量规则 | volatile 写 happens-before 后面对该变量的读 |
| 传递性规则 | A happens-before B,B happens-before C → A happens-before C |
| 线程启动规则 | Thread.start() happens-before 该线程的任何操作 |
| 线程终止规则 | 线程的所有操作 happens-before Thread.join() 返回 |
| 线程中断规则 | Thread.interrupt() happens-before 被中断线程检测到中断 |
| 对象终结规则 | 构造函数执行完 happens-before finalize() 方法 |
应用示例
java
// 利用 happens-before 规则保证可见性
public class HappensBeforeDemo {
private int a = 0;
private volatile boolean flag = false;
// 线程A
public void writer() {
a = 1; // 1. 普通写
flag = true; // 2. volatile 写
}
// 线程B
public void reader() {
if (flag) { // 3. volatile 读
int i = a; // 4. 普通读,i 一定等于 1!
}
}
}
推导过程:
ini
1. a=1 happens-before flag=true (程序顺序规则)
2. flag=true happens-before 读flag (volatile规则)
3. 读flag happens-before 读a (程序顺序规则)
4. 传递: a=1 happens-before 读a → 读a 能看到 a=1
即使 a 不是 volatile,也保证了可见性!
这就是 volatile 的"附带效果"------它前面的写对后面的读可见。
六、volatile 的典型应用场景
1. 状态标志位
java
private volatile boolean shutdown = false;
public void run() {
while (!shutdown) {
doWork();
}
}
public void shutdown() {
shutdown = true;
}
2. DCL 单例
java
private static volatile Singleton instance;
3. 轻量级发布
java
// 利用 volatile 写的 happens-before 语义
class ConfigHolder {
private volatile Config config;
public void updateConfig(Config newConfig) {
this.config = newConfig; // 之前的赋值都对读线程可见
}
}
4. 不适用场景:复合操作
java
// 错误用法:volatile 不能替代锁
private volatile int count;
public void increment() { count++; } // 不安全!
// 正确做法1: AtomicInteger
private AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); }
// 正确做法2: synchronized
private int count;
public synchronized void increment() { count++; }
七、volatile vs synchronized
| 维度 | volatile | synchronized |
|---|---|---|
| 可见性 | ✅ | ✅ |
| 原子性 | ❌ | ✅ |
| 有序性 | ✅ (禁止重排) | ✅ (锁的串行化) |
| 阻塞 | 否 | 是 |
| 粒度 | 变量级 | 对象/代码块级 |
| 性能 | 高 | 相对低 |
| 编译优化 | 禁止相关重排序 | 锁块内可优化 |
| 可重入 | N/A | 可重入 |
八、面试高频问题速答
Q1: volatile 能保证原子性吗?
不能。volatile int i; i++ 不安全,因为 i++ 是读-改-写三步操作,volatile 只保证每一步的可见性,不保证三步整体的原子性。但 volatile 对单次读或单次写是原子的(如 boolean flag = true)。
Q2: volatile 底层是怎么实现的?
在 x86 上,volatile 写生成带 lock 前缀的汇编指令,作用是:(1) 将 Store Buffer 刷新到缓存;(2) 通过 MESI 协议使其他 CPU 的缓存行失效。JMM 层面通过插入 StoreStore/StoreLoad/LoadLoad/LoadStore 内存屏障禁止重排序。
Q3: 为什么 DCL 单例必须加 volatile?
new 操作可能被重排序:分配内存→赋值引用→初始化对象。如果赋值在前,其他线程可能拿到未初始化的对象。volatile 的 StoreStore 屏障保证初始化完成后才赋值。
Q4: i++ 为什么 volatile 解决不了?
i++ 等价于 i = i + 1,包含读 i、加 1、写回 i 三个操作。两个线程可能同时读到相同的值,各自加 1 后写回,丢失一次自增。需要用 AtomicInteger(CAS 保证原子性)或 synchronized。
Q5: happens-before 和 as-if-serial 有什么区别?
as-if-serial 保证单线程内程序执行结果不被重排序影响。happens-before 是跨线程的可见性保证。as-if-serial 只关心正确性,happens-before 关心跨线程可见性。
Q6: volatile 数组能保证元素可见性吗?
不能。volatile 只保证数组引用的可见性,不保证数组元素修改的可见性。修改 arr[i] 不会触发 volatile 语义。要用 AtomicIntegerArray。
java
volatile int[] arr = new int[10];
arr[0] = 1; // 其他线程不保证可见
// 应该用 AtomicIntegerArray
AtomicIntegerArray arr = new AtomicIntegerArray(10);
arr.set(0, 1); // 保证可见
九、总结
arduino
volatile 核心保障:
┌─────────────────────────────────────────────────┐
│ │
│ 可见性: lock前缀指令 + MESI缓存一致性 │
│ 有序性: 内存屏障 (StoreStore, StoreLoad, │
│ LoadLoad, LoadStore) │
│ 原子性: ✗ (只保证单次读/写,不保证复合操作) │
│ │
│ 应用场景: │
│ ├── 状态标志 (boolean running) │
│ ├── DCL 单例 (防止 new 重排序) │
│ ├── 轻量级发布 (happens-before 级联可见性) │
│ └── 不适合: 计数器、复合状态修改 │
│ │
│ happens-before 传递性: │
│ volatile写之前的所有写 → volatile读之后可见 │
│ │
└─────────────────────────────────────────────────┘
volatile 不是万能药,它是 JMM 提供的"轻量级同步工具"。理解内存屏障和 happens-before 规则,才能在正确的场景下正确使用它------既不会该用 volatile 的地方误用 synchronized 导致性能浪费,也不会该用锁的地方只加了 volatile 导致并发 Bug。