5.1 可见性与 volatile 失灵复盘 本节摘要:一个置为 true 的停止标志,另一个线程永远读不到——这是 JMM 可见性问题最标准的形态。本节从线程不退出的怪事讲起,拆解工作内存与主存的模型、缓存与优化的现实来源、volatile 的能力边界、双重检查锁单例的半初始化陷阱,最后给出"能不共享就不共享"的第一原则。 事故现场:关不掉的后台线程 配置热更新服务里有个后台扫描线程,关闭钩子里置停止标志,但服务下线时扫描线程偶尔不退出,进程悬在那里等到 : 没有任何异常、没有死锁(jstack 显示线程 RUNNABLE 在跑循环),flag 明明被置了 true。加一行 就"好了"——经典的 Heisenbug(观察改变行为)。
本节摘要:一个置为 true 的停止标志,另一个线程永远读不到——这是 JMM 可见性问题最标准的形态。本节从线程不退出的怪事讲起,拆解工作内存与主存的模型、缓存与优化的现实来源、volatile 的能力边界、双重检查锁单例的半初始化陷阱,最后给出"能不共享就不共享"的第一原则。
配置热更新服务里有个后台扫描线程,关闭钩子里置停止标志,但服务下线时扫描线程偶尔不退出,进程悬在那里等到 kill -9:
class ConfigScanner implements Runnable { private boolean stopped = false; // 普通 boolean public void stop() { this.stopped = true; } public void run() { while (!stopped) { // 可能永远读到 false scanAndReload(); } } }
没有任何异常、没有死锁(jstack 显示线程 RUNNABLE 在跑循环),flag 明明被置了 true。加一行 println 就"好了"——经典的 Heisenbug(观察改变行为)。根因:JMM 允许每个线程把变量缓存在工作内存(寄存器/缓存)里,普通变量的读不强制去主存刷。JIT 更狠:它看到 stopped 在循环内不被本线程修改,直接把循环优化成 while (true),主存里的 true 与它再无关系。
Java 内存模型不描述物理 CPU,它是一套抽象契约:每线程有工作内存,共享变量在主存;读写在默认规则下可以走缓存、可以重排,只要单线程语义不变(as-if-serial)。这套宽松契约给了 JIT 与 CPU 乱序执行的自由度,也意味着写在不加约束时,别的线程可能永远看不见,或者以另一种顺序看见。
三性对应三类事故:
count++ 是读-改-写三步,两线程交错就丢更新。锁与 AtomicLong/LongAdder 解决
private volatile boolean stopped = false; 一行修复。volatile 的语义:读看见最新写(可见性),且该变量的读写成为内存屏障,约束前后的重排(有序性)——写 volatile 之前的普通写,对之后的 volatile 读可见(happens-before 边)。停止标志、状态机单字段切换、双检锁的实例引用,都是它的合法场景。
但 volatile 不保证原子性。经典反例:
volatile int count = 0; void inc() { count++; } // 仍然丢更新 volatile 救不了
两个线程同时读到 5,各自加到 6,写回两个 6。计数的正解按场景选:低竞争用 AtomicLong(CAS 自旋),高竞争统计用 LongAdder(分段累加,读时合并,牺牲一点读精确性换写吞吐)。而 volatile int[] arr 只保证引用可见,数组元素不在保护范围(要 AtomicIntegerArray)——"volatile 数组"是常见误用。
class Singleton { private static Singleton instance; // 缺 volatile static Singleton get() { if (instance == null) { // 第一次检查 免锁快路径 synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); // 非原子的三步 } } } return instance; } }
new Singleton() 在字节码上是三步:分配内存、初始化字段、引用指向内存。2 和 3 允许重排。线程 A 执行到"引用已指向、字段未初始化"的中间态,线程 B 在第一次检查处看到非 null 直接返回,拿到了半初始化对象——字段是默认值。修复是 private static volatile Singleton instance,用 volatile 禁止 2-3 重排。更省心的替代:静态内部类持有实例(类加载语义保证)或 enum 单例,直接绕开这个坑。
并发工具箱再全,最优解仍是消除共享:方法内局部变量天然线程封闭(栈私有);不可变对象(final 字段、安全发布)免同步;ThreadLocal 把共享变成"每线程一份"(注意池化场景的清理,呼应第 2 章)。真正需要共享可变状态时,优先并发容器(ConcurrentHashMap)与原子类,最后才是手写锁。虚拟线程时代这条原则更重要——共享可变状态在百万级线程下没有任何锁方案能撑住。
⚠️ 常见坑:用 volatile 修饰多个相关状态变量。两个 volatile 字段各自的读写有序,但"先写 a 再写 b"与"先读 b 再读 a"之间没有跨变量约束,状态机照样看见混合态——要么封成一个不可变对象整体替换,要么上锁。
💡 关键直觉:JMM 的宽松不是实现偷懒,是性能换来的契约。你的代码要么明确接受契约的保证(volatile/锁/不可变),要么就活在"碰巧能跑"的悬崖边。
下一节看两个线程互相谦让到死的故事:死锁。