资讯详情

资讯详情

Java内存模型深度解析:从可见性Bug到volatile与happens-before

这段代码我至今印象深刻。一个线上服务抖动排查了整整一天半最后定位到的是一个连volatile都没加的boolean标志位。线程A把标志置为true线程B在主循环里死死读不到新值CPU时间片被空转耗尽。那时我才真正意识到Java内存模型JMM不是面试八股而是每一个写并发代码的人早晚要撞上的墙。本文从一次真实可见性故障讲起把happens-before、volatile、synchronized、final四条核心语义逐条拆开讲清楚它们各自承诺了什么、又没承诺什么。适合所有写过Thread、synchronized但没系统梳理过JMM的Java开发者也适合准备面试时想要一次说透的人。1. 从一次诡异可见性Bug说起JMM到底在管什么1.1 那段让我排查了一整天的代码先看这个简化后的复现版本它完美还原了当时的问题public class VisibilityDemo { private boolean running true; public void stop() { running false; } public void work() throws InterruptedException { int i 0; while (running) { i; } System.out.println(循环结束共执行 i 次); } }主线程调用stop()把running置为false工作线程的while (running)却可能永远跳不出来。我当年第一反应是这不科学一个线程改了变量另一个线程怎么会看不到后来查了大量资料才明白两个线程各自运行在不同的CPU核心上每个核心都有自己的一级、二级缓存线程对running的读取和写入都是针对缓存里的副本而不是直接操作主内存。while (running)这个看起来简单的读取可能每次都被编译器优化成从寄存器取值寄存器里存的还是旧值true。哪怕另一个核心已经写回了主内存这个核心的缓存行没失效当前线程照样读不到——这就是经典的可见性问题。1.2 主内存与工作内存JMM的核心模型JMM规范里定义了一套抽象模型用来统一描述线程、缓存、主内存之间的关系。规范把内存分成了两块主内存和工作内存。主内存是所有线程共享的存放变量的正宗值每个线程各自有一份工作内存线程对变量的所有操作读、写、赋值都必须在工作内存中进行不能直接读写主内存。用生活化的方式理解主内存是公司统一存放文件的档案室工作内存是每个人工位上的文件复印件。你改文件是先改自己工位上的复印件改完了觉得有必要再交回档案室让档案室更新。别的同事能不能看到你的修改取决于你把复印件交回去的动作是否被执行、以及同事是否重新去档案室取新版复印件。JMM规定一个线程对共享变量的修改必须经过写回主内存这个过程其他线程才能感知到——但这个过程什么时候发生、是否需要发生JMM没法强制。这就是为什么需要一个显式的同步机制来约束线程之间的协作。volatile、synchronized、final这些关键词本质都是JMM提供出来、让开发者主动控制主内存与工作内存之间刷写时机的工具。1.3 三大并发顽疾可见性、有序性、原子性并发编程有三大经典问题可见性、有序性、原子性。可见性上面已经讲了根源在CPU缓存和寄存器有序性则是编译器和处理器为了提升性能对指令进行重排导致的原子性则关乎一个操作是否能在执行过程中不被插入其他操作。public class Counter { private int count 0; public void increment() { count; // 看起来是一行代码实际是三步 } }count不是原子操作它可以拆成读取count的值、计算count1、把结果写回count三步。两个线程同时执行可能出现都读到旧值0、都写回1的情况最终结果小于期望值2。JMM对这三种问题的处理方式不同原子性指令由处理器原语保证比如compare-and-swapJMM更关注的是如何通过规则把可见性和有序性问题管住。happens-before规则正是JMM解决可见性与有序性问题的一整套判据。2. happens-beforeJMM给并发世界定下的八条铁律2.1 为什么有了happens-before规则就能保证可见性我在初学JMM时有个困惑happens-before翻译过来是先行发生这个发生到底是什么意思难道不是按时间先后吗关键在于happens-before不是简单的时间上在前面。它的核心价值是如果操作A happens-before操作B那么A的执行结果对B是可见的。这个可见包含两层意思A对共享变量的修改在B执行时能读到新值同时A的操作不允许被重排序到B之后。JMM不需要你去理解CPU缓存何时刷新、内存屏障具体怎么插入它直接告诉你一套推理规则只要你的代码逻辑满足某条happens-before规则那么可见性和有序性就有了保证。你不需要关注底层怎么实现规范已经把细节封装好了。八个规则听起来很多实际上可以分成三组理解程序次序规则管线程内部的顺序监视器锁规则、volatile变量规则管线程间的协作线程启动、中断、终止、终结、传递性管生命周期和推理链条。2.2 八条规则逐一拆解先看完整的规则列表规则名称核心内容典型使用场景程序次序规则同线程内按代码顺序书写的前一个操作happens-before后一个操作同一线程中的普通语句、循环监视器锁规则解锁操作happens-before同一把锁的后续加锁操作synchronized代码块/方法volatile变量规则对volatile变量的写happens-before后续对该变量的读状态标志位、DCL单例线程启动规则线程对象的start()方法happens-before该线程内的任何动作启动子线程前设置共享值线程终止规则线程内所有操作happens-before其他线程对该线程的join()返回用join()等待线程结束后读结果线程中断规则调用interrupt()发生在被中断线程检测到中断事件之前中断标志位的判断对象终结规则对象构造完成happens-before它的finalize()调用不建议主动依赖传递性若A happens-before BB happens-before C则A happens-before C多规则联合推导程序次序规则最容易引起误解它说的按代码顺序指的是在单线程内保持语义一致的情况下允许重排序。int x 1; int y 2;这两行交换执行结果也没区别那编译器就可能真的交换但如果y的默认值是依赖x的比如int y x 1;那编译器绝对不可能把赋值y排到前面。程序次序规则保护的是单线程内的数据依赖而不是绝对按书写顺序执行。2.3 传递性与实战推导如何推理一段代码是否安全传递性是我实际用得最多的一条规则。举个例子一个典型的多线程协作场景class Worker extends Thread { private int data 0; private volatile boolean ready false; public void setData(int value) { data value; // ①普通写 ready true; // ②volatile写 } Override public void run() { while (!ready) { // ③volatile读 Thread.yield(); } System.out.println(data); // ④普通读 } }推理过程①是普通写②是volatile写在同一个线程内由程序次序规则得出① happens-before②。线程B在③读到ready true意味着它读到了②的写入由volatile变量规则得出② happens-before③。④和③在同一个线程内有③ happens-before④。顺着传递性① happens-before② happens-before③ happens-before④于是①对data的写入对④读取data一定可见。这段推理的价值在于它解释了为什么volatile标志位 普通变量可以组合出线程安全volatile的作用是把它之前的写入和它之后的读取串起来。如果你没有梳理出这条链条光靠我试了能跑是危险的因为不同JVM、不同CPU架构下的表现可能完全不同运气好跑通了不代表是安全的。3. volatile变身记JSR-133之后的可见性与有序性保障3.1 volatile真正承诺了什么又不承诺什么很多Java开发对volatile的理解停留在线程之间共享变量加个volatile就安全了这个理解一半对一半错。volatile的两个核心语义是可见性和禁止指令重排序它从来不承诺原子性。JSR-133规范Java 5及以后对volatile语义做了明确定义对于一个volatile变量的写会立即写回主内存并且在写之前的普通读写操作不允许被重排序到它之后对于一个volatile变量的读会直接从主内存读取并且在读之后的普通读写操作不允许被重排序到它之前。这两条约束下来等于把我写完了和你看到了这两个动作绑在了一块。之前举的ready标志位例子就是volatile典型用法一个线程写标志位另一个线程读标志位普通数据的修改通过标志位的传递性被安全发布出去。但你不要指望volatile解决累加问题public class VolatileCounter { private volatile int count 0; public void increment() { count; // 依然不是线程安全的 } }count的读-改-写三步仍然可能交叉两个线程都读到旧值0各自加一后都写回1。volatile保证了写回时其他线程立刻能看到但无法阻止两个线程基于同一个旧值分别运算。这也是面试里最高频的考点volatile能不能替代synchronized答案是不能它只解决可见性不解决复合操作的原子性。3.2 内存屏障视角下的volatile实现从底层实现看volatile的可见性和重排序约束是通过内存屏障memory barrier实现的。JMM对volatile的读写插入了四类屏障屏障类型指令示例作用LoadLoad读volatile后插入禁止后续普通读被重排到volatile读之前LoadStore读volatile后插入禁止后续普通写被重排到volatile读之前StoreStore写volatile前插入禁止前面的普通写被重排到volatile写之后StoreLoad写volatile后插入禁止后续的读被重排到volatile写之前同时保证写对其他处理器可见以写为例x 1; volatileFlag true;这两行JVM会在volatileFlag true之前插入StoreStore屏障保证x 1必须先落主内存在它之后插入StoreLoad屏障保证后续读不会被提前。这套屏障的存在就是volatile禁止重排序的硬件级基础。理解这些屏蔽细节在排查生产环境并发问题时非常有用。比如你怀疑某段代码被重排序了可以先想清楚这里有没有volatile如果没有编译器可能把前面的普通写挪到后面去那么别的线程读到的是被重排序后的中间状态——这解释了为什么有些看起来没问题的代码在高并发下时好时坏。3.3 volatile的经典误用计数器、DCL与状态标志volatile的经典使用场景我总结成三种状态标志、一次性安全发布、DCL单例的辅助。状态标志是最合适的场景比如前文的running标志位用volatile boolean running true就足够。一次性安全发布适合某个线程先写好配置其他线程启动后读取的场景。DCL单例模式则是volatile的进阶应用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; } }instance new Singleton()并不是原子操作它大致分三步分配内存、调用构造器初始化字段、把instance指向这块内存。若不加volatile第三步可能在第二步之前被另一个线程看到——另一个线程拿到一个分配了内存但字段还是默认值的半成品对象。volatile禁止了这种重排序DCL才能成立。需要特别提醒的是很多网上代码在双重检查里加volatile但没讲清楚为什么。没有volatile的DCL在极端并发下确实可能出问题我自己就见过一次单例对象里的字段全是0/null的诡异案例最后排查下来就是DCL缺volatile导致的。4. synchronized的可见性传递锁不只是互斥4.1 锁规则的精确含义很多初级开发者以为synchronized只是锁住代码块防止多线程同时执行实际上它的另一个同等重要的职责是可见性保障。JMM监视器锁规则说解锁操作happens-before同一把锁的后续加锁操作。翻译成大白话线程A在synchronized代码块里改了一堆变量释放锁线程B获取同一把锁进入代码块B一定能看到A做的所有修改。注意同一把锁这个限定条件不同锁之间没有这个保证。这背后其实是一套强制性的内存操作线程A释放锁时会把工作内存中修改过的共享变量全部写回主内存线程B获取锁时会把主内存中的最新值重新加载到工作内存。一写一读可见性天然就有了保证。所以synchronized除了互斥还自带全量刷写效果——它比volatile的可见性范围更大volatile只保证被修饰的那个变量synchronized保证的是进入临界区之前的所有共享变量修改。4.2 从工作内存视角理解锁的刷写时机来看一个典型的锁可见性例子public class LockVisibility { private int counter 0; private int total 0; public synchronized void update() { counter; total counter; } public synchronized int getTotal() { return total; } }如果counter、total不是volatile两个方法用synchronized保护后依然能保证getTotal()读到最新结果原因就在锁规则的刷写时机线程A执行update()修改total方法结束前JVM会释放锁并刷新工作内存线程B执行getTotal()方法开始时JVM会加锁并加载主内存最新值。整个过程不需要任何额外关键词锁内部的刷写逻辑已经完成。有意思的是这个刷新并不是技术上的精细操作——JVM在实现synchronized时底层通过monitorenter/monitorexit字节码指令配合内存屏障主要是StoreLoad完成刷写。也就是说synchronized的可见性开销在锁机制本身而不是你额外加的什么关键字。这也是为什么有些代码里既要synchronized又要volatile其实是冗余的锁已经把可见性覆盖了。4.3 锁与volatile的协作模式synchronized和volatile不是二选一的关系它们各有擅长。我的经验是先用一个简单的判断逻辑如果多个线程要同时修改一个共享变量且修改操作是复合操作用synchronized或AtomicXxx如果只是一个线程写、多个线程读的状态切换用volatile就足够如果既需要复合操作的原子性又需要某种标志位级别的快速感知可以组合使用关键数据用锁保护状态标志用volatile暴露。public class OrderService { private final Object lock new Object(); private int pendingCount 0; private volatile boolean shutdown false; public void enqueue() { synchronized (lock) { pendingCount; } } public void startShutdown() { shutdown true; // volatile快速通知 synchronized (lock) { // 这里还能拿到最新的pendingCount } } }shutdown负责让工作线程快速感知停机信号pendingCount的复合操作交给锁保证原子性。两套机制互不干扰各司其职。5. final语义构造安全的最后一道防线5.1 final在JMM中的独特地位final在JMM里是一个经常被低估的关键字。很多人只当它是变量不可再赋值却不知道JSR-133为final字段单独定了两条特殊规则final字段的写在构造器内对final字段的写入与随后把构造完成的对象引用发布给其他线程之间不允许重排序引用绕过构造器之外一个对象的final字段若能通过某个线程发布给另一个线程那么在构造器执行完之后final字段的值是完整且正确的。这两条规则解决的是对象发布安全问题。举个例子public class SafeObject { private final int value; private int normalValue; public SafeObject(int v) { this.value v; // final写 this.normalValue v * 2; // 普通写 } }如果另一个线程拿到了SafeObject实例它保证能读到正确的value但normalValue不保证——除非发布过程有happens-before保护。JSR-133之前final字段也可能被重排序线程可能看到value的默认值0当时这是个严重问题。Java 5之后规范禁止了final字段的写与构造器外发布之间的重排序让不可变对象的安全发布从此有了明确依据。5.2 this逃逸与安全的对象发布final语义有一个重要前提对象不能在构造完成前被提前发布出去。最典型的坑是this逃逸public class UnsafePublish { private final int id; public UnsafePublish() { new Thread(() - System.out.println(this.id)).start(); // 危险的构造器内启动线程 this.id 42; } }在构造器里启动线程并传递this子线程可能会在id 42执行之前就运行读到默认值0。即使id是final也不能挽救——因为final的保护是从构造器完成之后才开始生效的构造器没执行完就泄漏了引用一切都白搭。正确做法是构造器在完成所有final字段赋值之后再发布引用不要在构造器内启动线程、注册监听器、调用可重写方法。这也是Effective Java里不要在构造器中调用可覆盖方法的思想来源。5.3 final DCL单例模式里的隐藏前提回到DCL单例除了volatile之外final其实可以做另一种设计public class Singleton { static class Holder { static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }静态内部类方式利用了类初始化时的隐式同步Singleton的静态final字段在类加载阶段完成赋值JVM保证类初始化阶段的写入对使用该类的所有线程可见。这比DCL更简洁也不需要volatile。DCL加volatile的方式适合需要延迟初始化并且实例创建开销大的场景静态内部类则适合大多数标准单例。如果你的单例对象包含final字段即使走DCLfinal规则也会给对象内部的final字段多一层发布安全的保障。比如单例对象里有个final MapString, String config在volatile禁止外层重排序之外final字段本身的写也不允许逃逸重排双重保障之下发布安全性更好。6. 一个综合案例缓存管理器的可见性设计聊了这么多理论用一个小而完整的案例把它们串起来。假设要写一个轻量缓存管理器支持单线程写入、多线程读取要求读到的一定是完整的最新快照public class SnapshotCacheK, V { private final MapK, V data new HashMap(); private volatile MapK, V snapshot Collections.emptyMap(); public void put(K key, V value) { synchronized (this) { MapK, V updated new HashMap(data); // 拷贝当前数据 updated.put(key, value); // 写入新值 data.put(key, value); // 维护主版本 snapshot Collections.unmodifiableMap(updated); // volatile发布快照 } } public V get(K key) { return snapshot.get(key); } }设计思路拆解put方法用synchronized保证拷贝写入更新主版本发布快照整体是原子的snapshot用volatile保证读线程拿到的引用一定是最新发布的快照快照本身是Collections.unmodifiableMap包装的不可变对象内部字段通过不可变设计保证读取安全。读线程无锁直接读性能极高写线程罕见地拷贝整份Map对读多写少的场景非常合适。这个案例把本章前面讲到的三类语义全部用上了锁负责原子性和写操作的可见性传递volatile负责发布最新不可变快照final不可变对象设计保证快照内数据在发布后的安全性。三者的分工一目了然不会再出现用锁还是用volatile的选择困难。7. 踩坑记录三个让我刻骨铭心的并发问题7.1 一个优化掉的volatile服务器诡异的间歇性卡顿某个项目里有个高频状态标志当时觉得volatile性能差就换成了普通boolean结果服务在流量高峰时周期性卡顿。事后分析是写线程改了标志读线程一直读到旧值进入了一个空转等待分支把CPU打满。加上volatile之后问题立刻消失。这让我明白volatile的性能开销远小于错误同步造成的故障成本规范之所以存在就是为了约束你不要瞎优化。7.2 DCL缺volatile导致的半成品对象另一个项目里单例对象内部有个ListString字段初始化时填充了一些默认配置。偶发出现某个节点拿到单例后List是null隔三差五才出现一次。排查到DCL加上volatile后问题再没复现。这个案例直接印证了前面讲的分配内存、初始化、赋值引用三步重排序问题。所有DCL单例必须加volatile——除非你换静态内部类方案但凡是DCL就不能省。7.3 构造器内启动线程不可变对象的发布灾难还有一次某人写的配置类在构造器里启动了一个异步上报线程把this传了过去。上线后偶尔出现配置项读取为默认值的情况。根因就是this逃逸异步线程可能在final字段赋值前就读到了旧值。修复方式是把线程启动移到构造器之外用start()显式触发。final语义保护的是构造完成后发布的对象不是构造过程中的泄漏。这三个坑的共同点都是表面能跑关键时刻炸的类型。并发问题的可怕之处在于它的复现往往依赖特定的CPU调度时机单测跑一百遍都没事上线跑几天才露馅。所以我在团队里定了一条规矩涉及共享变量可见性的地方必须显式写出同步手段不允许靠应该没事吧来赌运气。8. 面试中如何把JMM讲出层次感既然标题里带着深入两个字最后聊聊面试场景——毕竟这是多数人第一次系统接触JMM的地方。太多候选人一上来就背happens-before八条规则背完就没了。真正的层次感是这样的第一层先讲清楚为什么要JMM——CPU缓存、指令重排导致可见性和有序性问题JMM就是一套规则约束编译器和处理器的行为边界让开发者能在多线程下写出行为确定的程序。第二层把happens-before作为推理解题工具来讲而不是名词堆砌。现场写一段代码演示怎么从程序次序规则、volatile规则、传递性推出某个写对某个读可见考官立刻知道你理解的是机制而不是概念。第三层谈到volatile和synchronized时主动对比它们的能力边界——volatile只保证可见性和有序性synchronized还保证原子性和互斥。顺手抛出DCL单例为什么要volatile、为什么有半成品对象问题深度就有了。第四层把final的发布安全语义补上讲清楚不可变对象在多线程中为什么安全以及this逃逸的危险。这一层能说透的候选人基本就在深入理解的区间了。如果面试官再追问底层可以提内存屏障StoreStore、StoreLoad这类但注意区分这是HotSpot在x86架构上的实现方式不是JMM规范的一部分。讲清楚规范定义问题、实现提供方案这个层次比把每个屏障的细节背得滚瓜烂熟更能体现水平。9. 写在最后并发没有银弹但JMM是必读的地图写完全文再回顾我的整体感觉是JMM并不难难的是把规则翻译成直觉。刚接触时我也觉得happens-before是抽象的数学定理直到被线上故障教育过几次才真正建立起那种看到一段并发代码马上能判断哪里缺了同步的条件反射。我最想分享的实操建议只有一条给团队制定一份小的并发检查清单。包含几项共享变量是否加了volatile或锁保护复合操作是否依赖了volatile的原子性这是大忌DCL单例是否加了volatile构造器里是否泄漏了this每次代码评审对照清单过一遍能拦住绝大部分并发隐患。JMM的学习曲线在于它不像框架API那样背了就完它是一套需要反复推演才能内化的思维模型。但一旦建立起来你对Java并发的理解会进入完全不同的层级——不再是靠经验碰运气而是按规则做推导。这份从容花多长时间去学都值。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →