在多线程编程里,最让人头疼的 bug 往往不是死锁,而是“可见性”:一个线程修改了共享变量,另一个线程却迟迟读不到新值,或者读到了一个“半成品”的值。要彻底理解这类问题,必须搞清楚 Java 内存模型(JMM) 与 volatile 关键字。JMM 定义了多线程环境下读写操作如何变得可见,volatile 则是日常开发中最常用的轻量同步手段——它保证可见性并禁止指令重排,却不保证原子性。本文从可见性故障出发,讲清 happens-before 原则与 volatile 的实战陷阱。
一、可见性问题从哪来
现代 CPU 为了性能,会给每个核心配备独立的高速缓存,并允许编译器和处理器对指令重排序。这在单线程下毫无问题,但在多线程下,一个线程对变量的写入可能滞留在它自己的本地缓存里,尚未刷回主内存,其他线程自然看不到。更隐蔽的是“指令重排”:代码顺序不等于执行顺序。下面这个经典例子,在缺乏同步时可能输出 0、输出 1,甚至可能无限循环。
// 线程 A
ready = true;
value = 42;
// 线程 B
while (!ready) { /* 可能永远看不到 ready=true,死循环 */ }
System.out.println(value); // 可能打印 0
二、JMM 与 happens-before 原则
JMM 并没有要求所有操作立刻全局可见,而是通过“happens-before(先行发生)”关系来约定:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见且 A 的执行顺序排在 B 之前。它是判断一段并发代码是否安全的根本依据。JMM 内置了若干天然的规则,记住它们能省去大量加锁。
2.1 关键 happens-before 规则
| 规则 | 含义 |
|---|---|
| 程序顺序 | 同一线程内,前面的操作 happens-before 后面的操作 |
| volatile 变量 | 对 volatile 的写 happens-before 后续对该变量的读 |
| 锁的释放/获取 | unlock happens-before 后续对同一锁的 lock |
| 线程启动 | Thread.start() happens-before 新线程中的任何操作 |
| 线程终止 | 线程中的所有操作 happens-before 其他线程检测到它结束 |
| 传递性 | 若 A hb B 且 B hb C,则 A hb C |
三、volatile 的两大语义
volatile 提供两个保障:一是可见性,写操作会立即刷回主内存,读操作会从主内存重新加载;二是禁止指令重排,对被修饰变量的读写前后插入内存屏障。双检查锁单例是它最经典的应用——没有 volatile,另一个线程可能拿到一个“构造未完成”的对象。
public class Singleton {
private static volatile Singleton instance;
public static Singleton get() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
四、volatile 不保证原子性
这是最大的误区:volatile 解决不了“读-改-写”的竞态。下面这段在 100 个线程各自增 1000 次后,结果几乎必然小于 100000,因为 i++ 不是原子操作。
private static volatile int count = 0;
// 多个线程并发执行
count++; // 实际是 读取->+1->写回 三步,volatile 无法保证这三步不被打断
// 正确做法
private static final AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 原子自增
五、volatile 与 synchronized、Atomic 怎么选
| 手段 | 可见性 | 原子性 | 适用场景 |
|---|---|---|---|
| volatile | 保证 | 不保证 | 状态标志、一次性发布、读多写少 |
| synchronized | 保证 | 保证 | 复合操作、临界区互斥 |
| Atomic 类 | 保证 | 保证(CAS) | 计数器、高频自增 |
六、实战:用 volatile 做优雅关闭标志
最典型、最安全的用法就是“状态标志”:一个后台线程循环工作,主线程通过修改 volatile 标志通知它退出。volatile 在这里保证了标志的及时可见,又比加锁轻量得多。
private static volatile boolean running = true;
public static void worker() {
while (running) {
doWork();
}
System.out.println("已优雅退出");
}
public static void shutdown() {
running = false; // 其他线程下一轮循环即可看到
}
七、常见误区
误区一:以为 volatile 能替代锁做复合操作。误区二:用 volatile 修饰 64 位 long/double 来解决“字分裂”,其实 Java 5 之后普通 long/double 的读写也已是原子的,根本无需 volatile。误区三:在复合不变式(如“下限小于上限”)场景下只用 volatile,必须用锁或 java.util.concurrent 工具来保证整体一致性。
八、小结
理解 JMM 的 happens-before 是写好并发代码的底层功底,而 volatile 是这把工具箱里最轻巧的一把:它管可见性与重排、不管原子性。当你需要状态标志、一次性安全发布或读多写少的共享变量时,优先考虑 volatile;涉及复合操作时,请转向 synchronized 或 ConcurrentHashMap、线程池与异步编排 这类并发工具。想进一步压榨高并发能力,可以阅读 Java 21 虚拟线程 与 结构化并发实战,把可见性认知一并带入新范式。




