深入Java内存模型:原子性、可见性与Happens-Before规则实战解析
做了这么多年Java并发开发如果说哪个概念让我从“会用锁”变成“真正理解锁”那一定是JMMJava内存模型。它的设计目标很多人只在面试时背过“原子性、可见性、有序性”但真正遇到线上并发Bug时才明白这九个字的分量。上篇文章我梳理了JMM出现的背景和基础目标屏蔽不同硬件架构的差异、为并发编程定义一套统一的语义规范。这篇继续往下聊重点放在设计目标如何落地成具体规则、这些规则在代码里怎么体现、以及我用JMM解释过的几个真实线上问题。适合所有写过synchronized、volatile但对“为什么”还似懂非懂的开发者。JMM的设计目标不是让你把每个变量都加上锁也不是逼你成为JVM源码专家而是在“正确性”和“性能”之间划一条清晰、可推导的线。这条线从哪里开始到哪里结束就是今天要讲的全部内容。1. JMM设计目标到底在解决什么问题1.1 从一个积分丢失事故说起两年前我处理过一个网上商城的积分业务。用户下单后服务端会对积分做累加高峰期出现大量“下单成功但积分少加”的投诉。看业务代码逻辑非常简单从数据库查出当前积分加N再写回去。照理说每个请求独立事务不应该错。问题出在代码里有个缓存层为了减少数据库压力开发同学把当前积分放在了Redis之外的一个静态变量里多个线程直接读改写。虽然Redis有原子操作但静态变量没有。两个线程同时读到100各自加5再写回105实际应该110。这其实就是最典型的可见性加原子性叠加问题。JMM要解决的第一个目标就是让这种“多个线程读写共享变量”的行为可以被预测。Java是跨平台语言同一套代码跑在x86上、ARM上、不同版本JVM上结果必须一致。如果JMM不存在每个CPU架构都有自己的内存一致性模型程序员写并发代码就得根据不同芯片去适配那Java的“一次编写到处运行”就彻底成了笑话。所以JMM的设计目标第一条定义一套抽象的内存模型规定多线程共享变量的读写什么时候可见、什么时候有序、哪些操作必须互斥。JVM和CPU必须在满足这套抽象的范围内做优化而不是让程序员去面对千奇百怪的硬件差异。1.2 JMM给我们的核心承诺是什么如果你去看JSR-133就是制定现代JMM的规范文档会发现它对设计目标的描述里有一句非常关键的话只要程序没有数据竞争那么程序的执行结果就和完全顺序执行的结果一致。这句话是整个JMM的基石我建议你把它抄下来背熟。所谓“没有数据竞争”指的是程序中不存在这样的场景两个线程同时访问同一个共享变量至少一个线程是写操作且这些访问没有通过同步机制约束。只要你的代码满足了“无数据竞争”JMM就向你保证最终看到的结果就好像所有操作是按某个全局顺序一条条执行下来的而且这个顺序和你代码里的逻辑顺序一致对单线程而言。为什么这个承诺重要因为它给了普通开发者一个非常实用的推导工具。你写多线程代码时不需要关心底层CPU缓存一致性协议是MESI还是MOESI不需要知道ARM的弱内存序有多“弱”。你只需要保证共享数据的访问都在同步块里或者用了volatile、final这类正确的手段。剩下的JMM替你兜底。但这不等于JMM要求所有程序都退化成单线程。它允许编译器做指令重排序、允许CPU乱序执行、允许变量在缓存里待着不立刻刷新主存。这些优化的前提是单线程语义不能被改变。如果一段代码是正确同步的那优化完了之后的多线程结果仍然要“看起来像”顺序执行。这个度怎么拿捏就是JMM设计目标的下半场。我习惯用一个后厨类比来解释这个目标餐厅规定每桌菜必须按“下单、备菜、炒制、上菜”的顺序完成但后厨内部可以把切肉和切菜的顺序调换也可以先把两桌的菜一起炒了只要每位客人最终吃到的菜是对的后厨的效率就能最大化。JMM就是那个总规则硬件和JVM就是后厨它们有自己的优化空间但边界由JMM划定。2. JMM设计目标的核心内容三大特性与Happens-Before2.1 原子性不是你以为的那种原子JMM关于原子性的设计目标可以用一句话概括保证最基本的读写是原子的但不保证复合操作是原子的。在主流64位JVM上对int、long、double、引用类型变量的单个读写本身是原子操作。早期32位JVM上一个64位的long或double可能被拆成两个32位的高低位操作两个线程可能分别读到“半个新值”。JMM的设计目标就是消除这种不确定性所以明确规定任何JVM实现都必须保证对volatile long/double的读写是原子的而对普通long/double也要求尽量做到原子实在做不到也必须保证不能产生“字撕裂”——也就是不能读到既不是旧值也不是新值的垃圾中间值。但真正让开发者踩坑的是i这种“看起来很简单”的操作。它实际上是三条指令读取变量、加1、写回变量。JMM不管这三条指令之间是否原子它只保证单条读或写本身的原子性。所以两个线程同时执行i可能两个都读到同一个旧值然后都写回同一个新值最终少加了一次。JMM设计目标里原子性的完整实现要靠显示同步synchronized方法或代码块、Lock.lock/unlock、AtomicInteger的CAS机制。这里面有个容易被忽略的细节JMM并不要求synchronized内部的操作一定是原子的而是要求整个临界区在互斥基础上被当作一个整体来执行。你在锁里做了十次累加其他线程要么全部看不到要么全部等锁释放后再进入不会有中间状态漏出去。在设计层面原子性的目标本质上是“不要让线程互相撕裂数据”而不是“让所有代码变成大号同步块”。你可以用CAS做无锁累加也可以用synchronized做重量级互斥JMM都支持但它不会替你决定用哪种。2.2 可见性缓存与主存之间的“最终一致性”可见性问题的根源是现代CPU引入了多级缓存和寄存器。JMM为了统一描述抽象出了一个“主内存”和每个线程的“工作内存”的概念。虽然这个模型和真实硬件不完全一样但非常适合用来推演问题。在JMM的抽象里每个线程有自己的工作内存所有共享变量存在于主内存。线程A读一个变量时会先在工作内存里找找不到或认为过期再去主内存加载线程A改了变量先写回工作内存等某个时机再刷到主内存。如果这个“刷新的时机”迟迟不来线程B读到的永远是旧值。JMM设计目标中的可见性承诺是只要两个线程之间存在Happens-Before关系那么前一个线程对共享变量的写操作对后一个线程一定可见。注意这不是“最终一致”而是有明确边界的必须有同步关系搭桥。最常见的桥就是volatile。JMM规定对volatile变量的写操作会立即刷新到主内存对volatile变量的读操作会从主内存重新读取。同时volatile写和volatile读之间还能建立Happens-Before关系线程A写一个volatile变量后线程B读同一个volatile变量之前线程A做的所有其他写操作对线程B都可见。这个设计目标非常务实。它没有要求所有变量时时刻刻保持可见因为那会牺牲掉所有缓存优化。它只要求在关键同步点可见性必须得到保证。你用一个普通变量传flag线程一直不退出就是因为线程里的flag被优化成寄存器副本没有重新去主内存读。你把它标成volatileJIT就知道这里不能缓存必须每次读内存。2.3 有序性编译器优化与指令重排序的权衡有序性可能是JMM设计目标里最违反直觉、也最容易引起线上事故的一条。JMM并不保证你的代码会严格按照书写顺序执行。编译器可能把不相关的两条指令换序CPU可能把访存指令乱序执行缓存系统可能让不同线程看到的写入顺序不一致。这些重排序在单线程下没有感知但在多线程下会导致非常诡异的现象。JMM引入了一个as-if-serial原则不管怎么重排序单线程执行结果不能改变。这是JMM给重排序划的第一条线。比如a1; b2; 只要最终的a和b是正确的编译器可以先给b赋值再给a赋值。对单线程程序这完全没问题。但对多线程程序as-if-serial不够。两个线程各自执行独立的赋值操作如果它们的操作顺序被重排另一个线程观察到的顺序就可能和代码顺序相反。经典的例子// 线程1 a 1; flag true; // 线程2假设flag是普通变量 while (!flag) { } System.out.println(a); // 理论上期望输出1实际可能输出0如果flag是普通变量线程2可能先看到flag变成true但a的赋值还没“到达”。即使flag是volatile如果a和flag之间没有正确的Happens-Before传递也可能出问题。JMM的做法不是禁止重排序而是定义了一组规则告诉你哪些重排序是允许的哪些同步方式能阻止危险的重排序。volatile变量之所以特殊是因为JMM给volatile加了一道内存屏障volatile写之前的普通写操作不能重排到volatile写之后volatile读之后的普通读操作不能重排到volatile读之前。通过这种屏障JMM保证了“先写数据、再写标志”的顺序不会反过来。JMM设计目标里对有序性的要求总结起来是你正确使用了同步机制JMM就保证重排序不会破坏你的程序语义你加锁不够或漏了volatileJMM就不承诺任何顺序出问题只能自己认。2.4 Happens-Before规则设计目标的“执法条款”如果说JMM设计目标是“法律条文”那Happens-Before规则就是具体的执法条款。它定义了一系列场景只要场景满足前面的操作就一定发生在后面的操作之前并且前面对共享变量的写对后面的读可见。Java规范里列出了八条核心规则我整理成一张表平时写并发代码时对照着看比死记硬背高效得多规则名称内容典型场景程序顺序规则同一个线程内书写在前面的操作Happens-Before书写在后面的操作单线程代码的天然顺序监视器锁规则对一个锁的解锁Happens-Before后续对这个锁的加锁synchronized代码块volatile变量规则对一个volatile变量的写Happens-Before后续对这个volatile变量的读volatile标志位线程启动规则Thread.start()调用Happens-Before该线程的每一个操作线程内能看到主线程启动前的修改线程结束规则线程内所有操作Happens-Before其他线程检测到该线程终止如join返回Thread.join()中断规则对线程调用interrupt() Happens-Before被中断线程检测到中断事件中断处理终结器规则对象构造函数结束Happens-Before该对象finalizer的开始自定义finalize传递性A Happens-Before BB Happens-Before C则A Happens-Before C多段同步关系串联Happens-Before的价值在于它可以跨同步机制传递。典型场景线程A先改一堆数据再释放锁线程B获得同一把锁后不仅能看到锁内的修改还能看到A获得锁之前的所有修改。这个“之前”不是物理时间的前而是Happens-Before关系的前。理解这层逻辑你才算真正理解了JMM。我在团队里常跟新人说判断一段并发代码是否安全不要靠“感觉”拿出这张表看两段数据访问之间能不能找到一条Happens-Before链。如果能就安全不能就别赌加同步。3. JMM如何实现这些目标内存屏障、同步语义与安全发布3.1 内存屏障硬件层面的“语义翻译官”内存屏障是JMM设计目标落到真实硬件的最关键机制。JMM不是在真空中运行的它最终要靠CPU提供的能力来实现。不同CPU架构的能力差异非常大x86的访存模型相对较强ARM和PowerPC则很弱。如果JMM不定义一套统一的“理想化屏障”JVM很难在这么多架构上保持一致。JMM抽象出了四种屏障LoadLoad屏障两个读操作之间前一个读一定先于后一个读完成。StoreStore屏障两个写操作之间前一个写一定先于后一个写对其他处理器可见。LoadStore屏障读操作一定先于后面的写操作完成。StoreLoad屏障写操作一定先于后面的读操作完成这是最贵的一种屏障因为它要求先把写缓冲区的数据刷出去再执行读。volatile的读写语义就是通过这些屏障组合实现的volatile写之前插入StoreStore之后插入StoreLoadvolatile读之后插入LoadLoad和LoadStore。这样从Java语义层面看volatile的“禁止重排序”和“写后立刻对其他线程可见”就得到了保证。JVM拿到这些屏障后会针对具体架构翻译成对应的CPU指令或类似机制比如x86上使用mfence/lfenceARM上使用dmb等。这个设计目标告诉我们一件事JMM选择的是“半抽象半落地”的路线。它不直接依赖某个具体CPU原语也不完全在空中楼阁里谈逻辑而是定义了一套理想化的屏障模型再要求每一个JVM实现去映射到目标平台。正因为如此Java程序才能在弱内存模型的手机上跑出和x86服务器一致的结果这背后都是JMM在兜底。3.2 volatile最轻量的可见性保证理解了内存屏障volatile的语义就非常清晰了。它的设计目标是在最轻量、不阻塞线程的前提下保证单次读写的可见性和一定的重排序限制。先说它能保证什么可见性线程A对volatile变量的修改会对后续读取该volatile变量的线程B可见且A在写volatile之前做的所有操作B也能看到。有序性禁止编译器把普通写操作移到volatile写后面也禁止把普通读操作移到volatile读前面。基本原子性对volatile变量的单个读或写是原子的包括long和double。再说它不能保证什么复合操作不原子volatile int count然后count依然不安全。不提供互斥两个线程同时写volatile变量没有排他性后写覆盖先写。JMM设计目标里给volatile的定位是“轻量级同步原语”适合做状态标志、开关、发布对象引用。比如停止线程的flag、懒加载的引用、缓存是否初始化的状态位。它不适合做计数器累加更不适合做需要read-modify-write的中间状态。项目中我常用volatile做“初始化完成”的信号后台线程加载配置全部加载完成后把volatile boolean ready设为true其他线程读ready为true后就可以安全使用配置数据。因为Happens-Before的volatile规则加上传递性加载配置时的普通写操作对后续读者可见。这个模式非常轻量压测下性能损失远小于锁。3.3 synchronized与锁的JMM语义synchronized的JMM语义比volatile更重核心发生在两个字节码指令上monitorenter和monitorexit。JVM规范规定monitorenter加锁代表获取监视器锁monitorexit解锁代表释放监视器锁。JMM的锁规则要求解锁操作Happens-Before后续对同一把锁的加锁操作。这意味着什么线程A进入synchronized块修改共享数据然后释放锁。线程B等待并获得同一把锁进入临界区后它能看到A在临界区内做的所有修改甚至能看到A在进入临界区之前做的、和Happens-Before链相关的修改。这个“能看到”不是靠锁本身直接刷新缓存而是靠锁的获取和释放建立了内存屏障释放锁时屏障确保之前的写操作刷出获取锁时屏障确保之后的读操作从内存拿新值。很多人以为synchronized只是“同一时间只能有一个线程执行”但实际上它的内存语义同样重要。我处理过一个案例一个对象被两个不同的锁保护导致并发修改同一份数据但大家觉得“都有锁啊”。JMM的锁规则只作用于同一把锁两把锁之间不会建立Happens-Before关系。这类问题不靠锁的“互斥”概念能解释必须用JMM的锁规则去推导。要注意的是synchronized是可重入的同一线程可以多次加同一把锁。JMM对重入的处理是每一次加锁和解锁都遵循规则但同一条执行路径上的重入不会造成死锁。异常退出时JVM会自动执行monitorexit保证锁释放。但从JMM角度看自动释放并没有额外魔法它只是补上了解锁操作让后续等待线程能拿到锁并建立Happens-Before。3.4 final字段的构造器安全发布JMM设计目标里有一个很多人不熟悉的板块但它极为重要final字段的安全发布。早期JMM下即使有引用不逸出一个线程读取另一个线程构造的对象也可能看到final字段的默认值比如0或null因为对象构造和字段初始化之间存在重排序构造成员的“半初始化对象”可能被发布出去。JSR-133对final域的语义做了两处关键规定编译器不能把final字段的写入重排序到构造函数之外也不能把构造函数的写操作与之后对该对象引用的发布操作重排序。当某线程首次读取一个包含final字段的对象的引用时之后读取该对象的final字段时不能把它重排序到读取引用之前。这带来的实际保障是正确构造的不变对象可以无额外同步地被安全发布。比如String、Integer、以及那些被标记为immutable的业务对象。只要你没有让this在构造函数中逸出final字段就不会出现“读了一半”的问题。注意关键词是“正确构造”如果你在构造函数里启动了一个线程并通过那个线程访问this那就不叫安全发布final也救不了你。JMM为什么要专门给final开小灶因为很多不可变对象的使用场景天生就不需要加锁比如配置类、路由表、缓存内的Key。如果每次读取都要靠volatile或者synchronized才能保证看到的是完整对象那代价就太大了。final的特殊语义让“不可变对象天然线程安全”这句话在JMM层面真正落地。4. 把设计目标落到日常开发代码层面的实践4.1 手写一个极简的并发门闩理解了JMM的设计目标和Happens-Before规则你其实可以自己用volatile写一些轻量级同步工具。这里我给你看一个我常用的“初始化就绪门闩”的简单版本public class SimpleReadyGate { private volatile boolean ready false; private int configData; public void publish(int data) { this.configData data; this.ready true; } public int read() { while (!ready) { Thread.onSpinWait(); } return configData; } }这段代码为什么安全因为configData的写入发生在ready写入之前程序顺序规则而read()里的读取发生在看到ready为true之后volatile变量规则再通过传递性configData的写入Happens-Before它的读取。哪怕没有锁也不会有并发问题。当然这个工具只适合“一次发布多次读取”的场景不能替代互斥锁但它展示了JMM设计目标在日常编码中最优美的一面用规则推导可靠性而不是靠玄学。4.2 双重检查锁volatile为什么必不可少网上关于单例的文章铺天盖地但真正理解JMM之后你才会明白双重检查锁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; } }new Singleton()这一步在JVM层面不是原子的它至少包含分配内存、调用构造函数、把引用赋值给instance三个动作。编译器可能把“把引用赋值”重排序到“构造函数完成”之前。这样线程A在构造过程中就把半初始化对象赋给了instance线程B发现instance非空直接使用就拿到了一个构造到一半的对象。volatile在这里的作用就是禁止上述重排序volatile写之前的普通写操作构造函数内部的各种字段初始化不能重排到volatile写之后。所以instance被赋值时构造函数一定已经执行完成了。JMM设计目标并没有刻意要求“禁止所有重排序”它只要求“在你需要的地方用正确原语阻止危险的排序”。4.3 业务中怎么选同步手段JMM的设计目标给了你一套工具箱但不同工具有不同成本。我把常见选择整理成表开发评审时可以直接照着看工具能保证什么成本适用场景volatile单次读写可见性、有限重排序约束极低状态标志、已发布引用synchronized互斥、完整可见性、复合操作原子性中高有锁竞争时临界区、读改写、计数器LockReentrantLock同synchronized 可中断/超时/公平较高需要超时、多条件、高并发控制的场景AtomicInteger等CAS工具单变量复合操作的原子性中低计数器、累加器、状态推进ThreadLocal线程隔离数据不共享低线程内上下文、连接管理final安全发布不变对象零配置、常量、不可变对象真正的经验是能用final就不加锁能用volatile就不上synchronized能用无锁CAS就不要阻塞。但反过来如果你的临界区复杂、涉及多个变量的不变性约束那老老实实用synchronized或Lock不要硬造无锁。JMM设计目标的精髓在于让你知道每种手段的能力边界而不是鼓励你炫技。我评审代码时有个自检清单你写并发代码时也可以套一遍这个共享变量是否需要跨线程可见对它的操作是单个读写还是复合操作线程之间的先后关系有没有建立Happens-Before对象是否会在构造完成之前被发布出去如果出Bug能否用JMM规则指出是哪条规则被破坏了如果回答不了这五个问题说明代码设计还没过JMM这一关。5. 常见问题与排查技巧实录5.1 案例1while循环停不下来有次线上应用的一个线程一直占着CPU跑死循环看起来像是卡在某段轮询代码里。业务代码是private boolean running true; public void run() { while (running) { // 处理任务 } } public void stop() { running false; }stop()被另一个线程调用但run()里的循环就是不停。原因很简单running没有volatile修饰也没有锁保护。JIT检测到这个循环里没有对共享变量的显式同步操作就把running当成不变量直接优化成“永不退出”的无限循环。这也解释了为什么光靠断点调试看不出问题断点会暂停线程你无法直观看到JIT优化后的代码状态。修复方案就是给running加上volatile。加完之后stop()里对running的写会通过volatile写屏障刷新到主内存run()里每次循环都从主内存重新读线程才能正常退出。这是一个非常典型的JMM可见性目标应用也是每一个Java并发入门者都该亲手踩一次的坑。5.2 案例2半初始化对象的“时间旅行”另一个真实场景一个同事自己实现了一个连接池里面预创建了一些连接对象放在一个普通数组里准备就绪后把数组引用赋给一个普通静态字段。理论上赋值完成后其他线程再读数组引用应该能看到所有连接已经初始化好了。但他忽略了引用字段没有volatile也没有任何锁JMM并不保证数组元素写入和引用发布之间的顺序。其他线程拿到了数组引用后遍历出来发现某些连接对象的字段还是默认值。这种问题就是我前面说的“半初始化对象发布”。JMM设计目标里final字段的构造器安全发布规则和volatile的安全发布语义就是用来堵这个洞的。如果把静态字段声明为volatile或者把连接数组声明为final就能保证发布时构造一定完成。这里重点强调即使你确定“构造函数逻辑很简单”也不要赌重排序不会发生因为你控制不了JIT更控制不了CPU。5.3 案例3volatile累加结果不对还有人问我我都用volatile int count了为什么多线程累加结果还是不对原因前面已经说透了volatile保证单个读写的可见性和原子性但count是“读-改-写”三步三步之间可以被多个线程穿插。这个并发问题不是可见性而是复合操作缺失原子性。解决思路有两个。简单场景用AtomicIntegerprivate final AtomicInteger count new AtomicInteger(); count.incrementAndGet();复杂场景或者需要和别的状态一起保持一致性的场景用synchronized或Lock包住整个操作。我见过最坑的写法是先用volatile发现不对改成AtomicInteger但业务逻辑里还有其他字段要和count一起动作结果AtomicInteger只能保证count自己原子不能保证整个业务不变性。最后老老实实用synchronized锁方法。这个过程的教训是JMM设计目标不是为了让你做“最小代价的同步”而是让你理解每种工具的边界按业务正确性来选择不是按性能感觉来选择。5.4 排查并发问题的手法排查JMM相关问题的难点在于它不是必现的往往要靠压测和工具。我的建议是复现要并发压测不要指望单机跑一次就能出来。用CountDownLatch、CyclicBarrier这类工具把并发起点对齐不然线程启动的时间差本身就掩盖了问题。jstack看线程状态确认是阻塞、等待还是真正在空转。如果CPU高但堆栈显示在某个while循环大概率是可见性问题。关注JIT优化。可以用-XX:PrintCompilation观察编译状态或-XX:PrintAssembly看汇编代码。不过生产环境别开日志量太大本地复现时可以用。条件允许的话用JCStress跑一下并发测试。JCStress是专门设计用来研究JMM/JIT/CPU重排序的工具官方用它验证了很多并发语义。我一般会把怀疑有并发问题的测试场景转成JCStress测试用例跑不同线程数、不同affinity看是否会出现意外的变化。最重要的是保留现场。线上发现并发异常一定要先把线程dump、日志、版本对应的代码都保存下来再重启。很多并发问题重启后就再也复现不了复盘全靠现场数据。6. 我的一些经验和建议JMM的设计目标本质上是在给开发者“减负”它承诺正确同步的程序一定行为一致同时也清楚告诉你不遵守规则的代码会存在哪些不确定性。这个承诺的真正价值要等你被线上并发问题折磨过几次才能体会。我自己的一个体会是遇到诡异并发问题别先怀疑JDK有Bug也别急着换更“高级”的并发工具先拿JMM的规则把代码过一遍看每个共享变量的访问有没有建立Happens-Before链有没有漏掉volatile有没有半初始化对象逸出。大多数问题都会在这一步现出原形。最后分享一个小技巧写多线程Demo时先通过可见性、有序性、原子性三把尺子分别量一遍写清楚每个变量的访问方式。这样即使当时不出问题后面代码演化时你也能快速判断改哪里会破坏JMM语义。这套思维比背任何面试题都值钱。