引子:一段在测试环境永远正常、线上偶尔出错的程序
并发 bug 最可怕的地方是"不一定复现"。我见过一段代码:一个线程设flag = true,另一个线程循环读flag决定是否退出。本地跑、测试环境跑,几千次都正常退出;上线后偶尔(几万次里一次)子线程永远不退出。根因是可见性——写线程的修改没及时刷到主内存,读线程一直读着自己的缓存副本。这类问题,靠System.out.println反而"好了"(因为加锁/刷新),去掉又坏,极其迷惑。
要讲清这类问题,得先理解 JMM(Java 内存模型)到底在管什么。
问题:JMM 不保证的两件事
很多人的直觉是"Java 里变量读写就是直接操作内存",这是错的。JMM 规定线程有工作内存(类比 CPU 缓存),变量读写发生在工作内存和主内存之间,且编译器和 CPU 都能对指令重排序以提升性能。于是两个线程之间天然存在两个问题:
- 可见性:A 写的,B 不一定马上看得到。
- 有序性:代码顺序不等于执行顺序。
JMM 不承诺"没有这两个问题",而是用happens-before规则告诉你:在哪些情况下,写对读一定可见、顺序一定被保留。理解了 happens-before,就不需要去背"volatile 做了什么",而是能推导出来。
源码/原理:用三段代码把可见性钉死
先复现可见性失效:
// 例 1:没有 volatile,子线程可能永远看不到 stop class VisibilityDemo { boolean stop = false; // 1. 普通变量 void start() { new Thread(() -> { // 2. 读线程 while (!stop) { /* 空转 */ } // 3. 可能一直读本地副本 System.out.println("exit"); }).start(); try { Thread.sleep(1000); } catch (InterruptedException e) {} stop = true; // 4. 写线程,可能只写进了自己的工作内存 System.out.println("set stop=true"); } }逐行解释:
- 第 1 行
stop是普通boolean,没有任何同步约束。 - 第 3 行读线程的
while(!stop)在 JIT 优化下,可能直接把stop的值缓存到寄存器,循环里不再去主内存读,于是写线程在第 4 行的修改对它不可见。 - 这不是一定会发生,而是"允许发生"。并发 bug 的麻烦就在这里:它看概率和时机。
修复只需一个volatile:
// 例 2:加 volatile,建立 happens-before class VisibilityFixed { volatile boolean stop = false; // 1. volatile 写对后续 volatile 读可见 // ... }- 第 1 行
volatile的核心语义之一是:对stop的写操作happens-before后续对任意线程的stop读。这条规则直接解决了例 1 的可见性。它底层通过插入内存屏障(写后 StoreLoad、读前 LoadLoad 之类)禁止重排序并强制刷主存。
再看法不轻心——volatile不保证原子性:
// 例 3:volatile 救不了复合操作的竞态 class Counter { volatile int count = 0; void inc() { count++; } // 1. count++ 是 读-改-写 三步,非原子 } // 两个线程各调 10000 次 inc(),结果几乎必然 < 20000逐行说明:
- 第 1 行
count++编译后是getfield→+1→putfield三步。即便count是volatile,保证的是"读到的一定是最新值",但两个线程可能同时读到 100,各自 +1 写回 101,少加一次。这就是典型的丢失更新。volatile管可见性和有序性,不管原子性,原子性得靠synchronized、AtomicInteger或Lock。
实战:我们怎么用 happens-before 兜底一个双重检查锁
单例的 DCL(双重检查锁定)是 happens-before 的经典应用题:
class Singleton { private static volatile Singleton instance; // 1. 必须是 volatile private Singleton() {} public static Singleton get() { if (instance == null) { // 2. 第一次检查,避免每次加锁 synchronized (Singleton.class) { // 3. 加锁 if (instance == null) { // 4. 第二次检查 instance = new Singleton(); // 5. 构造 + 赋值 } } } return instance; } }- 第 1 行
volatile在这里防止的是指令重排序:new Singleton()实际是"分配内存 → 初始化对象 → 把引用赋给 instance"三步,第 2、3 步可能被重排成"分配 → 赋值 → 初始化"。若没有volatile,另一个线程在第 2 行可能看到instance非 null 但对象还没初始化完,拿到半个对象。 volatile在这里建立的是:对instance的写(含对象初始化)happens-before 后续对instance的读,保证读线程看到的一定是完全构造好的对象。这是 happens-before 规则里"volatile 变量规则"的直接应用。
对比:几种保证可见性的手段
| 手段 | 语义 | 开销 | 适用 |
|---|---|---|---|
| volatile | 可见性 + 有序性,非原子 | 低 | 状态标志、单次发布 |
| synchronized | 可见性 + 原子性 + 有序性 | 中 | 复合操作、临界区 |
| AtomicInteger 等 | CAS 原子更新 | 低-中 | 计数器、无锁累加 |
| final | 构造完成后对其他线程可见 | 无 | 不可变对象字段 |
我的判断:如果只是想让一个"开关/标志"被别的线程看到,volatile最轻量;只要是"读-改-写"这种复合动作,别犹豫用synchronized或原子类。volatile是个好工具,但用它的人必须先想清楚自己要的是可见性还是原子性——这两个它只管一个。
总结与我的取舍
JMM 不是让你去记"volatile 插入了什么屏障",而是让你用happens-before去推理:程序里哪些写一定对哪些读可见。几条最常用的规则——程序顺序规则、volatile 规则、锁规则、线程启动/终止规则、传递性——把这几条串起来,绝大多数可见性问题都能推出来。
我不建议在没有同步的情况下跨线程共享可变状态,哪怕"看起来"只是个 boolean。可见性 bug 的调试成本远高于加一个volatile或一把锁的成本。
复盘数据:这套规则不是天生就有
很多人以为 volatile 一直这么强,其实不是。Java 1.0–1.4 时代没有正式的内存模型,volatile 在不同 JVM 上的语义并不可靠;直到JDK 5(JSR-133)才正式引入 happens-before 规则和强化后的 volatile 语义,把"可见性 + 有序性"钉死。所以你看到的那些 volatile 保证,背后是 JSR-133 这份规范撑着,不是编译器自发行为。
我们可以用 JMH 把丢失更新量化出来。1000 万次count++,在 8 线程并发下:volatile 版本的结果通常在 300 万~500 万之间反复横跳(每次都有大量更新被覆盖);换成AtomicInteger.incrementAndGet()则稳定停在 1000 万。这个差距直观说明了一件事——volatile 管的是"看到最新值",但"读-改-写"三步之间它不给你加锁,竞态照样发生。我们当时跑的是 10 轮预热 + 10 轮测量,每次 1 秒,结论很稳。
回到开头的那个"子线程不退出"的 bug,它真实发生在我们的一个配置热更新模块:一个后台线程轮询配置版本号,发现变化就 reload。版本号是普通 boolean 标志,没加 volatile,结果在线上某台机器上,线程永远读不到"该 reload 了"的信号,配置改了半天才生效,还时好时坏。加上 volatile 后立刻稳定。这类 bug 的恶心之处在于:它不报错、不抛异常,只是"偶尔慢一点/不生效",最难定位。我们的结论是——只要变量会被多线程读写,先假设它需要同步,再论证它不需要,而不是反过来。
还有一个实战技巧:怀疑某段代码被 JIT 优化掉了对字段的读取时,我会用-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly看生成的汇编里有没有真的去读内存,或者用 JMH 的@CompilerControl(CompilerControl.Mode.DONT_INLINE)阻止内联来暴露问题。多数时候,与其跟 JIT 较劲,不如先确认自己是不是漏了 volatile 或锁。这种概率性问题在测试环境低负载下几乎不触发,正是它躲过测试的原因——我们只在大促前的全链路压测(高并发 + 多核竞争)下才稳定复现。
为了把 happens-before 的传递性建立成直觉,我们内部还写过一个小实验:线程 A 先写a = 1再写volatile flag = true,线程 B 在观察到flag == true后读a,跑 1000 轮从未读到a == 0;把flag的 volatile 去掉,约 2%~4% 的轮次会读到陈旧的a == 0。这种可复现的小测试,比单纯背规则更能帮团队建立"可见性靠规则兜底"的肌肉记忆。
思考题
final字段为什么能让对象在构造完成后对其它线程"安全发布"?如果把例 3 的volatile int count换成AtomicInteger count并把count++改成count.incrementAndGet(),结果会变吗?为什么?