
如果你在一个线上系统里见过一个布尔开关“明明被改成 true 了但线程就是打死都不退出”那你大概率已经踩到了 Java 内存模型JMM里最经典的坑。今天这篇是并发编程系列的第三篇专门把 volatile 和它背后的 JMM 讲透。我先说结论volatile 解决的是可见性和有序性它不解决原子性很多人把它当成“轻量级锁”用错了地方结果线上出问题还一头雾水。这篇文章不会只让你背八股而是从一次真实故障出发一路拆到 CPU 缓存、指令重排、内存屏障这一层最后再给出一份能直接落地的使用清单。内容不短但值得耐心读完。1. 一个开关字段引发的“灵异事件”先从故障说起1.1 故障现场前两年我维护过一个配置中心类的中间件业务方通过它实时接收开关配置。某个下午运维反馈说有一台机器的某个功能开关已经推送成 true但机器上的业务线程依然走旧逻辑持续了大概几十秒后才恢复正常。抓包看配置早就到了应用进程打日志看配置对象也被更新了可是那些长时间运行的线程读到的开关值却始终是 false。当时第一反应是“是不是有缓存”但查遍业务代码没有加任何本地缓存。后来一个老同事看了一眼配置对象的定义问了一句这个开关字段是不是没加 volatile改上加 volatile 之后故障再也没出现过。1.2 为什么“明明改了就是看不见”这个问题的本质是 Java 内存模型允许线程把主内存中的共享变量拷贝到自己的工作内存或者寄存器里使用。一个线程改了值如果只改了自己工作内存里的副本没有及时写回主内存另一个线程自然读不到。反过来也一样一个线程即使从主内存读了一次下次再读也仍然可能用本地副本里的旧值。你可能觉得现在的 CPU 都有缓存一致性协议像 MESI 这种怎么还会有这种情况但要注意缓存一致性协议只保证缓存行级别的最终一致它不能解决编译器优化和 JIT 优化带来的问题。比如 JIT 发现你的循环体里没有对那个变量的修改它会很“聪明”地把读操作提升到循环外面甚至直接变成一个死循环。这个时候你改主内存里的值根本没用因为代码根本没有再去读它。1.3 从这次故障引出 JMMJava 内存模型就是一套规则用来约束编译器、JIT、CPU 对内存访问的重排序和缓存行为保证多线程环境下共享内存访问的确定性。它规定了两件事一是哪些操作之间不能重排序二是线程什么时候能看到其他线程的修改。JMM 定义了主内存和工作内存以及一系列操作规则volatile 就是这套规则里最重要的一块基石。2. JMM 的三座大山主内存、工作内存与重排序2.1 线程、主内存和工作内存的抽象模型JMM 把内存抽象成三层主内存所有线程共享的存储区域存放对象的字段、静态变量等。工作内存每个线程独享的抽象存储区域保存线程临时用到的变量副本。线程与主内存交互必须经过工作内存中转不能直接读写主内存。这里的工作内存并不等于 CPU 寄存器它是一个抽象概念实际可能对应 CPU 缓存、写缓冲区、寄存器等。但用这个模型可以很好地解释大体上的可见性问题线程 A 把变量 x 从 0 改成 1只是改了 A 自己的工作内存副本如果没有写回主内存线程 B 永远只能看到旧的 0。JMM 定义了 8 种原子操作来规范主内存和工作内存之间的交互操作作用对象含义lock主内存把变量标记为线程独占unlock主内存释放变量独占状态read主内存把变量值从主内存传输到工作内存load工作内存把 read 得到的值放入工作内存副本use工作内存把工作内存中的值传给执行引擎assign工作内存把执行引擎得到的值赋给工作内存副本store工作内存把工作内存中的值传输到主内存write主内存将 store 得到的值写入主内存变量这些操作不是给业务代码直接用的而是 JMM 为了实现“按值传递、按顺序可见”而设计的底层约束。2.2 重排序编译器、处理器、内存系统都在“帮倒忙”我见过不少人把重排序理解成“指令执行的魔法”其实它没那么神秘来源主要有三个编译器重排序编译器为了优化寄存器分配、表达式计算会调整语句顺序只要不改变单线程语义。指令级并行重排序现代 CPU 可以在一个时钟周期内发射多条指令处理器会根据依赖关系重新安排执行顺序。内存系统重排序处理器有写缓冲区store buffer可能导致写操作的提交顺序和程序顺序看起来不一致。重排序的核心出发点是性能。单线程下它无所谓因为“as-if-serial”保证最终结果一致但多线程下另一个线程没有这些约束就可能看到一个“颠倒黑白”的执行结果。2.3 JMM 的核心承诺as-if-serialJMM 允许各种重排序但有一条底线不管怎么重排单线程程序的执行结果不能被改变。这就是 as-if-serial 语义。所以你写int a 1; int b 2; int c a b;JIT 完全可以把 b 的赋值和 c 的计算调整顺序也可以先把 c 算出来只要最后 a、b、c 的值符合源代码语义。可是如果这是在多线程中另一个线程同时读 a、b它看到两个变量的修改顺序可能就和你想的不一样。这也解释了为什么没有同步机制时多线程程序会出现“看起来完全违反直觉”的行为。JMM 存在的意义就是把这种自由限制在可控范围内你可以重排序但共享变量的关键读写必须遵守 happen-before 约束。3. Volatile 的可见性承诺缓存一致性协议与内存屏障3.1 从 CPU 缓存到 MESI 协议要理解 volatile 为什么能保证可见性得先知道硬件层面是怎么做的。现代 CPU 都有多级缓存L1/L2/L3线程在某个核心上修改变量时往往只修改了该核心的缓存行。其他核心如果没有收到通知继续用自己缓存里的旧值就会出问题。所以缓存系统引入了缓存一致性协议比如 x86 上常见的 MESI 协议为每个缓存行维护四种状态状态含义MModified当前缓存行已被修改与主内存不一致但只有本核心持有EExclusive当前缓存行未被修改与主内存一致且只有本核心持有SShared当前缓存行未被修改多个核心可能同时持有IInvalid当前缓存行已失效当一个核心写入一个处于 S 状态的缓存行时协议会向其他核心发送“使无效”消息让它们的缓存行失效。之后其他核心一旦再访问这个变量就会因为缓存失效而重新从主内存或者持有 M 状态的核心读取最新值。MESI 解决的是“缓存行之间的可见性”但它并不能覆盖编译器重排序、寄存器分配等问题。所以 JMM 还会要求编译器在适当位置生成内存屏障指令。3.2 volatile 读写时到底发生了什么按照 JMM 规则volatile 变量的特殊约束可以简化成三条写 volatile 变量时JMM 要求把该变量在本地工作内存中的修改强制写回主内存。读 volatile 变量时JMM 要求先把本地工作内存中该变量标记为失效然后从主内存重新读取。volatile 变量的读写操作不能和前后其他普通变量的读写操作进行重排序。用前面的 8 个原子操作来表达就是对 volatile 变量的使用use/assign 必须与 load/store 成对出现而且 read-load-use 和 assign-store-write 必须连续中间不能插入其他操作。这样读 volatile 就相当于“从主内存拿最新值”写 volatile 就相当于“立刻刷回主内存”。3.3 内存屏障在 x86 上的实际形态光有高层规则不够CPU 需要真正的指令来阻止重排和强制刷新。x86 是强内存模型它本身会保证很多读写顺序所以 HotSpot 在 x86 上对 volatile 的实现相对保守volatile 写通常被翻译成带 lock 前缀的写指令比如lock addl $0, 0(%rsp)。这个 lock 前缀相当于一个全屏障会强制完成写缓冲区的刷写并让其他核心感知到写操作。常见的屏障类型包括LoadLoad 屏障禁止本线程后面的读操作重排到前面的读操作之前。StoreStore 屏障禁止本线程后面的写操作重排到前面的写操作之前。LoadStore 屏障禁止本线程后面的写操作重排到前面的读操作之前。StoreLoad 屏障禁止本线程后面的读操作重排到前面的写操作之前也是代价最高的屏障。JMM 针对 volatile 读写的屏障插入策略可以简化如下场景要求volatile 读之后插入 LoadLoad 和 LoadStore 屏障防止后面的普通读写被重排到 volatile 读之前volatile 写之前插入 StoreStore 屏障防止前面的普通写被重排到 volatile 写之后volatile 写之后插入 StoreLoad 屏障防止后面的普通读被重排到 volatile 写之前注意这只是一种抽象策略。在 x86 上没有 StoreLoad 之外的复杂要求所以 volatile 读写成本比在 ARM、PowerPC 这些弱内存模型上低不少。很多人在讨论 volatile 性能时常常低估了这种平台差异。4. Volatile 的有序性为什么它能把重排序“按住”4.1 Volatile 与 happens-beforeJMM 里最核心的规则是 happen-before。只要两个操作之间有 happen-before 关系前一个操作的执行结果对后一个操作可见并且前一个操作不会被重排序到后一个操作之后。和 volatile 直接相关的规则就是对一个 volatile 变量的写操作happens-before 于任意后续对这个 volatile 变量的读操作。再加上传递性就能推导出更强的约束如果线程 A 先写普通变量 x再写 volatile 变量 flag线程 B 先读 flag再读 x那么 B 一定能看到 A 写入的 x。这个特性在实际编码中意义重大很多“无锁状态发布”模式就是依赖它实现的。4.2 JSR-133 对 volatile 语义的增强很多人不知道volatile 的语义在 JDK 5 有过一次重要增强也就是 JSR-133。在旧的 JMM 里volatile 的可见性规则存在不少漏洞导致一个被 volatile 修饰的变量虽然自身能保证可见性但它旁边那些普通字段的写入顺序却可能乱掉。JSR-133 把规则改成了现在这样volatile 写相当于发布一个“同步点”volatile 读相当于获取一个“同步点”中间的一切普通读写都必须尊重这个边界。这也是为什么我强烈建议如果你的项目还跑在 JDK 8 以上就对底层依赖的“看似正常但没加 volatile 的共享变量”做一次全面排查。很多老代码在旧语义下侥幸跑得对换到新 JIT 或者新硬件上就现出原形。4.3 一个能跑出问题的最小例子下面这段代码是一个很典型的“缺少 volatile 导致死循环”的 Demopublic class VolatileDemo { private static boolean stop false; // 这里改成 volatile 才能正常退出 public static void main(String[] args) throws Exception { Thread worker new Thread(() - { int i 0; while (!stop) { i; } System.out.println(worker stopped, i i); }); worker.start(); Thread.sleep(1000); stop true; System.out.println(main set stop true); } }不加 volatile 时很多环境上 worker 线程永远不会退出因为 JIT 把!stop的判断提升到了循环外CPU 寄存器里始终保存着旧值 false。加了 volatile 后JMM 禁止这种优化每次循环都会重新读取主内存中的值程序才能正常结束。我第一次跑这个例子是在一台老服务器上JIT 触发后肉眼可见 CPU 占用率飙升但主线程怎么设置 stop 都没用。那一刻对“可见性”三个字才真正有了体感。5. Volatile 不保证原子性并发 i 的翻车实录5.1 三步骤与竞态条件volatile 保证了可见性但很多初学者会把它误当成“线程安全”。最经典的翻车案例就是并发下的i。private static volatile int count 0;然后开 10 个线程每个线程循环 10000 次count。理论上结果应该是 100000但实际跑下来你会发现结果经常是 995xx、996xx。原因在于count在字节码层面不是一条指令而是三步操作读取 count 的当前值把 count 加 1把结果写回 count。volatile 能保证第 1 步读到最新值第 3 步立刻刷回主内存但它不能保证第 1 步到第 3 步之间没有其他线程插一脚。如果两个线程同时读到同一个旧值 1各自加 1 后都写回 2那这次操作就丢了一次加法。5.2 证明代码与运行结果写一个验证程序public class VolatileAtomicDemo { private static volatile int count 0; private static final int THREADS 10; private static final int LOOP 10000; public static void main(String[] args) throws Exception { Thread[] threads new Thread[THREADS]; for (int i 0; i THREADS; i) { threads[i] new Thread(() - { for (int j 0; j LOOP; j) { count; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(count count); } }我本地跑了几次结果分别是99920、99864、99731从来没有一次稳定跑到 100000。这就是 volatile 不保证原子性的直接证据。5.3 正确替代方案如果确实需要一个计数器有几种选择synchronized 包住整个count简单但并发度低。使用AtomicInteger基于 CAS无锁但通常比 volatile 更稳。如果是高频计数可以用LongAdder分段计数再求和适合高并发统计场景。private static AtomicInteger atomicCount new AtomicInteger(0); atomicCount.incrementAndGet();这里要特别提醒不要试图通过给count外面套 volatile 再“手动加锁”来实现原子性那样逻辑会变得非常难维护。要么彻底用原子类要么就用锁或同步块。6. 经典案例拆解DCL 单例为什么必须加 volatile6.1 从懒加载到双重检查锁双重检查锁Double-Checked LockingDCL是面试高频题也是生产环境里最容易写错的一段代码。先看标准写法public class Singleton { private static volatile Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }为什么已经有了 synchronized还要给 instance 加 volatile这是很多人背答案但不理解的地方。6.2 没有 volatile 时会发生什么new Singleton()不是一条不可分割的指令它可以拆成三个大步骤在堆上分配内存调用构造函数初始化对象把引用指向这块内存。重点来了编译器或 CPU 可能把第 2 步和第 3 步重排序也就是先“把引用赋值给 instance”再执行构造函数里的初始化。想象一个场景线程 A 进入同步块执行instance new Singleton()在重排序后instance 已经指向了一块内存但对象字段还没有初始化完。线程 B 同时调用getInstance()在外层检查instance null时发现 instance 不为 null于是直接返回这个半初始化的对象。线程 B 后续访问对象的字段读到的可能是默认值 0 或 null程序出错。加了 volatile 之后JMM 通过内存屏障禁止了第 2 步和第 3 步之间的重排序并且保证 instance 的赋值对线程 B 可见线程 B 不会看到“引用非空但对象未初始化”的中间状态。6.3 JDK 5 前后语义变化DCL 在 JDK 5 之前其实是失效的因为旧的 JMM 允许 volatile 变量与非 volatile 字段的重排序使得“半初始化发布”仍然可能发生。JSR-133 修复了这一点增强了 volatile 的语义并且强制要求对 final 字段的初始化推迟可见性也做了处理。所以现在写 DCLinstance 必须加 volatile 这一条已经是铁律。不要因为换用 ThreadLocal、ConcurrentHashMap 等替代方案就忽略这个基本原则。6.4 其他单例替代方案如果你在低版本 JDK 上工作或者不想依赖 volatile 的语义细节可以用静态内部类或枚举public class Singleton { private Singleton() { } private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }静态内部类靠类加载机制保证只初始化一次配合 final 字段在 JDK 5 以后都能安全使用。但也不是说 DCL 要弃用而是在理解 volatile 的前提下DCL 依然是通用性最好的写法之一。7. Volatile 使用的实战清单与常见误区7.1 哪些场景该用 volatile根据我的实战经验以下几种场景适合用 volatile状态开关布尔类型的运行开关、启动标志、停止标志单线程写、多线程读。事件发布一个线程写配置快照另一个线程读快照中间用 volatile 字段发布。双重检查锁中的共享引用。复合操作中只需要保证可见性、不需要保证原子性的部分。典型例子是“线程安全的发布不可变对象”。比如一个线程创建了一个不可变的配置对象然后用 volatile 引用发布其他线程读这个引用时不仅能拿到最新对象还能看到对象内 final 字段的安全初始化。7.2 哪些场景别用 volatile计数器递增count、count n这类复合操作volatile 帮不上忙。多个状态条件组合比如“当 x 0 且 y 10 才执行”单独 volatile 两个变量无法保证整体一致性。需要互斥访问的场景比如一个队列的入队出队必须用锁或并发容器。需要读改写一体的场景例如map.put(key, map.get(key) 1)这种操作volatile 无法保证。7.3 和 synchronized / Atomic 的选型对比维度volatilesynchronizedAtomicInteger 等可见性保证保证保证原子性不保证保证临界区内保证单变量互斥无有无CAS 重试性能特点开销最小有锁竞争和上下文切换风险高并发下可能自旋适用场景状态发布、开关复合操作、临界区计数器、序号生成看到这张表你就知道选型逻辑其实很清楚先判断要不要互斥再判断是不是单变量原子操作最后才考虑用 volatile。7.4 常见误区汇总误区一volatile 线程安全。这是最危险的理解。误区二volatile 一定能防止所有重排序。它只约束以 volatile 变量为边界的前后读写具体结果要看 JMM 规则。误区三volatile 适合做高性能锁。它本身不是锁沿用错误模型只会越改越乱。误区四不加 volatile 的程序就一定出错。不一定会因为 JIT 优化、硬件调度都会影响但“偶尔正确”比“必然错误”更难受。我在代码评审里看到过不少 add volatile 后“碰巧修好”的案例看起来单次修对了但实际上作者没有理解根因。如果问一句“这个 volatile 在保证什么”往往答不上来。这种代码一旦换个环境、换个 JDK很容易重新爆炸。8. 从 JMM 到实际调优我对 volatile 的最终使用建议写了这么多还是想给你一个可以“抄作业”的清单。第一任何共享可变变量只要不是 synchronized/加锁保护的都先想清楚是否需要 volatile。默认不加 volatile 的共享写读几乎一定是错的。第二对 volatile 变量的读写尽量保持简单。不要在 volatile 变量周围包一层复杂的“为了优化而优化”的逻辑给编译器留出更多可控空间。第三判断某个并发问题是不是 volatile 能解决只需问三个问题是不是单次读写就能解决问题如果是复杂计算或修改组合volatile 不够。需不需要互斥需要就别用 volatile。有没有前序依赖如果 B 必须看到 A 的全部修改结果保证 A 和 B 之间有 volatile 写读或其他 happens-before 关系。第四把 JMM 的 happens-before 规则刻在脑子里。遇到任何“明明改了却没看到”的并发 bug先画一张线程交互时序图标出有没有 volatile、有没有加锁、有没有 join/CountDownLatch大部分问题一眼就能看出来。最后说一句个人体会。每次有人问我 volatile 到底要怎么学我都建议先放下概念先写一个死循环标志的 Demo亲眼看看没有 volatile 时线程真的不退出。看明白这个JMM 的可见性、有序性、原子性就自然串起来了。至于生产环境我的习惯是状态开关用 volatile计数器一律走 Atomic复合操作用锁绝不指望 volatile 帮我一键搞定。这套组合用到现在还没翻过车。