深入理解Java内存模型:JMM与JVM内存模型的区别及并发实战
2026/9/24 22:42:37 网站建设 项目流程

1. 先把概念掰正:JMM不是“堆栈分区”那套东西

很多同学一听到“JMM”就条件反射地去背堆、栈、方法区、本地方法栈、程序计数器这几块内存区域,然后就被面试官一句“这是JVM的内存模型,不是Java内存模型”给问住了。这个场景我见过太多次,说实话,我自己刚入门那会儿也在这上面栽过跟头。JMM全称Java Memory Model,它跟JVM运行时数据区(也就是我们常说的堆、栈、方法区)完全是两个维度的东西——一个是在讨论“Java程序运行时的内存区域怎么划分”,另一个是在讨论“共享变量在多线程环境下如何保证一致性和正确性”。

JMM解决的是一类非常现实的问题:多个线程同时读写共享变量时,为什么会出现数据不一致?为什么明明代码顺序是先写后读,执行结果却不符合预期?为什么加了volatile之后问题就消失了?它的本质是一套抽象规范,定义线程与内存之间如何交互、什么时候能读到最新的值、什么样的重排序是被允许的。它不关心对象到底放在堆还是栈里,关心的是“你看到的数据是否新鲜、操作是否可见、指令顺序是否可控”。

把这篇文章看明白之后,你能收获三样东西:第一,能清晰区分JMM和JVM内存模型,再也不会搞混;第二,能快速理解volatile、synchronized、final这些关键字在JMM层面到底做了什么;第三,遇到并发场景下的数据不一致问题,手里有具体的排查思路和实验工具,而不只是靠猜。这篇文章适合正在准备面试的Java开发,也适合写了几年并发代码但从来没深究过“为什么”的工程师。我会把概念、规则、实战排查和个人踩坑一次性讲透,不带废话。

2. JMM为何存在:从CPU缓存到并发失序的必然背景

2.1 物理机内存架构给JMM出的难题

JMM不是凭空设计出来的,它的诞生与计算机硬件的发展密切相关。现代CPU的运行速度和内存的访问速度差距巨大,如果每次读写都直接访问内存,CPU只能干等着。于是硬件层面引入了高速缓存(Cache),把内存数据复制到离CPU更近的缓存里,读写优先操作缓存,再由缓存与内存同步。

这种设计的代价是:每个CPU核心都有自己的缓存,多个核心之间的缓存副本可能长时间不一致。线程在核心A上修改了变量x的值,这个修改先停留在核心A的高速缓存里,核心B上的线程读到的还是内存里那个旧值。这就是经典的缓存一致性难题,硬件层面用缓存一致性协议(比如MESI协议)来解决,JMM则是软件层面针对Java程序的规范。两套东西要配合工作:JMM规定Java代码层面哪些行为是合法的,底层由缓存一致性协议去保证具体实现。

如果完全没有JMM,Java的语义就失去了跨平台的保障。每个CPU架构的缓存模型不同,同一段并发代码在x86上表现正常,换到ARM上可能就疯狂出bug。JMM的核心价值是提供一个稳定的“内存语义契约”:只要你的代码逻辑满足这个契约,无论在什么平台上运行,结果都是一致的。

2.2 重排序:编译器、处理器都在偷偷“优化”

除了缓存不一致,另一个破坏并发正确性的因素是重排序。为了让流水线更紧凑、让CPU尽量不空闲,编译器和处理器会对指令做乱序执行或延迟提交的处理。这种优化在单线程环境下没有任何问题,因为最终结果和顺序执行完全等价;但在多线程环境下,其他线程观察到的操作顺序可能和代码写出来的顺序完全不同。

这里要区分两种重排序:编译器重排序(编译阶段指令调整)和处理器重排序(运行时乱序执行、内存系统重排序)。JMM针对这两种情况都有约束,具体手段是happens-before规则和内存屏障。JMM允许程序员认为程序是按源码顺序执行的,但允许处理器和编译器在“不改变单线程程序结果”的前提下自由优化,再用内存屏障去限制那些会破坏并发语义的乱序。理解了这一点,你就明白为什么“在代码里先写A后写B,另一个线程先看到B后看到A”是完全可能发生的。

2.3 JMM与JVM内存模型的本质区别

这里必须划重点:JMM不是去描述对象在堆里的分配、垃圾回收怎么分代、栈帧里存什么。那是JVM运行时数据区的事,我们通常叫它“JVM内存模型”或者“运行时内存区域”。JVM内存模型回答的是“Java程序运行时数据放哪、怎么管理”;JMM回答的是“多线程共享变量的读写语义是什么、可见性怎么保证、指令顺序怎么约束”。

一个简单的说法:JVM内存模型是“房子的户型图”,堆、栈、方法区是房间;JMM是“楼里邻居之间的相处规则”,规定谁在什么条件下能看到别人放的东西,什么时候必须等别人先做完动作。两个理论体系互相配合,但在并发问题上起决定性作用的是JMM。如果你在讨论并发时的数据一致性,却大谈年轻代晋升老年代的阈值,那方向已经跑偏了。

3. JMM的顶层设计:主内存、工作内存与八种原子操作

3.1 抽象内存模型:每个线程都有自己的“工作副本”

JMM规定所有变量都存储在主内存(Main Memory)中,每条线程还拥有自己的工作内存(Working Memory),线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存。工作内存里保存了该变量在主内存中的副本拷贝,线程之间的变量传递必须通过主内存来完成。

这个模型和物理硬件是一一对应的:主内存对应物理内存,工作内存对应CPU缓存加寄存器。你写并发代码时,不需要关心CPU内部具体细节,只需要遵守JMM这个抽象层提供的语义即可。线程A要更新变量x,先在工作内存中改副本,再把新值刷回主内存;线程B要读取变量x,先看自己的工作内存中有没有副本,有就直接用,没有或不确定时就到主内存重新加载。问题也随之而来:如果线程A刷主内存之前,线程B就读了旧副本,双方就会看到不一致的数据。

工作内存的概念解释了一个很多新手疑惑的问题:为什么多个线程共享同一个对象,却可能看到不同的值。不是因为对象被复制了多份,而是因为每个线程操作的是各自高速缓存的副本,副本同步到主内存的时机是不确定的。JMM的所有规则,本质上都是在围绕“工作内存和主内存什么时候同步、同步需要满足什么条件”来制定的。

3.2 八种内存交互操作:JMM规定的最小动作

为了让工作内存和主内存之间的同步有章可循,JMM规定了八种原子操作:

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

这八个操作在设计上有严格约束,比如read和load必须成对出现,store和write必须成对出现。为什么强调成对?因为任何一个环节断开,都会导致数据不同步。如果只read不load,主内存的值就没进入工作内存;如果只store不write,工作内存的修改就没有真正落到主内存。

3.3 操作规则里藏着的关键约束

光有八种操作还不够,JMM还制定了一系列规则来约束它们。比如,不允许线程丢弃最近的一次assign操作,也就是说,工作内存中被修改过的变量必须同步回主内存,不能只改不存;不允许线程把没有经过assign的变量从工作内存同步回主内存,也就是说,变量必须先有值才能写回。

更关键的是几条围绕lock和unlock的规则:一个变量在同一时刻只允许一条线程对其执行lock操作,lock之后不能被其他线程执行unlock;对同一个变量执行unlock之前,必须先把该变量同步回主内存。这条规则是synchronized关键字的内存语义基础——你退出同步代码块时,JVM会强制把工作内存中修改过的变量刷回主内存,于是其他线程进入同步代码块之前的读取操作就能拿到最新值。

这几条规则看起来抽象,实际运行时都是真实发生的动作。synchronized的底层实现里就有lock/unlock对应的monitorenter和monitorexit指令,volatile则会强制插入store/write操作确保写可见。理解八种操作之后再看关键字源码,你会觉得豁然开朗。

4. Happens-Before规则:JMM给并发程序立的“规矩”

4.1 规则存在的意义:线程之间没有绝对时间线

很多刚接触并发的人会有一个直观但错误的假设:线程A先执行了某个操作,线程B后执行,那么B一定能看到A的效果。现实是,线程之间没有统一的时钟,也没有绝对的执行先后。JMM如果强行要求所有操作都按全局时间序来执行,会让所有处理器都失去优化空间,性能完全不可接受。

因此JMM引入happens-before关系来定义“操作可见性”。这里的happens-before不是时间意义上的“先发生”,而是“前一个操作的结果对后一个操作可见,且前一个操作顺序排在前后”的一种语义保证。如果操作A happens-before操作B,那么A的执行结果对B可见,且A的执行顺序在B之前。但如果两个操作之间没有happens-before关系,JVM允许它们任意重排序,这也是并发bug的主要来源。

4.2 六大规则,一个都不能少

JMM定义了几条最基本的happens-before规则,它们是并发编程正确性的基石:

程序顺序规则:在一个线程内,按照代码顺序,书写在前面的操作happens-before书写在后面的操作。这是单线程逻辑正确性的保证,也是其他规则的基础。

监视器锁规则:一个unlock操作happens-before后面对同一个锁的lock操作。线程A释放锁之后,线程B获取同一个锁,B一定能看到A在锁保护区域内做的所有修改。

volatile变量规则:对一个volatile变量的写操作happens-before后面对这个变量的读操作。理解这条规则要抓重点:不是“写完之后读就能看到”,而是“读操作发生在写操作之后”时,读一定能看到写的结果。也就是说,只要读操作在happens-before序上位于写操作之后,JMM保证读到的不是旧值。

线程启动规则:Thread对象的start()方法happens-before该线程中的每一个动作。线程启动之前设置的状态,新线程一定能看到。

线程终止规则:线程中的所有操作happens-before对此线程的终止检测。其他线程通过Thread.join()等方法等待该线程结束时,一定能看到该线程的所有操作结果。

传递性规则:如果A happens-before B,且B happens-before C,则A happens-before C。这条规则让前面的各种规则可以串联起来,用于推导更多场景下的可见性保证。

如果你开发中遇到“为什么加了锁问题就消失”“为什么改成volatile就好了,但加锁也行的场景”,回到这里去找答案。几乎所有内存可见性问题都能通过这几条规则来套用解释。

4.3 as-if-serial语义:单线程的“障眼法”

与happens-before紧密相关的是as-if-serial语义:不管怎么重排序,单线程程序的执行结果不能被改变。这是编译器和处理器重排序的基本底线。有了这个底线,我们写单线程代码时完全不用考虑乱序问题;而多线程并发代码,则只能依赖happens-before规则保障可见性和顺序性。

as-if-serial和happens-before的关系可以这样理解:as-if-serial保护单线程内的程序正确性,happens-before扩展出跨线程的可见性保证。重排序可以自由进行,但必须在上述语义约束的范围内活动。超出范围的重排序,就需要用内存屏障来阻止。内存屏障是一类特殊的CPU指令,JVM在volatile、synchronized的字节码周围插入相应的屏障,告诉CPU:这里不允许跨越该屏障做重排序。屏障的具体类型(LoadLoad、StoreStore、LoadStore、StoreLoad)和插入位置,不同虚拟机实现有差异,但最终目标一致:保障JMM定义的内存语义。

5. JMM三大特性与关键字落地的实战细节

5.1 可见性:volatile到底做了什么

可见性指的是当一个线程修改了共享变量,其他线程能立刻看到这个修改。普通变量不保证可见性,因为工作内存副本的存在,其他线程可能长期读到旧值;volatile变量则强制在读写操作上做特殊处理。

volatile变量的写操作会被JVM插入StoreStore屏障和StoreLoad屏障:前者确保volatile写之前的普通写操作不会被重排序到volatile写之后,并且所有普通写操作的结果会先行刷新到主内存;后者确保volatile写之后,其他CPU能看到最新值。volatile变量的读操作会插入LoadLoad屏障和LoadStore屏障,确保后续的普通读和普通写操作不会重排序到volatile读之前。这样做的效果就是:volatile变量的写操作与后续对它的读操作之间,建立了happens-before关系。

需要注意一个高频误区:volatile不保证原子性。典型的反例就是volatile i加一的场景,i++是读-改-写三步,volatile只保证读到的不是脏数据、写出的能被看到,但不能保证三步整体不被其他线程穿插。要保证原子性,还得靠synchronized、Lock或者AtomicInteger这类工具。这是面试里经常用来筛选“懂没懂”的问题,如果你只会背“volatile保证可见性和有序性,不保证原子性”而举不出例子,建议把这一步写进代码里跑一遍,结果会非常直观。

5.2 原子性:从long/double特殊规则到CAS背后的JMM环节

原子性的含义是“一组操作要么全部执行完,要么一个都没执行”。在JMM中,八种内存操作本身是原子的,但普通变量的赋值、读取、自增这类复合操作不保证原子性。这对long/double这种64位类型有历史意义:早期JVM允许将一个long/double变量的读写拆成两次32位操作来执行,其他线程可能读到“高32位是新值、低32位是旧值”的半个变量。

不过JMM专门补充了64位数据规则:允许虚拟机选择不把long和double的读写实现为原子操作,但强烈提倡实现为原子操作。实际操作中,主流JVM在商用平台上都已实现为原子读写,我在实际排查问题时基本没遇到过半写问题。但作为开发者要清楚,这种“没遇到”是平台实现特性,不是Java语言级别的绝对保证,在特定场景下仍需依赖锁或原子类来做保护。

synchronized保证原子性的底层能力来自字节码层面的monitorenter/monitorexit,以及JMM中lock/unlock操作规则。而Java并发包里的CAS(Compare And Swap)则依赖处理器提供的原子指令,JMM同样对CAS相关操作有可见性方面的配合:CAS成功后,会有一个写操作的效果,让后续读到该变量的线程看到更新值。AtomicInteger之所以能实现线程安全的自增,本质就是“循环CAS加volatile可见性”的组合,这一层的理解比单纯背API有含金量得多。

5.3 有序性:别让DCL单例的坑在你代码里重演

有序性问题最经典的例子就是双重检查锁(DCL)单例。很多老代码里写过这样一段:

public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

代码逻辑看起来没有问题,但在没有volatile修饰instance时,存在一个严重的可见性和有序性隐患。new Singleton()这个操作在JVM层面被拆成了三步:分配内存、调用构造器初始化对象、把引用赋值给instance。编译器和CPU可能重排序第二步和第三步,于是另一个线程在第一个线程尚未完成初始化时,就看到instance不为null,直接拿去使用,此时对象可能还未完全构建,读取到的字段可能是默认值。

解决办法就是给instance加上volatile修饰。volatile通过插入StoreStore屏障,禁止了“把引用赋值给instance”和“初始化对象”之间的重排序。这个案例在面试中出镜率极高,考察点就是你是否真正理解volatile阻止的是哪两类重排序,而不只是会背“volatile禁止重排序”这句话。

6. 多线程问题定位与设计规避的实操经验

6.1 我踩过的坑和排查套路

说几个我实际遇到的经典场景。第一个场景:状态开关用普通boolean,工作线程循环检查这个标志位来退出。上线后偶发线程不退出的问题,加volatile解决。原因就是可见性——工作线程长期在自己的工作内存里读旧值,根本没看到主线程的修改。

第二个场景:缓存初始化使用HashMap,多个线程同时get后put,偶发数据丢失。这不是JMM层面的问题,而是线程安全问题,最终改成ConcurrentHashMap或加同步机制解决。这个场景提醒我:排查并发问题,先分清是“可见性”“原子性”还是“数据结构本身非线程安全”,方向不同手段完全不同。

排查套路我总结如下:先复现问题并确认并发环境(多核、多线程下的概率性出现);再从代码中找到共享变量和访问它们的线程;逐一判断对这些共享变量的访问是否建立了happens-before关系,没有就用volatile、加锁或原子类补齐;最后用压测或专门的测试工具验证修复有效性。排查时我会优先怀疑最简单的原因,比如忘加volatile、非线程安全容器、复合操作未加锁,而不是一上来就怀疑编译器重排序这种底层场景。

6.2 jcstress:把“玄学”变成可复现的结果

JMM相关的很多问题具有概率性和平台相关性,靠肉眼观察和单次运行很难得出结论。业界常用的工具是jcstress(Java Concurrency Stress),由OpenJDK发布,专门用来做并发压力测试和验证内存模型语义。用它可以写一个简单的冲突测试,让多个线程并发执行一组操作,多次运行后统计所有可能的观察结果。

jcstress的好处是把不稳定的现象变成统计意义上的结论。比如你想验证一个不加volatile的共享变量的读操作是否可能读到过期值,写出测试类,用@Actor和@Outcome注解声明并发动作和预期结果,跑几百次迭代,就能在输出报告中看到“观察到了非法状态”这种明确结论。这个工具不是日常业务代码需要依赖的,但在验证你的并发设计、审查别人写的可疑代码时,它是非常有力的证据来源。如果你所在团队对某段并发代码的正确性有争议,我建议直接上jcstress,用数据说话,比谁口才好在代码审查会上更有效。

6.3 设计策略:如何从一开始就规避JMM“陷阱”

排查问题再多也是事后补救,好的并发设计应该从源头减少对JMM规则的依赖。我这些年逐步形成的几条经验:

优先考虑不可变对象。如果一个对象创建后所有字段都不会变化,final字段保证安全的发布,类的不可变性让共享不再成为问题。这是最简单、最不容易出错的并发方案。

线程间的通信尽量收敛到少数可见性“锚点”。把所有共享状态都放入一个由锁保护的临界区,或者全部通过并发容器来传递,不要这个变量用锁、那个变量用volatile、再一个变量靠final,混着用很容易漏掉某个关联变量的可见性。这里有个具体例子:多个共享变量之间存在业务关联时,只给其中一个加volatile是远远不够的,必须用锁把它们整体保护起来,否则会出现“一个变量看到新值、另一个变量还是旧值”的不一致状态。

利用现成的并发组件交付能力,而不是自己手写依赖JMM精细语义的代码。比如状态标志用AtomicBoolean而不是自己用volatile加循环自旋保护;计数器用LongAdder而不是手动同步;发布-订阅场景使用CompletableFuture或并发集合来协调。标准库的组件经过长期验证,比我们自己用底层关键字拼装的方案可靠得多。

代码评审中重点关注共享变量的可见性与有序性。如果一段代码涉及多线程修改同一个字段,评审问题应该是:这个字段的发布是否安全?读写操作之间是否存在happens-before关系?对它的操作是否是原子的?这三个问题问下来,多数并发隐患都能暴露。我发现只要坚持用这三个问题来审并发代码,踩坑率会明显下降。

7. 写在最后:关于JMM学习的一点个人体会

我已经不止一次说过,JMM是Java并发学习的“分水岭”。不理解的阶段,volatile和synchronized用起来全靠背结论,出了并发问题只能靠加锁、加sleep这些笨办法去碰运气;理解了之后再看并发代码,脑子里会自然浮现出“这行和那行之间有没有happens-before关系”这个校验流程,排查问题的速度快了一个量级。

我个人非常建议初学者去研究一下你所使用JDK版本的JMM文档和遇到的经典实战案例。很多看起来“诡异”的现象,比如程序一加System.out.println就“正常”了(其实是println内部的同步引入了一条happens-before链),比如在低并发下没有问题的代码在高并发时偶现脏读,都能通过JMM规则得到合理解释。每一个看起来反直觉的现象,背后都藏着一条没有被满足的规则,把这些现象一个个弄明白,比刷一百道并发选择题都有用。

最后再分享一个小技巧:面试和实战中把JMM和JVM内存模型分开讲,能瞬间体现你的体系化程度。面试官问“JMM”,你就围绕主内存、工作内存、happens-before、三大特性来讲;问“JVM内存模型”,你再聊堆、栈、方法区、GC分代。这两套体系虽然中文名字相似,但内涵完全不同,能主动区分它们,说明你不是在背书,而是真正理解了Java并发的底层逻辑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询