
先说我踩过的一个坑。当时线上有个任务调度模块主线程通过一个boolean flag来控制 Worker 线程的启停代码写得干干净净逻辑也看不出毛病可跑起来就是偶尔失灵明明把flag置成false了Worker 线程还在那自顾自地跑得等好一会儿才停甚至有时候直接停不下来。当时第一反应是线程没被 interrupt 到后来排查了一圈才发现问题不在interrupt而在flag这个普通变量压根没加volatile。两个线程各自持有一份变量副本主线程改了主内存里的值Worker 线程的工作内存还拿着旧值自然看不见变化。那次之后我就把 Java 内存模型JMM从头翻了一遍从 volatile 到 happens-before再到内存屏障和缓存一致性协议算是把这个八股文里最常被问、也最容易糊弄过去的知识点啃透了。这篇文章就把我梳理下来的东西完整写出来不是面试速背版而是从底层机制到实际排查、再到高频误区的完整拆解适合所有想真正搞懂并发可见性、正在准备 Java 面试、或者遇到诡异并发 bug 不知道怎么定位的同学。1. 从一次线上事故说起为什么需要 JMM1.1 那个灵异的死循环先把当时现场还原一下。代码大概是这个形态的public class FlagDemo { private boolean running true; public void stop() { this.running false; } public void work() { while (running) { // 执行耗时任务 } System.out.println(worker stopped); } }主线程调stop()Worker 线程跑work()。理论上stop()执行完running变成falsewhile条件不满足循环就该退出。可实际表现是循环退出得很慢甚至一直不退。用jstack一抓Worker 线程状态还是RUNNABLE卡在while (running)那行纹丝不动。这就是典型的可见性问题。现代 CPU 架构下每个核心都有自己的高速缓存L1、L2部分还有 L3线程在核心上执行时变量读写不是直接操作主内存而是先操作缓存中的副本。两个线程恰好被调度到不同核心上时一个核心缓存里runningfalse这件事另一个核心的缓存是感知不到的。除非发生缓存同步cache coherence或者变量被特殊机制强制刷新否则 Worker 线程看到的永远是它自己缓存里的旧值true。当年这个问题让我养成了一个习惯凡是跨线程共享、且没有加锁保护的状态标志位一律先问自己一句——它被volatile修饰了吗1.2 硬件的坑缓存一致性与 MESI 协议要理解 volatile 为什么能解决这个问题得先知道 CPU 缓存是怎么保持一致的。现代处理器普遍采用缓存一致性协议最经典的是 MESI 协议它把缓存行cache line的状态分成四种状态含义说明MModified已修改缓存行只在当前核心且与主内存不一致写回主内存前不可被其他核心读取EExclusive独占缓存行只在当前核心与主内存一致SShared共享多个核心都持有该缓存行与主内存一致IInvalid无效缓存行失效需要重新从主内存或其他核心拉取当一个核心写数据时会广播失效消息其他核心收到后把自己的缓存行标记为 I。下次再读这个变量时缓存不命中就得重新从主内存加载。这个机制保证最终能拿到新值但注意几个关键点第一MESI 是缓存行级别的协议不是变量级别的。一个 64 字节的缓存行里可能装了多个变量某个变量被更新整个缓存行都会被波及——这就是伪共享false sharing问题的根源。第二MESI 的一致性保证的是最终可见不是立刻可见。核心 A 的写入操作和核心 B 的失效确认之间存在时间窗口。而且处理器为了性能还有写缓冲区store buffer、失效队列invalidate queue这些乱序机制。理解到这个层面你就会明白一个道理硬件层面本身就允许一定程度的乱序和延迟语言层面的内存模型才有存在的必要。JMMJava Memory Model就是在这样的硬件背景下由 JSR-133Java 5 开始确立的一套抽象规则。它不关心你跑在 x86 还是 ARM 上统一规定什么时候一个线程的写入对另一个线程可见什么情况下允许重排序让 Java 并发程序在不同平台上表现一致。这就是为什么说 JMM 是 Java 并发编程的地基——没有它代码写没写对全靠玄学。2. JMM 核心抽象主内存、工作内存和三大特性2.1 把硬件模型翻译成 Java 世界的语言JMM 把内存分成了两层主内存Main Memory和工作内存Working Memory。主内存是所有线程共享的存储所有变量的权威版本工作内存是线程私有的存的是变量在主内存中的副本拷贝。线程对变量的所有读写操作都必须先在工作内存中进行不能直接读写主内存变量。读的流程是主内存 - 工作内存 - 线程执行引擎写的流程反过来线程执行引擎 - 工作内存 - 主内存。JMM 还定义了 8 种操作来约束这个交互过程lock、unlock、read、load、use、assign、store、write。这个模型和实际硬件的映射关系是主内存约等于物理内存工作内存约等于 CPU 缓存 寄存器。如果你用过 Redis可以把这个模型类比成Redis 主节点和从节点的异步复制——从节点有自己的一份副本主节点更新后需要同步同步之前的窗口期内两边数据不一致。Java 线程的工作内存就是那个从节点只是同步触发时机更加保守普通变量没有任何主动同步手段时你完全不知道它什么时候会刷新。我第一次看这个模型时觉得太抽象直到把它对应到 CPU 缓存上才豁然开朗。也正因为它对应的是不同硬件平台的通用抽象JMM 才能屏蔽 x86、ARM、RISC-V 的底层差异让 Java 程序员只需要理解一套规则。2.2 三大特性原子性、可见性、有序性并发编程的所有问题最终都可以归到这三个特性上。原子性描述的是一个操作要么全部执行、要么全部不执行中途不可中断。i不是原子的因为它实际是读-加-写三个步骤reference的赋值通常是原子的但long/double在理论上需要分两次 32 位写入Java 5 后规范允许实现自行保证原子性现代 64 位 JVM 上实际已原子但这属于规范允许而非一定保证。可见性描述的是一个线程修改共享变量后另一个线程能否立刻看到这个修改。普通变量不保证可见性volatile保证可见性synchronized通过锁的互斥与内存刷新也间接保证可见性。有序性描述的是程序执行顺序是否符合代码书写的顺序。编译器、处理器都可能对指令进行重排序单线程内有as-if-serial语义保护重排序后结果必须与顺序执行一致但多线程环境下一个线程的重排序可能对另一个线程产生不可预期的影响。volatile通过内存屏障禁止特定类型的重排序synchronized通过锁的排他性保证临界区内的串行。我常把这三大特性比喻成一个跨线程通信协议原子性决定这句话是不是一个完整句子可见性决定你说了对方能不能马上听到有序性决定你说的这句话里的词序会不会被调换。三个都保证了跨线程通信才可靠。2.3 重排序的来源和 as-if-serial 语义重排序不是 Java 独有的也不是 bug而是编译器优化和 CPU 并行执行的自然产物。主要来源有三类编译器重排序JIT 编译器认为调整语句顺序不影响单线程语义时会为了性能优化而调整指令顺序。指令级并行重排序CPU 采用流水线、乱序执行等技术多条指令可能并行执行执行完成的顺序和发射顺序未必一致。内存系统重排序现代 CPU 有写缓冲区写操作可能被延迟合并导致读操作先行完成。三类重排序叠加在一起就是你在多线程环境下看到明明代码写的顺序不是这样现象却是那样的根本原因。但 JMM 规定了一个底线——as-if-serial不管怎么重排序单线程的执行结果不能改变。这个底线保证了我们写单线程代码时不需要操心乱序问题。问题全出在多线程的交互点上线程 A 的写入顺序在线程 B 看来可能是反的而as-if-serial只约束单个线程管不了跨线程的观察视角。所以 JMM 需要另一套规则来约束跨线程操作的顺序这就是后面要重点讲的 happens-before。3. volatile 到底做了什么从语义到内存屏障3.1 volatile 的两条语义volatile在 Java 中是弱同步机制它提供两个保证可见性和有序性禁止重排序。注意它不保证原子性。先看可见性JMM 对 volatile 变量规定了特殊的读写规则线程对 volatile 变量的use操作前必须先load也就是每次使用前都从主内存重新加载最新值线程对 volatile 变量的assign操作后必须立刻store也就是每次修改后立即回写主内存。这样一个线程写了 volatile 变量其他线程再读的时候拿到的一定是最新的值。这正是我那个 flag 问题加个volatile就解决的原因。而且 volatile 的读-写规则天然构成一条 happens-before 关系对一个 volatile 变量的写happens-before 于后续对它的任意读。这条我们后面还会反复用到。再看有序性。编译器做优化时看到普通变量不会顾虑那么多看到 volatile 变量就会变谨慎它不能把 volatile 变量的写重排序到前面的普通写之前也不能把普通读重排序到 volatile 读之后。因为 volatile 变量通常被用作线程间的通信信号一旦重排序整个通信协议的时序就乱了。为了实施这个约束JMM 规定了内存屏障的插入位置。3.2 内存屏障volatile 的闸门内存屏障Memory Barrier是一条特殊的 CPU 指令作用是阻止两侧的指令跨过它进行重排序同时强制缓存/内存在屏障处刷新的效果。JMM 中一共有四种屏障屏障类型指令组合作用LoadLoadLoad1; LoadLoad; Load2确保 Load1 在 Load2 之前完成StoreStoreStore1; StoreStore; Store2确保 Store1 在 Store2 之前完成且 Store1 已刷主内存/缓存LoadStoreLoad1; LoadStore; Store2确保 Load1 在 Store2 之前完成StoreLoadStore1; StoreLoad; Load2确保 Store1 在 Load2 之前完成且 Store1 对全局可见最强屏障对于 volatile 写操作JMM 要求在它前面插入 StoreStore 屏障在它后面插入 StoreLoad 屏障。前面的 StoreStore 保证在 volatile 写之前的所有普通写操作都先刷新到主内存再执行 volatile 写。这样 volatile 变量一旦更新它前面的那些值也一并被发布出去了。后面的 StoreLoad 保证volatile 写之后的普通读操作不会越过这条写被提前执行。对于 volatile 读操作JMM 要求在它后面插入 LoadLoad 和 LoadStore 屏障。这保证volatile 读之后的所有普通读写都在这条读完成之后执行也就是说 volatile 读把你推到了最新的内存视图上。用生活化的比喻volatile 写像是在朋友圈发了一条置顶声明声明之前你发的所有内容都会被大家看到volatile 读像是在大家确认看到声明后才继续往下刷不会有人跳过声明直接看到后面的内容。屏障就是这两个置顶刷新动作的物理实现。x86 架构下由于处理器自身已经有较强的内存排序保证实际落实这些屏障时很多会被简化甚至省略但它依然是我们在 Java 层面理解和推导并发正确性的核心依据。你在任何一个 JVM 实现上写volatile语义都是统一的底层怎么做那是 JVM 的事。3.3 volatile 不保证原子性i 的惨痛教训这是面试里几乎必问的一个坑点。很多人背了volatile 保证可见性和有序性不保证原子性但没真正理解为什么。private volatile int count 0; public void increment() { count; }两个线程各调 10 万次increment()结果往往不是 20 万。原因很简单count读到的值、1、写回主内存这三步不是原子的。volatile 保证了可见性但 step2 和 step3 之间可能会有另一个线程也做了 read两边同时基于同一个旧值做 1然后各自写回互相覆盖。所以 volatile 适用于一个线程写、多个线程读的场景适用于状态标志位场景但绝不适合读-改-写的复合操作场景。复合操作用AtomicIntegerCAS 保证原子性或者用synchronized/Lock包起来。这个边界分清之后很多并发设计就不会走偏。4. happens-before 规则看不见的秩序4.1 八条规则逐一拆解happens-before 是 JMM 定义的一套偏序关系如果操作 A happens-before 操作 B那么 A 的结果对 B 可见且 A 的执行顺序在 B 之前。它不是时间先后而是可见性先后和排序约束的组合。四个字总结先见先觉。JSR-133 定义了 8 条规则我按记忆的脉络整理一下1程序次序规则同一个线程中书写在前的操作 happens-before 书写在后的操作。这就是 as-if-serial 的体现。2管程锁定规则unlockhappens-before 之后对同一把锁的lock。意味着临界区里写的共享变量退出锁后对下一个拿到锁的线程可见。3volatile 变量规则对一个 volatile 变量的写 happens-before 之后对该变量的读。4线程启动规则Thread.start()happens-before 该线程的任意操作。所以启动线程前设置好的共享变量线程启动后一定能看到。5线程终止规则线程中的所有操作 happens-before 检测到该线程终止的任何操作。Thread.join()返回后、Thread.isAlive()返回 false 后该线程写入的共享变量对主线程可见。6线程中断规则Thread.interrupt()的调用 happens-before 被中断线程检测到中断事件发生如捕获到InterruptedException、读到中断标志。7对象终结规则对象构造完成 happens-beforefinalize()方法的开始。8传递性A happens-before BB happens-before C则 A happens-before C。这是把前 7 条串起来的拼图规则。这里要特别强调未满足 happens-before 关系的两个操作JMM 允许乱序和不可见但也不代表一定乱序、一定不可见。它给的是一个最坏情况的承诺不是保证所见即所写。很多并发 bug 之所以难复现就是因为大多数时候运气好恰好落在了一个看起来有序的时间窗口里。4.2 volatile 的 happens-before 怎么串联整个程序光记规则没用得会用happens-before 推导链来分析一段并发代码是否正确。拿一个经典配置发布的例子// 线程 A Config config loadConfig(); // 步骤1普通写 ready true; // 步骤2volatile 写 // 线程 B if (ready) { // 步骤3volatile 读 use(config); // 步骤4普通读 }推演一下步骤1 和 步骤2 在同一个线程里程序次序规则成立步骤1 happens-before 步骤2。步骤2 是 volatile 写步骤3 是 volatile 读且读到的是写后的值volatile 规则成立步骤2 happens-before 步骤3。步骤3 和 步骤4 在同一个线程里程序次序规则成立步骤3 happens-before 步骤4。然后利用传递性步骤1 - 步骤2 - 步骤3 - 步骤4所以步骤1 happens-before 步骤4。结论是线程 B 在ready true的前提下一定能看到线程 A 写入的config对象。这就是 volatile 的发布-订阅模式也是它作为状态标志位时能顺手带着其他变量一起安全发布的根本原因。如果你把ready换成普通变量这个推导链就断了JMM 不承诺任何可见性保证。应用层看不出问题只是概率上的问题没被你撞到而已。所以我在写这类代码时会在旁边加注释标明这是一条volatile 发布链避免后面维护的人悄悄把 volatile 去掉。4.3 传递性为什么它比你想的更重要传递性经常被忽视但它才是 happens-before 发挥威力的核心。没有传递性前面所有规则都是孤立的单点约束根本连不成一条完整的推理链。有了传递性你可以把线程内顺序 volatile 规则 锁规则自由组合推导出任意两个跨越线程的操作之间的可见性关系。实际编码时锁加上了、volatile 也加了但就是还有问题的情况多半是传递链断了。比如// 线程 A synchronized(lock) { x 1; } // unlock 之后 flag true; // 这里的写没有与后续读建立关系若 flag 不是 volatile线程 B 不见得能看到 // 线程 B if (flag) { synchronized(lock) { print(x); } }锁规则只保证同一个锁的 unlock 对后续 lock 可见。flag不是 volatile 时线程 B 也许在锁外就读了 flag或者读到了旧值整个链路的源头就断了。排查这类 bug 时我的习惯是在纸上把每个跨线程交互都画成操作 - happens-before - 操作的箭头箭头断在哪儿问题就在哪儿。5. 实战经典场景里的 volatile 与 happens-before5.1 DCL 单例为什么双重检查锁必须加 volatile单例模式的线程安全写法里双重检查锁Double-Checked Locking, DCL最常被拿来分析。代码是这样的public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查无锁读 synchronized (Singleton.class) { if (instance null) { // 第二次检查加锁读 instance new Singleton(); // 问题核心 } } } return instance; } }synchronized已经保证第二次检查时的互斥为什么instance还必须加volatile关键在instance new Singleton()这一句。它不是一个原子操作JVM 在堆上创建对象分三步分配内存空间在内存上初始化对象构造器执行将引用赋值给变量instance。步骤2和步骤3可能被重排序先赋值引用再执行构造器。如果线程 A 完成了重排序后的步骤13还没执行步骤2线程 B 恰好进来第一次检查instance ! null直接返回了一个尚未初始化完成的对象——拿到手的是半成品使用它的字段时可能读到默认值甚至触发各种诡异 NPE。加上volatile之后volatile 写在先的 StoreStore 屏障保证构造对象的前序写操作字段赋值必须全部完成才能执行引用赋值。线程 B 读到 volatile 引用时能立即看到完整的初始化结果。这就是 DCL 必须配合 volatile 的原因。顺带一提更简单的替代方案是使用静态内部类 Holder 式单例它依赖类加载机制的天然线程安全既没有锁也没有 volatile。但 DCL 依然是面试高频题volatile 在这里的价值必须讲明白。5.2 flag 模式线程停止的标准写法回到文章开头那个事故修复后的标准写法public class GracefulShutdown { private volatile boolean running true; public void shutdown() { running false; } public void run() { while (running) { // 处理任务 } cleanup(); } }这个模式有几个注意点running的写用 volatile 保证对 Worker 线程立即可见。如果任务循环里调用了阻塞方法比如阻塞队列的take()单纯 volatile 标志没法立刻中断阻塞需要配合interrupt()一起用。volatile 负责普通 CPU 密集循环的退出interrupt 负责阻塞等待的唤醒。如果想省掉 volatile也可以把循环体内的操作全部包进synchronized或者用Lock但那样性能和代码复杂度都不划算。状态标志位场景中 volatile 是最轻量、最合适的选择。每当有人问我啥时候该用 volatile我都会给这个判断标准多个线程里只有一个线程负责写这个变量其他线程只负责读就把这个变量加 volatile。谁都能写、还涉及复合操作就该考虑锁或原子类了。5.3 安全的对象发布final 的关键补充有人会问不加 volatile 也能安全发布对象吗如果对象字段全部声明为finalJSR-133 之后是有一个特殊的保证的。JMM 规定在构造器中final 字段的写入与构造器返回后该对象引用的赋值之间有一个 StoreStore 屏障禁止 final 字段的写入被重排序到构造器外。这就是final 的安全发布语义。具体来说这种写法是安全的public class SafeObject { private final int value; public SafeObject(int value) { this.value value; } } // 线程 A public static volatile ???注意前提是对象引用被安全发布出去——这个发布动作本身仍需 happens-before 来保证。final保证了对象内部的字段不会处于半初始化状态但另一个线程能不能看到这个引用仍然需要其他同步机制。所以更严谨的表述是final 解决的是对象内部一致性volatile/锁解决的是引用可见性两者是不同层面的保证。面试里如果能把这两个语义区分开说深度一下就上去了。我在实际项目中见过一个坑有人在类里定义了一堆final字段就觉得反正都是 final肯定安全结果对象是通过静态工具类里的普通变量发布出去的另一个线程拿到引用后依旧能看到默认值null/0。final 再强也管不住发布路径上的可见性这个边界想清楚能少踩很多坑。6. 高频面试题、常见误区与避坑指南6.1 六道高频题一次讲透Q1volatile 能保证原子性吗不能。它只保证可见性和有序性。对i这类读-改-写复合操作需要AtomicInteger或锁。很多人背结论面试官接着问为什么不能其实考察的是你对原子性不可分割和volatile 的机制刷新缓存屏障的理解深度。Q2普通变量的写什么时候会对另一个线程可见JMM 不承诺任何时间点。它只承诺没有 happens-before 关系的两个操作视为无约束一切乱序、延迟都可能发生。分析并发问题时不能假设过一会儿总能看到要假设可能永远看不到。Q3synchronized 和 volatile 的区别语法上volatile 修饰变量synchronized 修饰方法/代码块。语义上volatile 只保证可见性和有序性不保证原子性synchronized 同时保证原子性、可见性和有序性通过锁的互斥 内存刷新。性能上volatile 通常更轻量但滥用也会因为频繁刷内存而拖慢性能。选型原则能用 volatile 解决的状态标志场景不用锁涉及复合操作必须锁或原子类。Q4long/double 的读写是原子的吗JMM 理论上是分两次 32 位操作的但规范在 Java 5 之后允许实现自行保证原子性现代 64 位 HotSpot 上实际是原子的。32 位 JVM 或未来特性下不能依赖这个实际。面试答理论分两步现代实现通常原子但标准不强制最稳妥。Q5happens-before 是执行时间先后吗不是。它是可见性顺序和排序约束的承诺。A happens-before B 意味着 A 的结果对 B 可见且 A 不会被重排序到 B 之后。但它不要求时间上 A 一定先于 B 完成这也正是它能和硬件乱序执行共存的原因。Q6final 字段在构造器里有重排序风险吗JSR-133 之后final 字段的写在构造器内有 StoreStore 屏障保护不会被重排序到构造器外。但对象引用的可见性仍需其他同步机制保证。两条要分开记别把 final 当成万能安全发布。6.2 实战排查清单与避坑经验最后把我这几年排查并发可见性问题的套路整理成清单遇到类似诡异 bug 可以直接按顺序走先确认共享变量是否被多线程读写单线程场景一切正常多线程才出问题优先怀疑可见性/重排序。判断读写模式一写多读 - volatile 或 final多写互斥 - synchronized/Lock/原子类。画 happens-before 推导链把所有跨线程交互点标出来看每条写 - 读是否有规则支撑断点就是嫌疑点。用工具验证竞态jstack抓线程转储检查是否卡在预期位置必要时用jcstressJava Concurrency Stress Tests写微小用例跑并发压力测试。不要靠 sleep 等碰运气修复看到有人用Thread.sleep(1000)掩盖可见性问题基本等于埋雷。正确做法是补上同步语义而不是靠时序凑合。避免伪共享缓存行是 64 字节如果两个线程频繁写同一个缓存行中的不同变量可能互相拖慢。必要时用Contended注解或手动 padding但这是性能优化层面的事别在正确性还没保证时过早引入。踩过几次坑之后我最深的体会是JMM 不是面试八股文里背几句结论就能过关的它是一套需要在实际问题里反复验证的思维框架。每次遇到我明明改了他怎么看不见的诡异问题回到 happens-before 推导链上去走一遍基本都能找到答案。这套分析方法比记住任何特定指令或特定 CPU 架构的细节都更值钱因为它在所有 JVM 平台、所有硬件架构上都通用。最后再分享一个小技巧。写共享变量时我习惯在变量声明的注释里直接写明此变量的可见性由 XXX 机制保证volatile / synchronized / final 发布方式。这不是形式主义而是因为并发代码最大的风险不是写错而是后来维护的人看不懂你的意图随手把关键字改掉。把同步责任写清楚等于给后来的人一张并发安全的地图——这个习惯帮我避免了不止一次线上事故。