ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java内存模型JMM深度解析:从硬件缓存到并发三大特性

Java内存模型JMM深度解析:从硬件缓存到并发三大特性

写并发编程,迟早要撞上JMM这堵墙。Java内存模型(Java Memory Model)不像HashMap那样看两行源码就能懂,也不像synchronized敲个关键字就能用,它是一整套关于“多线程读写共享变量时,什么行为是合法的、什么行为是违反契约”的底层约定。很多开发者写了几年并发代码,遇到诡异的数据不一致、偶发的死循环或者奇怪的null,排查半天找不到原因,最后发现是对JMM的理解有漏洞。这篇文章把JMM掰开揉碎,从硬件模型讲到Java规范,再和几种常见内存模型做对比,最后结合一些真实的排查记录,希望能帮你把这块拼图补上。

JMM要解决的问题很明确:在多核CPU时代,多个线程同时读写同一个变量,为什么会出现和直觉不符的结果?它定义了一套跨平台的内存可见性规则、指令重排序规则和同步语义,让Java程序员不用关心底层CPU架构的差异,只要按照JMM的规则写代码,就能保证并发程序符合预期。无论你是刚接触并发编程的初学者,还是被线上并发Bug折磨过几年的老手,理解JMM都能让你少走很多弯路。

1. 为什么需要JMM:从硬件内存架构说起

1.1 CPU缓存与内存一致性

先看一个最底层的场景。现代CPU的运算速度和内存的读写速度差距悬殊,如果每次读写都直接走内存,CPU大部分时间都要空转等待。为了弥补这个差距,CPU内部加入了多层高速缓存,通常是L1、L2、L3三级缓存,越靠近核心的缓存越快,容量也越小。线程执行时,变量会从主内存复制到CPU缓存中,计算完成后再刷回主内存。这个设计本质上是拿“空间换时间”,但代价是引入了缓存不一致的风险。

举个例子,两个线程同时读入一个共享变量count = 0,线程A在自己的CPU缓存里把它改成1,线程B的缓存里还是0,两个线程各自改完再写回主内存,最终主内存的值可能是0,也可能是1,取决于谁后写。这个现象叫“缓存一致性问题”,它直接催生了各种缓存一致性协议,比如常见的MESI协议,目的就是让多个CPU核心之间对同一份数据的缓存状态保持一致。但协议只能保证硬件层面的最终一致,不能免费帮你解决软件层面的逻辑正确性,于是语言层面需要定义一套规则。

Java内存模型就是Java对这个问题的抽象回答。它比CPU缓存模型更高一层,不关心具体是几级缓存、用的是什么协议,而是定义了一套“主内存-工作内存”的抽象模型:所有变量存在于主内存,每条线程有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量。不同线程之间无法直接访问对方的工作内存,只能通过主内存来传递变量值。

这个抽象和真实的CPU结构有对应关系:Java的工作内存,大致可以理解成CPU的缓存加寄存器等私有存储区域,主内存则对应物理内存。理解这层对应关系很重要,因为你写的代码最终要跑到真实硬件上,如果只知道Java规范而不懂硬件,遇到性能问题或者诡异现象时,很难定位根因。

1.2 指令重排序的必然性

除了缓存不一致,还有一个更难察觉的挑战:指令重排序。无论是编译器、JIT(即时编译器)还是CPU硬件,都有可能在不改变单线程语义的前提下,调整指令的执行顺序。这么做的根本动机还是性能——某些指令的顺序调整可以让流水线更顺畅、缓存局部性更好、或者减少等待。

重排序听起来很可怕,但它有一个底线:保证“单线程程序的结果不变”。对于单线程来说,重排序是无感的,因为最终结果和顺序执行一致。但一旦涉及多线程,重排序就可能破坏直觉。最典型的例子就是传说中的“双重检查锁”问题:

// 这段代码在JMM的旧规则下是有问题的,后来被修订后才安全 class Singleton { private static volatile Singleton instance; static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

问题出在instance = new Singleton()这一步,它编译后的底层操作包括分配内存、初始化对象、把引用赋值给变量。如果没有volatile修饰,JIT或CPU可能把“把引用赋值给变量”和“初始化对象”的顺序颠倒,另一个线程读到的instance就不是null了,但对象内部可能还没构造完成,于是拿到一个半初始化的对象直接崩溃。加volatile的本质作用之一,就是禁止这种针对该变量的重排序,并且提供可见性保证。

从这里可以看出,JMM不仅要管缓存一致性,还要管重排序边界。它定义了一个“内存屏障”的概念,volatile、synchronized、锁这些同步机制,底层都会插入对应的内存屏障指令来限制重排序,并确保缓存刷新的时机。了解了这一层,再回头看并发关键字,就不再是死记硬背了。

2. JMM核心规范:主内存与工作内存

2.1 抽象模型

JMM的规范文本很长,核心思想可以提炼成几条。第一,所有变量(这里指实例字段、静态字段和构成数组对象的元素,不包括局部变量和方法参数,那些是线程私有的)存储在主内存中。第二,每条线程拥有自己的工作内存,工作内存中保存了该线程用到的变量在主内存中的副本拷贝。第三,线程对变量的所有操作都发生在工作内存中,而不同线程之间无法直接访问对方的工作内存,消息传递必须通过主内存完成。

这三条规则看似简单,却是整个JMM的基石。举个例子:

// 假设这个共享变量 int counter = 0; // 线程A执行 counter = 1; // 线程B执行 System.out.println(counter);

如果没有额外同步,线程A改的是自己工作内存里的副本,线程B读到的可能是自己工作内存里那份永远没更新的副本,也可能运气好读到了主内存的新值。到底读哪个,取决于时机、缓存刷新策略、CPU调度,反正不是你能保证的。JMM允许这种不确定性存在,因为精确规定每一次读写何时刷主内存会严重拖慢性能,它选择给同步机制开“直通车”。

JMM的模型还定义了“使用(use)”、“赋值(assign)”、“加载(load)”、“存储(store)”等一系列操作,用来描述线程工作内存与主内存之间的交互动作。这些操作可以对照着看:use是线程从工作内存读取值用于执行,assign是线程把计算得到的新值写回工作内存,load是主内存把变量值复制到工作内存,store是工作内存把变量值传送到主内存。中间还有lock和unlock,对应锁的获取和释放。

2.2 八种内存操作及规则

JMM规定了下述八种内存操作(不同资料翻译略有出入,但不影响理解):

  • lock(锁定):作用于主内存变量,把变量标记为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,释放锁定状态。
  • read(读取):作用于主内存变量,把变量值从主内存传输到工作内存。
  • load(载入):作用于工作内存变量,把read得到的值放入工作内存副本。
  • use(使用):作用于工作内存变量,把值传递给执行引擎。
  • assign(赋值):作用于工作内存变量,把执行引擎收到的值赋给变量副本。
  • store(存储):作用于工作内存变量,把副本值传送到主内存。
  • write(写入):作用于主内存变量,把store传来的值写入主内存。

这八种操作并不是随手列的,它们之间有严格的使用规则。比如read和load必须成对出现,store和write必须成对出现;不允许线程无缘无故地把工作内存的值丢弃,也就是说assign之后必须在适当的时候同步回主内存;一个变量在同一时刻只能被一条线程锁定,但可以反复锁定多次;执行unlock之前必须先执行store和write,保证释放锁的时候数据已经回到主内存。

这些规则才是JMM的真正重头戏,它们直接推导出了我们常说的“可见性”和“有序性”的底层依据。平时用synchronized时,你其实不需要手动调用lock和unlock,JVM会自动在进入同步块时执行lock,退出时执行unlock,并在解锁前把工作内存中修改过的共享变量强制刷新到主内存,同时让其他线程工作内存中对应的缓存失效,下次读取时强制从主内存重新加载。这就是为什么synchronized能同时保证原子性、可见性和有序性——它本质上是借用了JMM底层的内存屏障和缓存同步机制。

3. 三大特性:原子性、可见性、有序性

3.1 原子性与synchronized

并发编程中经常听到“原子性”,但很多人把它和数据库事务的原子性弄混。JMM里的原子性指的是一个或多个操作在CPU执行过程中不可被中断,要么全部执行成功,要么全部不执行,中间不会有其他线程插入。最经典的例子是i++,它看起来是一条语句,实际上包含“读取i、计算i+1、写回i”三个独立步骤,任何一步都可能被其他线程打断,所以它不是原子操作。

要保证原子性,最简单也最保险的手段就是synchronized。当一个线程进入同步块时,其他想进入同一把锁的线程必须阻塞等待,直到锁被释放,相当于把一个复合操作整体给“锁”住了,不让其他线程在中间插一脚。还可以用java.util.concurrent.atomic包里的原子类,比如AtomicInteger,它们底层通过CAS(Compare And Swap,比较并交换)指令实现无锁原子更新,靠的是CPU硬件层面的原子指令。

这里有个实操经验:不要以为给单条赋值语句加了synchronized就万事大吉,锁的粒度会影响并发性能。锁太大的话,线程全部串行执行,性能还不如不加锁;锁太小的话,又可能保护不了复合操作。我见过不少案例,开发者把整个方法用synchronized锁住,结果高并发下吞吐量骤降,最后改成锁内只放关键步骤,甚至换成LongAdder这种分段累加器,才把性能拉回来。所以原子性的真正难点不是“加锁”,而是“在正确的位置、用正确的粒度”去加锁。

3.2 可见性与volatile

可见性是指当一个线程修改了共享变量的值,其他线程能够立即感知到这个修改。注意“立即”在JMM里不是一个硬性时间要求,而是说在满足特定规则的情况下,后续的读取操作一定能看到之前的写入结果。如果变量没有被正确同步,一个线程的修改可能长时间停留在自己的工作内存中,其他线程看到的永远是旧值——这就是经典的“可见性失效”。

volatile是JMM中最轻量的同步机制,它有两个核心语义:第一,对volatile变量的写操作,JMM会要求在执行store之前把修改后的值刷新到主内存;第二,对volatile变量的读操作,JMM会要求先从主内存把值加载到工作内存,而不是直接用缓存副本。相当于每一次读写都强制走主内存,绕过了缓存不一致的问题。

但volatile只解决可见性,不解决原子性。volatile int i的i++依旧不是线程安全的,因为“读-改-写”三步仍然是分开的。这也是初学者最容易踩的坑:听说volatile能保证并发安全,就把它加到计数器上,结果计数依然不正确。volatile更适合用在“一个线程写、多个线程读”的状态标志场景,比如控制线程停止的boolean标志,或者双检锁里的引用。

在实践中,volatile对性能的影响也不小,它禁止了缓存优化和部分重排序,所以高频写volatile变量会牺牲吞吐量。如果你有大量写操作需要保证可见性,又不想退化成完全串行,可以考虑用同步容器或读写锁来平衡。记住一个原则:volatile不是万能药,它只承诺“看到了最新的”,不承诺“多个线程一起操作不会错”。

3.3 有序性与happens-before

有序性是指在本线程内观察到的操作顺序,和按照代码顺序执行的结果保持一致;但在其他线程看来,由于重排序的存在,实际的执行顺序可能完全不同。JMM并没有禁止所有重排序——那样性能会崩掉——而是用一套规则界定哪些重排序在多线程环境下会破坏语义。这套规则的核心就是happens-before原则。

happens-before并不是时间先后的定义,而是“可见性”的偏序关系:如果操作Ahappens-before操作B,那么A的执行结果对B是可见的,且A的执行顺序在B之前(在允许重排序的前提下,JMM会插入必要的屏障来保证秩序)。JMM规定了几条基本的happens-before规则,你必须烂熟于心:

  • 程序次序规则:同一个线程内,书写在前的操作happens-before书写在后的操作。
  • 锁规则:对一个锁的解锁happens-before于后续对这个锁的加锁。
  • volatile变量规则:对一个volatile变量的写happens-before于后续对这个变量的读。
  • 传递性:如果Ahappens-beforeB,且Bhappens-beforeC,那么Ahappens-beforeC。
  • 线程启动规则:对线程的start()调用happens-before该线程中的任何动作。
  • 线程终止规则:线程中的任何动作happens-before其他线程检测到该线程终止(比如join()返回)。

有了这套规则,你写并发代码时可以先画一张“因果关系图”:哪些操作之间必须建立“看见”的链条?链条怎么搭建?用锁、用volatile、还是用Thread.join?一旦链条断了,数据竞争就出现了。我经常跟团队说,写并发别靠“我觉得应该能看见”,要拿happens-before规则一条一条过,过完心里才有底。

4. 对比剖析:JMM与常见内存模型

4.1 与CPU缓存模型对比

JMM经常被拿来和CPU缓存一致性模型对比,因为它们确实有亲缘关系。CPU缓存模型解决的是多核硬件上缓存行数据如何保持一致的问题,靠的是缓存一致性协议。JMM解决的是Java程序在多线程下变量的可见性和有序性问题,靠的是同步原语和内存屏障规则。

打个比方:CPU缓存模型像是修了一条多车道的高速公路,MESI协议相当于红绿灯,让不同车道的车辆(CPU核心)不会撞车;JMM则像是交通法规,规定了什么样的驾驶行为(代码操作)必须走哪条车道、什么时候必须踩刹车(内存屏障/锁)。高速公路上即使没有红绿灯,技术好的司机也能安全驾驶,但普通司机需要明确的交规才敢上路。Java为了跨平台、跨架构,选择了用一套统一规范来约束所有JVM实现,这就是JMM存在的意义。

两者是层次不同的抽象。你在写Java代码时,不会直接和MESI协议打交道,但volatile的实现很可能就依赖底层CPU提供的内存屏障指令(例如x86平台的mfence、ARM平台的dmb)。JMM把这些差异包装成load/store操作和happens-before规则,让开发者面对统一模型。这也是Java分布式一致性、Spring、Netty这些框架可以在各种硬件上跑的原因——底层细节被JMM和JVM挡掉了。

4.2 与C++内存模型对比

C++11也引入了自己的内存模型,和JMM有很多神似之处,但侧重完全不同。C++内存模型更贴近硬件,把内存序划分成几种明确的等级:松序(relaxed)、获取-释放(acquire-release)、顺序一致(sequentially consistent)。开发者可以通过std::atomic配合memory_order参数,非常细粒度地控制编译器能做什么重排序。好处是性能可以极致压榨,坏处是心智负担极大,用错一个memory_order就可能产生难以复现的Bug。

相比之下,JMM的设计更保守、更偏向“能正确工作就尽量简单”。Java没有把内存序等级直接暴露给业务开发者,而是用synchronized、volatile这些上层原语做了一层封装。synchronized大致对应C++的“acquire-release”级别:进入同步块时执行获取操作,退出时执行释放操作。volatile在Java 5修订后,语义上接近C++的“顺序一致volatile”,要求比acquire-release更严格,禁止更多重排序。

这种差异也反映在两个社区的风格上。C++社区经常讨论某个memory_order组合是否会被编译成lock前缀指令,或者是否会影响CPU流水线;Java社区更多讨论happens-before链是否完整、锁粒度如何调整。不能简单说谁好谁坏,C++需要的是在多平台系统编程中对内存序的直接掌控,Java则希望提供一个更安全的默认环境。作为Java从业者,了解这些对比能帮你更清楚地理解自己写下的每个同步关键字的行为边界,也方便你在阅读其他语言代码或参与跨语言项目时快速对齐语义。

4.3 与JVM实现的实际关系

很多人刚学JMM时有一种误解:JMM是规范,那么虚拟机的行为肯定严格遵循规范。实际上,JMM规定的是最低保证,JVM和CPU在保证这个最低底线的同时,会尽量积极地做优化。也就是说,你在代码里看到的重排序行为,是“在JMM允许的范围内,JVM和硬件能优化的都优化了”的结果。

举个例子,volatile写操作在x86平台上,因为x86的内存模型本身就比较强(写写不乱序等),所以实现时可能不需要额外插入太多昂贵的内存屏障指令,性能开销比ARM平台小。同样是volatile,不同硬件上的开销差异较大。JVM负责根据当前平台选择合适的内存屏障指令,并记录屏障类型优化掉多余的部分。这个机制很精巧,但对开发者有警示意义:不要依赖“我在自己机器上测试没问题”,因为你的测试环境用的可能是强内存序CPU,而线上环境可能是另一套架构,行为可能完全不同。

另外,JIT编译会做大量优化,比如将循环中重复读取的变量提到寄存器里,如果该变量没有正确的同步保护,你在调试模式下看到的“偶现”数据不一致,一旦换成生产模式JIT完全开启,现象可能放大或者变化。我遇到过好几次,测试环境一切正常,压测环境偶发数据错乱,最后发现是共享的普通字段没有加volatile,JIT优化导致旧值一直保存在寄存器里。这就是JMM规范和JVM实际实现交织在一起产生影响的典型案例。

5. 实操中的JMM:常见问题与排查

5.1 典型并发陷阱

结合JMM知识,很多并发Bug其实可以提前预判。第一个大坑是“不加volatile的布尔标志”。很多同学会用普通boolean stop = false来控制线程的结束,代码逻辑看起来很对:

while (!stop) { // 业务逻辑 }

但如果stop不是volatile,JIT完全可能把!stop优化成本地寄存器判断,因为它在循环里反复读取一个“看起来没变”的变量,导致线程永远无法退出。这类问题在开发环境不明显,因为解释执行或低级别JIT优化下,线程调度频繁切换,缓存失效概率高;到了生产环境高负载下,JIT深度优化,问题立刻暴露。

第二个大坑是把volatile用在复合操作上。volatile int count配合count++,或者volatile引用配合非原子的更新逻辑。volatile只保证读到的引用是最新的,不保证引用的状态是最完整的。比如一个volatile的HashMap,两个线程同时往里面put,即使引用可见,数据竞争依然存在,HashMap本身不是线程安全的。

第三个大坑是锁内的变量没有被store到主内存。比如synchronized块里修改了一个普通变量,退出同步块时JMM会统一刷新,但如果你在同步块外读取这个变量而不在同步块内,那就完全不保证能看见。很多人以为“只要写过synchronized就安全了”,忽略了读也要在锁保护范围内。可见性的建立必须是“读-写都纳入同步规则”,单边加锁等于没加。

5.2 排查手段与工具

遇到和JMM相关的诡异问题时,首先不要急着改代码,先复现并确认是否真的是可见性/有序性问题。一个常用的手段是“压力测试加矩阵验证”:多线程反复操作共享变量,统计出现错误的概率和条件。不过复现率低不代表没风险,JMM问题往往和硬件、JIT状态强相关,本地稳定复现不出来,可以借助一些工具直接观测底层行为。

-XX:+PrintAssembly可以打印JIT生成的汇编代码,你能看到是不是插入了内存屏障,或者变量是不是被放进了寄存器。这个工具门槛较高,需要配合HSDIS插件使用,输出也很多,但对排查重排序问题有奇效。如果不想看汇编,可以先用jcstress(Java并发压力测试工具)来编写并发测试用例,它能自动化“并发操作 + 结果验证”,帮你快速定位哪些场景存在非预期行为。还有像ThreadSanitizer这样的动态分析工具,适合在JVM层面检测数据竞争。

普通排查流程我一般这样走:先列出所有跨线程共享的变量,逐个确认有没有同步机制;再根据happens-before规则检查读写是否建立了正确的“先于”关系;然后检查锁的粒度是否覆盖了复合操作;最后如果还是不清爽,用jcstress写一段最小复现场景,在高并发下跑半小时以上。这套流程虽然朴实,但解决了绝大多数问题。

5.3 经验总结

最后说点个人的体会。JMM不是一门“背下来就会用”的知识,它的价值在于给你一套判断并发代码正确性的思维框架。我见过太多项目,靠“加锁”、“加volatile”这样表面操作去救火,加了一堆同步之后性能变差,还把问题藏得更深。真正解决问题的方式是先搞清楚共享数据是谁写的、谁读的、读写之间需要什么样的happens-before关系,然后再决定用哪个原语。

举个例子,某次线上系统出现偶发的“数据不一致”,排查发现是开发人员为了让状态传递更快,把普通int字段直接暴露给其他线程读。按照JMM规则,这已经产生了数据竞争。后来改成AtomicInteger,问题立刻消失。虽然改动很小,但背后的分析过程才是关键:先判断这是“读多写少”还是“写多读多”,再判断是否需要原子累加,再选择合适的并发类。每一个选择都能在JMM里找到对应依据。

如果让我给一条最值得记住的建议:写并发代码时,先写清楚变量的访问规则,再写业务逻辑。把“哪个变量被谁修改、哪个变量被谁读取、靠什么同步机制保证happens-before”用注释或者简单的文档记下来。这样不仅自己不容易犯错,后来接手的人也能快速理解,避免在不知情的状态下破坏原有并发保证。JMM看起来抽象,但落到项目里,它就是这套“访问规则”的理论地基。把这些规则真正用起来,并发编程就不再是玄学。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进