你是不是遇到过这样的诡异 bug:一个线程改了 flag,另一个线程却永远读不到新值,循环怎么都停不下来?这背后不是玄学,而是 Java 内存模型(JMM)在起作用。理解 JMM、volatile 与 happen-before 规则,是写出正确并发代码的地基。
一、JMM 的核心抽象:主内存与本地内存
JMM 并不要求所有变量都放在同一块物理内存里。它抽象出两层:主内存(Main Memory)存放变量的真实值;每个线程在运行时拥有自己的 工作内存(Working Memory / 本地内存),线程读写变量时,其实是先把主内存的值拷贝到本地副本,在副本上操作,最后再把结果写回主内存。
问题就出在这个”拷贝—计算—写回”的间隙。如果线程 A 改了值却还没刷回主内存,线程 B 从主内存读到的就还是旧值——这就是 可见性(Visibility)问题。更隐蔽的是,编译器和 CPU 出于性能会做指令重排序,在单线程下结果不变,但在多线程下可能破坏你预期的执行顺序。
二、一个最经典的可见性陷阱
下面这段看似无害的代码,在 Server 模式(JIT 充分优化)下可能永远停不下来:
public class VisibilityBug {
private boolean running = true;
public void start() {
new Thread(() -> {
while (running) {
// 做点事
}
System.out.println("stopped");
}).start();
}
public void stop() {
running = false; // 主线程改了,工作线程可能永远看不到
}
}
子线程把 running 缓存进了自己的工作内存,主线程对它的修改无法被感知,JIT 甚至会把 while(running) 优化成 while(true)。修复方式之一就是给 running 加 volatile。
三、volatile 到底解决了什么
volatile 在 JMM 层面做两件事:
- 保证可见性:对该变量的写操作会立即刷回主内存,读操作会绕过本地副本、直接从主内存取最新值。
- 禁止指令重排序:在读写前后插入内存屏障,防止编译器和 CPU 的优化打乱”初始化”与”发布引用”的顺序。
但它 不保证原子性。给 volatile int count 做 count++ 依然不安全,因为 ++ 是”读—改—写”三步复合操作,并非不可分割。此外在 Java 5 之前,对 64 位的 long/double 读写可能拆成两个 32 位操作,volatile 从 Java 5 起也顺带保证了其读写原子性。
// 错误示范:volatile 救不了原子性
private volatile int count = 0;
public void unsafe() {
count++; // 仍可能与其他线程的更新互相覆盖
}
// 正确做法:用 AtomicInteger
private final AtomicInteger count = new AtomicInteger(0);
public void safe() {
count.incrementAndGet(); // CAS 保证真正的原子自增
}
四、happen-before:可见性的”秩序法则”
JMM 用 happen-before 关系来定义”什么操作一定对什么操作可见”。只要 A happen-before B,那么 A 的所有修改对 B 一定可见,且 B 不会”看到”重排序后的乱序。几条最常用的规则:
| 规则 | 含义 |
|---|---|
| 程序次序规则 | 同一线程内,靠前的操作 happen-before 靠后的操作 |
| volatile 规则 | 对 volatile 变量的写 happen-before 后续对该变量的任意读 |
| 监视器锁规则 | unlock happen-before 后续对同一锁的 lock |
| 线程启动/终止 | Thread.start() 前的操作 happen-before 新线程的任何操作;线程所有操作 happen-before 其他线程的 join() 返回 |
| 传递性 | 若 A hb B 且 B hb C,则 A hb C |
五、安全发布:用 volatile 修好双重检查锁
单例的”双重检查锁(DCL)”是 volatile 的经典用武之地。instance = new Singleton() 在字节码层面并非原子:先分配内存、再调用构造器初始化、最后把引用赋给 instance。没有 volatile 时,重排序可能让”引用赋值”先于”构造完成”,另一个线程于是拿到一个”半初始化”的对象。
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 与 synchronized 怎么选
| 维度 | volatile | synchronized |
|---|---|---|
| 可见性 | ✅ 保证 | ✅ 保证 |
| 原子性 | ❌ 仅限单一读写 | ✅ 代码块整体原子 |
| 重排序 | ✅ 禁止 | ✅ 隐式保证 |
| 是否阻塞 | 否(无锁) | 是(锁竞争时阻塞) |
| 开销 | 低 | 较高 |
| 适用场景 | 状态标志、安全发布、读多写少 | 复合操作、临界区 |
七、内存屏障:volatile 背后的硬约束
JVM 在 volatile 读写处插入的其实是四类内存屏障(以 JSR-133 语义为准):
- LoadLoad:确保后续读不会重排到当前读之前;
- StoreStore:确保后续写不会重排到当前写之前;
- LoadStore:确保后续写不会重排到当前读之前;
- StoreLoad:最重的一条,确保后续读能看到当前写的结果,也是
volatile写后必须插入的屏障。
正是这些屏障让”写线程的修改”对”读线程”有序可见。代价是它在一定程度上抑制了 CPU 的乱序执行优化,所以 volatile 不应被滥用在高频计数器上。
八、读多写少与状态标志:volatile 的最佳战场
volatile 最合适的使用场景有两个:状态标志(如上面的 running、配置热开关)和读多写少的共享状态(如一个偶尔更新的配置对象引用)。前者靠可见性及时通知,后者靠”一次性写 + 多次安全读”避免每次加锁。
// 配置热更新:写少读多,volatile 比锁更轻量
private volatile AppConfig config = loadConfig();
public AppConfig getConfig() {
return config; // 读无锁,且总能拿到最新一次完整发布
}
public void refresh() {
config = loadConfig(); // 一次性原子发布新引用
}
九、现代并发下的内存可见性
到了 Java 21 虚拟线程 时代,JMM 的可见性规则并没有变,但调度模型变了:海量轻量线程并发运行,共享可变状态的可见性错误会被成倍放大。配合合理的 线程池参数调优 和 结构化并发,把可变状态收敛到明确的边界(如每个任务独占的局部变量),才是根治之道。如果你的”内存问题”表现为堆飙升而非可见性,可参考 Java 内存泄漏排查复盘 定位对象未释放的根因。
十、并发可见性排雷清单
- 状态开关、配置热更新一律用
volatile或AtomicBoolean,别裸用普通布尔; - 复合读写(i++、check-then-act)用锁或原子类,不要迷信
volatile; - 发布对象优先”安全发布”:final 字段、volatile 引用、或锁保护;
- 不要依赖线程优先级或
Thread.sleep()来”碰巧”读到最新值; - 用 jcstress 等专业工具做并发压力测试,别靠肉眼和运气。
可见性陷阱最阴险的地方在于:它”测试环境一切正常,上线半夜暴雷”。把 JMM 和 happen-before 刻进肌肉记忆,再用 volatile/锁/原子类各司其职,你的并发代码才算真正”线程安全”。




