核心解析:缓存、可见性与happens-before实战应用)
最近在排查一个并发场景的线上问题我重新把Java 内存模型JMM相关的资料翻了个底朝天。说实话面试里大家都把JMM、可见性、happens-before挂在嘴上但真正遇到诡异 Bug 时能用这套知识定位问题的人并不多。这篇属于上篇我先把最核心的骨架讲透——从 CPU 缓存模型到 JMM 抽象再到三大特性和 happens-before 规则最后落到两个非常容易踩的认知坑上。整个上篇适合这几类人准备 Java 并发面试、想系统梳理 JMM 底层逻辑、或者已经写过不少多线程代码但总感觉差点意思的开发者。我会尽量用大白话把抽象概念拆开讲也会给到能直接照着排查问题的思路不搞那种背完就忘的八股文。1. 为什么先聊硬件CPU缓存模型是JMM的出发点1.1 从一次诡异的并发BUG说起之前有个同事负责一个订单通知服务核心逻辑是后台线程轮询消息队列把推送任务交给 HTTP 客户端发出去。线上偶尔会出现通知中断但进程还没死的情况日志里看不出任何异常就像后台线程凭空消失了一样。查了半天最后定位到一个很常见的布尔标志位// 提供停止能力的服务 public class PushService { private boolean stopped false; public void stop() { stopped true; } public void work() { while (!stopped) { // 拉取消息并推送 } } }线程 A 调用stop()把stopped置为 true线程 B 正在执行的while (!stopped)却一直读到 false循环永远不退出。这不是什么玄学而是很典型的可见性问题——线程 B 没有及时看到线程 A 对共享变量的修改。这类问题在本地开发环境很难复现因为单机单核、代码少、JIT 优化不激进时线程之间碰巧能通过主内存拿到最新值。但上了线上多核 CPU、高负载、JIT 编译一跑起来问题就藏不住了。所以想理解 JMM必须先搞懂硬件层面发生了什么。1.2 三级缓存与缓存一致性CPU 怎么保证大家看到同一份数据现代 CPU 为了弥补内存访问速度和计算速度之间的鸿沟普遍采用多级缓存L1 Cache 最靠近 CPU 核心访问延迟极低但容量很小通常几十 KB。L2 Cache 稍大一些每个核心独享或两两共享。L3 Cache 更大多核共享。无论缓存分几级核心问题都一样不同 CPU 核心各自持有同一份内存数据的副本当一个核心修改了副本其他核心怎么知道数据已经变了硬件层的答案是缓存一致性协议最常见的比较经典的是 MESI 协议。它把缓存行的状态抽象为 Modified、Exclusive、Shared、Invalid 四种核心之间通过总线嗅探机制传递状态变更。简单说一个核心修改了某缓存行并回写后其他核心中对应的缓存行会被标记为 Invalid下次读取必须重新从主内存加载。怎么理解这件事你可以把共享变量想象成会议室白板上写的一个数字每个参会人都默默在自己草稿纸上抄了一份。一个人走上台改写了白板上的数字如果其他人不抬头看一眼白板他们手里的草稿纸就还是旧数字。CPU 缓存就是这个草稿纸主内存就是这个白板。问题在于抬头看一眼白板是有代价的——需要同步、需要总线通信、需要失效处理。CPU 为了性能并不会每次读变量都跑去主内存。于是单靠硬件缓存一致性协议依然无法完全解决多线程环境下的数据一致性问题这就需要软件层面的内存模型来定义什么时候必须抬头看白板。1.3 指令重排与 as-if-serial 语义你以为的顺序并不等于执行顺序比缓存一致性更隐蔽的问题是指令重排。编译器和 CPU 为了提升执行效率只要不改变单线程语义就可能把代码顺序打乱。一个经典例子int a 1; // 操作1 int b 2; // 操作2 int c a b; // 操作3操作1和操作2谁先执行对最终结果没有影响CPU 就可能先执行操作2再执行操作1。这就是重排序它受 as-if-serial 语义保护——无论如何重排单线程程序的执行结果必须和按代码顺序执行的结果一致。但多线程环境下这种不影响单线程结果的重排可能影响另一个线程的判断。比如有一组指令// 线程1 config loadConfig(); // 1. 加载配置 initialized true; // 2. 标记配置已就绪 // 线程2 while (!initialized) { /* 等待 */ } useConfig(config); // 3. 使用配置如果编译器或 CPU 把指令1和指令2颠倒过来线程2看到initialized true时config可能还没被赋值拿到的就是一个空对象或旧值。这种问题光靠加锁能挡住一部分但对于读多写少的场景我们需要更轻量的同步手段。这也是 Java 引入了volatile关键字来禁止特定重排的原因之一。可以说Java 内存模型的出发点就是要在执行效率和正确性之间划一条清晰的界线让程序员知道在什么情况下代码可以对另一个线程可见什么时候允许 JVM 自由优化。2. JMM核心抽象主内存与工作内存2.1 抽象模型每个线程都有一张草稿纸Java 内存模型本身是一套抽象规则并不等同于实际的 CPU 缓存或物理内存布局。它定义了两个核心区域主内存所有共享变量都存储在主内存中对应概念上的共享区域。工作内存每个线程持有自己的工作内存保存了线程用到的变量的副本。线程不能直接操作主内存中的变量只能把主内存的值拷到工作内存再在工作内存中读写然后刷回主内存。继续用白板和草稿纸的类比主内存是那面白板工作内存就是每个人手里的草稿纸。不同线程之间不能直接互相传纸条所有信息交换都必须经过白板完成。这套抽象虽然简单却足以解释大量并发问题为什么线程 A 改了变量线程 B 看不到因为线程 B 还在读自己工作内存里的旧副本。为什么加了synchronized能解决因为进入同步块会强制刷新工作内存退出同步块会强制把修改写回主内存。为什么volatile能解决因为读 volatile 变量时JMM 要求必须从主内存取最新值写 volatile 变量时要求立即刷回主内存。理解这套抽象比死记volatile 保证可见性更本质。你能在脑海中画出一张图读操作从主内存拉数据写操作推数据回主内存同步机制控制什么时间点允许拉和推。2.2 volatile 与内存屏障一句话说清它到底做了什么网上讲 volatile 的文章很多结论不外乎两条保证可见性、禁止指令重排。但很多人不理解它底层怎么做到的面试被追问就卡壳。在 JMM 层面volatile 的读写操作会插入特定的内存屏障内存屏障相当于一条关卡指令它禁止屏障两侧的指令越过关卡重排同时强制对缓存一致性做出协调动作。HotSpot 实现里volatile 写之前会插入 StoreStore 屏障写之后插入 StoreLoad 屏障volatile 读之后会插入 LoadLoad 和 LoadStore 屏障。这些名字不用背你只要记住一个核心感受volatile 变量就像公共白板上的重点标记每次读都强制看白板每次写都强制更新白板并且不让编译器随便调整跟它相关的代码顺序。但 volatile 不是万能的。它不保证原子性比如多个线程同时对 volatile 变量做count依然会丢失更新。为什么因为count本质是读、加一、写回三步volatile 只能保证每一步都看到最新值但不能保证这三步作为一个整体不被其他线程打断。所以 volatile 的典型应用场景是一个线程写、多个线程读的标志位或者作为发布安全不可变对象的引用。2.3 原子性、可见性、有序性三兄弟怎么配合并发编程的三大特性在 JMM 下各有各的保障手段特性含义靠什么保证原子性一个或多个操作在执行过程中不被中断synchronized、Lock、CAS、基本类型读写部分场景可见性一个线程修改共享变量其他线程能立刻看到volatile、synchronized、Lock、final有序性程序执行顺序不因重排而混乱volatile 禁重排、synchronized 保证临界区内互斥这里有一个常见误区很多人以为加synchronized只是为了互斥其实它同时解决了可见性和有序性。进入 synchronized 块之前JMM 会要求线程清空工作内存中相关变量从主内存重新读取退出 synchronized 块时把工作内存中的修改刷回主内存。所以同步块内部的操作对于同一个锁保护的后续代码是全局可见的。而final字段也有自己的可见性保证在构造函数中设置 final 字段之后其他线程看到该对象的引用时final 字段必然是初始化之后的值不会被重排到构造函数之外。这也是为什么不可变对象天然适合做并发共享。三大特性不是孤立的实际编码时要一起考虑。比如单例双重检查锁时如果你只用synchronized而不用volatile依然可能因为指令重排导致另一个线程拿到未完全初始化的对象。这就是可见性和有序性同时被破坏的例子后面第 3 章有机会再展开。3. happens-before原则判断并发安全性的标尺3.1 八条规则分组背不再怕面试官JMM 规范给出了 happens-before 规则作为判断两个操作之间是否存在时序保证的依据。如果操作 A happens-before 操作 B那么 A 的结果对 B 可见并且 A 的执行顺序在 B 之前。八条规则按记忆难度我分成三组第一组基础规则程序次序规则一个线程内书写在前的操作 happens-before 书写在后的操作。传递性A happens-before BB happens-before C则 A happens-before C。第二组同步相关规则管程锁定规则对一个锁的 unlock happens-before 后续对这个锁的 lock。volatile 变量规则对一个 volatile 变量的写 happens-before 后续对这个变量的读。第三组线程生命周期相关规则线程启动规则Thread 对象的start()happens-before 该线程中的任何动作。线程终止规则线程中的所有操作 happens-before 其他线程对该线程的终止检测比如join()返回。线程中断规则对线程的interrupt()调用 happens-before 被中断线程检测到中断事件发生。对象终结规则一个对象的构造函数结束 happens-before 该对象finalize()方法的开始。提示八条规则不要死记要理解每一组背后解决什么问题。基础规则是前提同步规则是并发代码最常用的契约生命周期规则帮你判断线程间协作时的可见性。3.2 用 happens-before 推导一段真实代码单纯列规则没有说服力我们用一段代码实际推导一遍。// 线程1 config loadConfig(); // 操作A ready true; // 操作Bready是volatile变量 // 线程2 if (ready) { // 操作C读取volatile变量 useConfig(config); // 操作D }我们来推导根据 volatile 变量规则操作 B写 readyhappens-before 操作 C读 ready。根据程序次序规则在线程1内部操作 A happens-before 操作 B。根据传递性操作 A happens-before 操作 B happens-before 操作 C happens-before 操作 D所以线程2在ready true时调用useConfig(config)一定能看到线程1加载好的完整配置。这里最容易被忽略的是传递性带来的连锁保证。很多文章只会说volatile 保证可见性但为什么 volatile 能顺带保证其他普通变量的可见性答案就在这条推导链路里因为 volatile 变量读写形成了一个同步点它前面的普通写操作借着传递性也获得了可见性保障。3.3 实际开发中的应用用它自查代码是否安全我自己的习惯是写完一段并发代码先不用急着运行拿八条规则逐个检查一遍时序关系。具体步骤如下找出所有写共享变量和读共享变量的地方。看有没有哪条 happens-before 规则能建立读写之间的顺序。如果找不到任何规则这个共享变量的读写就有安全隐患必须加同步或改用 volatile。如果找到了再顺着规则检查另外弄清楚规则覆盖的是不是所有执行路径。举个实际例子生产环境很常见的优雅停机实现public class GracefulShutdown { private volatile boolean running true; public void shutdown() { running false; // 写volatile变量 } public void run() { while (running) { // 读volatile变量 // 业务逻辑 } } }shutdown()和run()之间靠 volatile 规则建立 happens-before 关系。只要shutdown()先执行run()里的循环就能看到最新值。但如果running不加 volatile这条规则就不成立程序就可能陷入死循环。遇到更复杂的同步逻辑就查 lock/unlock 规则、线程启动规则等。这套自查流程比起凭感觉加锁要靠谱得多也能显著减少并发 Bug 的反复排查成本。4. 从JMM到JVM工作中的应用与常见认知坑4.1 停止线程为什么一定要用 volatile 或 Lock经典死循环复盘回到开头那个订单推送服务的案例。为什么本地测试没事线上就不停不下来原因可能有三层第一层硬件层面。现代多核 CPU 架构下不同核心各自持有变量的缓存副本线程 B 一直在自己的工作内存中读stopped根本不感知主内存的变化。第二层JIT 层面。热点代码被 JIT 编译后如果编译器认为这个变量在循环体内没有被修改可能直接把while (!stopped)优化成读取一次标志位之后永远用寄存器中的旧值循环变成真正意义上的死循环。第三层JMM 层面。stopped没有 volatile 修饰也没有其他 happens-before 规则保证线程 A 的写对其他线程可见所以线程 B 的行为没有被规范约束。正确写法很简单private volatile boolean stopped false;这样既避免 JIT 把循环条件优化成常量又保证了线程 A 的修改在线程 B 读取时立即可见。另外提一句如果你用一个 boolean 标志位做不到精确控制可以考虑Thread.interrupt()。中断机制配合线程中断规则天然有 happens-before 保证不需要额外加 volatile。很多老手偏爱 interrupt 而不是自研标志位这是有 JMM 依据的。4.2 别再搞混 JMM 和 JVM 运行时数据区顺带回答Java 8 还有没有方法区这是面试和日常沟通中最容易混淆的两个概念。JMMJava 内存模型是一个并发正确性的抽象模型讨论的是线程如何通过主内存交换数据、什么操作对什么线程可见跟堆、栈没有直接关系。JVM 运行时数据区讨论的则是进程内内存的分区结构堆、虚拟机栈、本地方法栈、程序计数器、方法区以及它的实现元空间。很多人问Java 8 的 JVM 内存模型里还有没有方法区这个问题的措辞其实就有歧义。准确说方法区是 JVM 规范中的一个逻辑区域Java 8 里它仍然存在但 HotSpot 实现发生了变化——永久代PermGen被移除改由元空间Metaspace来实现方法区元空间不再占用堆内存而是使用本地内存。所以严格来讲规范层面方法区还在只是实现方式和内存位置变了而永久代这个实现概念在 Java 8 中确实没有了。那 JMM 和 JVM 内存区域有关系吗有但间接。比如new出来的对象放在堆里而线程对对象字段的访问受 JMM 约束再比如方法区中的类元数据如果被多个线程同时加载也要靠 JMM 保证安全发布。理解不到这一层就容易把堆内存溢出和并发不可见混为一谈。4.3 锁消除、锁粗化与内存模型的关系以及下篇预告JVM 在运行时还会做一些针对锁的优化理解这些优化需要回头再看 JMM。HotSpot 里有几个比较典型的锁消除JIT 分析发现加锁对象只被当前线程访问就会把锁直接去掉。比如局部变量加synchronized实际上没有任何并发竞争。锁粗化如果代码里对同一个对象连续加锁、解锁JIT 会把锁的粒度扩大减少反复锁定的开销。偏向锁、轻量级锁在不同竞争级别下锁对象会从偏向锁逐渐膨胀为重量级锁本质都是在 JMM 保证不被破坏的前提下尽量减少同步带来的性能损失。这些概念在上篇里点到为止因为它们需要先理解 JMM 的底层规则不然只会背名词。下一篇我计划重点展开volatile 内存屏障的源码视角、synchronized 的 Monitor 与对象头、final 字段的安全发布语义、以及如何用这些知识定位实际线上的并发问题。毕竟内存模型这种底层知识光有理论不落到排查手段上还是空中楼阁。最后说一句个人体会。学 JMM 最忌讳的是直接背结论然后面试时把结论丢出来却对为什么两眼一抹黑。我建议踩过并发坑的朋友都自己做一个小实验写一个双线程共享变量循环对比加不加 volatile 的运行差异再写一个双重检查锁单例试着自己用 happens-before 规则推导一遍安全顺序。把这两步做完你对 JMM 的理解会比我见过的很多面试者要扎实得多。