首页 / 资讯中心 / 文章详情

Java内存模型JMM核心解析:可见性、原子性与有序性

Java内存模型JMM核心解析:可见性、原子性与有序性 ★ FEATURED ARTICLE
1. 从一段诡异并发代码说起JMM到底在解决什么问题有段时间我负责维护一个老项目线上偶发出现一个怪现象某个配置开关明明已经在后台改掉了但线上进程就是感知不到变化非要重启或者等上几分钟才生效。当时资历尚浅第一反应是缓存没刷新但排查了一圈发现业务代码里根本没有缓存层。后来才明白真正的问题是并发可见性——另一个线程修改了变量当前线程的工作内存里却还是旧值。这个问题的根源就是JMMJava内存模型覆盖的范畴。很多初学者会把JMM和JVM运行时内存区域搞混以为JMM讲的是堆、栈、方法区怎么划分。这是完全两码事。JVM内存结构是描述对象和元数据存在哪里而JMM描述的是并发场景下变量的读写规则。简单说JMM是一套规范定义了在多线程环境下一个线程对共享变量的写入何时对另一个线程可见以及编译器、JIT、处理器可以做怎样的指令重排序。这套规范足够抽象上游屏蔽了不同CPU架构的缓存一致性差异下游约束了编译器优化边界。这篇文章是并发编程系统学习的第三章前两章我们大概聊了线程生命周期和synchronized的基本用法这一章把JMM这个水下的冰山拎出来结合volatile、synchronized、final等关键字以及happens-before规则做一次彻底拆解。同时也把JMM和物理硬件模型、JVM运行时数据区、ThreadLocal做一个横向对比帮大家把脑子里那团并发知识真正理顺。适合已经能写多线程Demo、但遇到线上诡异问题就摸不着头脑的开发者无论你是校招刚入职还是工作两三年想补基础都能从中找到可落地的认知升级路径。2. 为什么需要一套内存模型从硬件缓存到JMM的抽象2.1 一个共享变量到底经历了什么先想一个很基础的问题在多核处理器上两个CPU核心同时访问同一个内存地址它们看到的值一定一样吗答案是不一定。现代CPU为了弥补主内存和寄存器之间的速度差在中间加了好几层高速缓存L1、L2甚至L3。每个核心都有自己专属的L1/L2缓存一个变量被加载到缓存里之后再修改如果没有特殊指令去刷回主内存其他核心是看不到这个改动的。就算写回了主内存其他核心缓存里的副本也未必会立刻失效。这就是CPU级别的缓存一致性问题。硬件层面应对这个问题的方案有很多比如各种缓存一致性协议常见的比如MESI协议族但是不同CPU厂商的实现细节不一样指令集不一样。Java作为一次编写、到处运行的语言不可能直接在语言规范里绑定某种CPU的具体行为于是JSR-133专家组就是制定Java内存模型的那群人设计了JMM这套逻辑抽象层。可以这么理解JMM是Java对待并发内存可见性问题的一份最低纲领。它不要求所有平台实现方式完全一致但要求最终的行为结果一致——只要一个程序在JMM规则下是正确的它跑在任何架构的JVM上结果都应该是正确的。2.2 JMM的核心抽象主内存与工作内存JMM规定所有变量这里指实例字段、静态字段、数组元素等共享变量不包括局部变量和方法参数因为它们是线程私有的都存在主内存中。每个线程还有一个自己的工作内存可以类比成线程私有的缓存副本。线程对变量的所有读和写都不能直接操作主内存。必须先把变量从主内存拷贝一份到工作内存然后在线程自己的工作内存里进行操作操作完成之后再找某个时间点把新值刷回主内存。这个过程在JMM里定义得非常机械——不是可能发生而是必须遵守。画一个生活类比工作内存就好比你的工位桌面主内存好比部门公共文件柜。你要改文件柜里的一份文档不能拿着笔蹲在文件柜前面写你得先把文件拿出来放在工位上改改完再放回去。关键问题是——你什么时候把文件放回去放回去之后隔壁工位的同事什么时候才能看到新版文件如果A同事改完没放回柜子B同事又从柜子里拿了一份旧版出来那两个人手里的文件就完全对不上了。这个类比里缺一个东西就是规则什么时机允许放回、什么时机必须放回、别人拿文件的时候什么情况下必须先去柜子刷新。JMM里定义那8种原子操作和happens-before规则干的就是这件事。2.3 JMM规定的8种原子操作JMM定义了8种操作来支撑主内存和工作内存之间的交互协议它们全部是原子的、不可再分的。虽然这些操作在实际Java代码里看不见摸不着但理解它们能帮你从底层看透很多并发Bug的本质read从主内存读取一个变量传输到工作内存。load把read得到的数据载入工作内存的变量副本。use把工作内存中的变量值传递给执行引擎。assign把执行引擎计算得到的新值赋值给工作内存中的变量。store把工作内存中的变量值传输回主内存。write把store传输的值写入主内存变量。lock作用于主内存变量把它标识成一条线程独占状态。unlock作用于主内存变量释放独占锁。这些操作配对出现但中间可以插入其他操作。举个例子read必须紧随load之前store必须紧随write之前但一对读写之间可以插入变量被use/assign的流程。换句话说工作内存从主内存拿一份数据、然后改半天、再刷回主内存这中间有充分的时间窗口可以让数据变得不一致。JMM还规定了一些顺序性约束比如不允许一个线程丢弃最近的assign操作改完的值不能当没发生不允许无原因地把工作内存中的数据刷回主内存时机没到不能瞎刷更不允许线程直接在主内存中修改变量。这些约束的目的只有一个让缓存不可见的问题变成一种有规律可循、可预测、可推理的行为。3. 三大并发特性的底层支撑3.1 原子性谁保证了一个操作不可打断并发领域三个经典特性是原子性、可见性、有序性JMM对这三个特性的支撑力度是完全不一样的。原子性说的是一个操作或者多个操作要么全部执行且不被干扰要么全部不执行。JMM直接保证的原子操作就是刚刚那8种基础操作。但是业务代码里类似i这样的复合操作在JMM层面并不是原子操作——它至少包含读取i、计算i1、写回i三个步骤期间随时可能被其他线程插进来。谁能扩展原子性的边界答案是锁。synchronized块在字节码层面通过monitorenter和monitorexit保证了临界区代码的互斥执行在JMM层面对应了lock和unlock操作。这也解释了为什么生产环境里统计类场景用AtomicInteger或LongAdder更合适——它们内部用CAS比较并交换配合volatile变量把读-改-写压缩成一连串受控的原子指令比用synchronized牺牲一点锁竞争、换来细粒度更小的冲突窗口。有意思的是JMM里有一个极少被提到但真实存在的规则对于64位long/double类型规范允许JVM把它们切分成两次32位读写来执行理论上有非原子性风险。但主流HotSpot VM在实际实现中把64位操作都实现为原子操作所以绝大多数开发者感知不到。这个问题在面试里偶尔被翻出来当冷知识但在工程实践中几乎不必担心。3.2 可见性为什么改了等于没改可见性问题在开篇已经点出来了。JMM实现可见性的核心机制是变量修改必须沿着工作内存→主内存→其他线程工作内存的路径传播。普通共享变量不保证可见性因为JMM没有规定线程什么时候把assign过的值刷回主内存也没规定其他线程什么时候主动read主内存。这两个没规定就是数据不可见的温床。volatile关键字的出现就是为可见性提供了一个明确约束volatile变量的写必须在写完之后立刻store到主内存volatile变量的读必须先从主内存read最新值再载入工作内存。换句话说volatile变量不允许线程私自缓存。synchronized也保证可见性但机制不同——它是通过lock/unlock语义实现的线程解锁之前必须把工作内存中的修改全部刷回主内存加锁之后工作内存中的变量副本全部失效后续读取必须从主内存重新load。所以锁不仅管互斥还管进门之前必须看最新版公告。3.3 有序性编译器的小动作是如何被约束的为了性能编译器、JIT、CPU都可能对指令做重排序。单线程下重排序不会改变程序的执行结果as-if-serial语义但多线程下就未必了。最经典的案例是双重检查锁定Double-Checked Locking里的未初始化半成品问题这里我稍微展开讲一下因为它比理论更能说明问题。看这段广为人知的代码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()在字节码层面可以拆成三步分配内存空间、调用构造器初始化所有字段、把引用地址赋给instance。如果不加volatileJIT和CPU可能把第2步和第3步调换顺序。于是另一个线程在第一次检查时看到instance不是null直接拿去使用但此时构造器还没执行完拿到的是一个字段全是默认值的不完整对象。volatile在JDK 5之后JSR-133重新修订了内存模型具备一个核心能力禁止对该变量周围的读写指令做重排序。具体来说volatile写操作之前的普通读写不允许跑到写操作后面volatile读操作之后的普通读写不允许跑到读操作前面。因此上面的双重检查锁才能成为安全写法。从这里也能看出有序性的核心不是完全禁止重排序而是在关键边界上划出禁飞区。JMM用happens-before规则限制了重排序的边界让并发程序的行为变得可以推理。4. happens-before规则八个最重要的并发交通法则4.1 为什么有这条规则happens-before是JMM定义的一种偏序关系用来描述两个操作之间的内存可见性。A happens-before B意思就是A的执行结果对B可见且A的执行顺序在B之前从内存可见性视角看。理解happens-before的关键是不要把它理解成时间先后而是要理解成可见性保证只要满足happens-before关系前一个操作写入共享变量的值后一个操作一定能看到。JMM给程序员提供了8条happens-before规则日常写并发代码基本靠这几条就够了。规则含义典型场景程序次序规则一个线程内按代码顺序前面的操作happens-before后面的操作垂直代码逻辑天然成立监视器锁规则解锁操作happens-before后续对这个锁的加锁操作synchronized块内外的变量共享volatile变量规则对一个volatile变量的写操作happens-before后续对该变量的读操作标志位控制线程启停线程启动规则调用start()方法happens-before新线程内的任何操作主线程new Thread后start线程终止规则线程内所有操作happens-before其他线程检测到该线程终止Thread.join()返回后线程中断规则调用interrupt()方法happens-before被中断线程检测到中断事件isInterrupted()为true对象终结规则对象构造函数执行完happens-beforefinalize()方法开始对象被回收前传递性如果A happens-before BB happens-before C则A happens-before C链式推导可见性4.2 几条规则组合出来的实战配方单独一条规则看起来都很简单但真正好用的是组合。我记得有个经验丰富的同事提过一句话并发排查的第一件事就是把代码里涉及的共享变量对照这8条规则一条条找看看能不能找到一条happens-before链。找到了可见性就没问题找不到就是隐患。举个例子线程A往普通HashMap里放数据放到一半调用threadB.join()。根据线程终止规则线程A之前的所有操作都happens-before join()的返回而join()返回后线程B继续执行的代码属于后续操作再通过传递性线程A往HashMap里放的数据对线程B可见。所以即使HashMap本身线程不安全如果只在A填完数据后启动B去读且中间没有写那么读写并不存在数据竞争。这也是线程安全定义里经常被忽略的细节——同步手段不一定是锁线程之间的协作机制本身也携带同步语义。还有最常见的线程启停模式用volatile布尔变量控制子线程退出循环。根据volatile变量规则主线程修改flag之后子线程在读取flag时必然看到最新值。这里很多人会问volatile到底能不能替代synchronized答案是不能替代互斥但能替代可见性。如果你的场景只是一个线程改另一个线程读且读者不关心中间状态volatile就是合适的选择。4.3 容易忽略的as-if-serial语义讲到有序性就绕不开as-if-serial。它的意思是不管怎么重排序单线程程序的执行结果不能改变。这是编译器做优化的底线。但在多线程程序里单线程视角就不够了。A线程里执行了x1接着执行y2另一个线程依赖x和y的顺序可能就会踩坑。因为编译器认为只要A线程自己看没问题就行它不会主动考虑别的线程的视角。所以我们在写并发代码时要把as-if-serial忘掉时刻记住别人看到的不一定是我写代码的顺序。唯一的例外就是happens-before规则覆盖的地方。5. 对比剖析JMM与硬件模型、JVM内存结构、ThreadLocal的分界线5.1 JMM vs 硬件内存架构抽象和实体的关系很多文章把JMM工作内存直接类比成CPU缓存这个类比能帮助理解但别过度延伸。因为硬件层面的缓存一致性协议比如MESI系列在个体行为上比JMM要激进得多——它提供了比JMM更强的保证但也更依赖硬件架构。举个例子x86架构下有比较强的缓存一致性保证加上它的写缓冲store buffer机制实际表现是StoreLoad重排有可能发生但LoadLoad和LoadStore几乎不会重排。因此在x86上实现volatile只需要在volatile写之后加一条屏障指令成本相对可控。而一些弱内存模型架构比如ARM、PowerPC重排限制很宽松编译器需要生成更复杂的屏障指令组合才能真正实现JMM要求的语义。这也是为什么JMM必须做成与具体硬件解耦的抽象规范——它定义的是必须满足的最弱保证具体到每个平台上实现方可以做得更强但不能更弱。从工程视角看这个对比带给我们一个启示JMM不是让你去背内存屏障指令的它是让你在写Java代码时不需要关心底层CPU细节。真正需要关心屏障的是JVM的开发者。5.2 JMM vs JVM运行时数据区别再把它们混为一谈这是我在指导新手时发现的高频认知误区。JVM运行时数据区指的是程序计数器、虚拟机栈、本地方法栈、堆、方法区。这是从内存哪里放什么的角度看的解决的是对象生命周期和回收管理问题。JMM里的主内存和工作内存是物理内存缓存的抽象模型解决的是多线程读写共享变量时的正确性问题。两者不是一回事也不需要一一对应。如果非要找对应关系可以粗略这么说JMM中的主内存在物理上主要对应堆内存中的变量存储区域而JMM中的工作内存物理上对应线程的寄存器、缓存可能还包括一部分栈区域。但注意这种对应只是为了建立直觉不是严格的映射关系。5.3 JMM工作内存 vs ThreadLocal两个维度的线程私有ThreadLocal也号称线程私有副本很多初学者会把工作内存和ThreadLocal搞混。这里必须掰开揉碎讲清楚。ThreadLocal的线程私有是指每个线程都持有自己的ThreadLocalMap线程A往ThreadLocal里放的值线程B读不到。这是对象级别的隔离——它让你根本不共享同一个变量实例。工作内存的线程私有则是指每个线程从主内存copy来的共享变量副本是自己可见的这个副本可能跟其他线程的副本不一致。这是值级别的缓存副本——共享变量还是那个共享变量所有人的修改最终要回到同一个地方只是中间会产生不一致窗口。如果画个图辅助理解ThreadLocal是同样一个钥匙每人一把锁各锁各门JMM工作内存是同一扇门每人手里都有一把钥匙但开门看到的东西可能新旧不一。两套机制解决的是不同层面的问题ThreadLocal解决参数透传和隔离JMM解决共享变量的可见性与正确性。实际项目中可以组合用但别指望用ThreadLocal替代volatile或者synchronized。5.4 volatile vs synchronized vs Atomic类三者在JMM语义上的差异展开对比这三个并发工具是因为它们在不同维度补足了JMM的能力特性volatilesynchronizedAtomic系类原子性不保证复合操作保证临界区互斥基于CAS保证单操作原子可见性写后立即刷主内存读前强制读取最新解锁刷回加锁失效副本内部依赖volatile实现可见有序性禁止重排的边界monitorenter/monitorexit限制内部封装的CAS指令具备原子性天然语义适用场景标志位、状态开关、发布不可变对象复合操作、临界区计数器、累加器、并发数据结构成本低高可能涉及操作系统层锁中循环CAS在高竞争下开销大在Spring这类框架里大量配置开关和运行状态引用的字段就是用volatile修饰的因为它们的写频率很低、读频率高volatile刚好贴合这种发布-订阅模型。而真正的复合变更比如转账扣款这种先校验后扣减再入账的序列必须用锁配合事务volatile层面的可见性只是必要条件不是充分条件。6. 内存屏障与JVM实现的核心细节6.1 为什么JMM规则落到实现里会需要屏障JMM对语言层的约束是规则化的但落实到JVM实现和CPU指令层面靠的是内存屏障Memory Barrier。内存屏障是一类特殊指令它的作用是限制它两侧的指令重排序同时保证某些缓存的失效或刷回操作被执行。JMM在逻辑上把屏障划分为四类LoadLoad、LoadStore、StoreStore、StoreLoad。名字本身已经提示了限制关系例如LoadLoad屏障意思是屏障前的读操作不能在屏障后的读操作之后执行StoreLoad屏障是四种里面开销最大的因为它要求所有之前的写操作都刷到主内存同时后续的读操作都从主内存重新取。HotSpot VM在解释执行和JIT编译时会根据目标平台插入对应的屏障指令。我们平时写Java代码感知不到是因为JVM帮我们把这些细节全部封装好了。但如果做底层研究比如用hsdis插件反汇编看JIT生成的机器码就能清楚地看到volatile读写周围多出来的指令序列。6.2 volatile和synchronized在实现上的不同套路以常见的x86平台为例volatile写操作JIT编译后会插入StoreStore屏障再加一条带lock前缀的指令或者直接使用某些带全屏障语义的写指令这能保证写操作不会和后续读写乱序volatile读操作由于x86的强一致性通常只需要保证读操作不提前即可所以有时候读侧只依赖编译器级别的重排限制就够了。synchronized则靠的是更重的监视器锁机制。HotSpot VM里存在锁升级路径无锁→偏向锁→轻量级锁→重量级锁。轻量级锁通过CAS操作获取锁重量级锁则依赖操作系统层面的互斥量。但不管哪一级锁获取锁和释放锁的边界处都会插入类似屏障的语义保证临界区内的修改对后续获得锁的线程可见。经验之谈排查并发问题时不要一上来就怀疑JMM的某些冷门行为。99%的并发Bug来自业务代码没做同步控制或者同步控制的范围没覆盖到共享变量而不是JVM实现出了偏差。6.3 final字段的特殊语义final在JMM里有一席之地是因为构造器安全发布问题。JSR-133增强了对final的保证在构造器内对一个final字段的写入与随后把这个构造好的对象引用赋值给某个外部变量这两者之间不会重排序同时初次读到一个包含final字段对象的引用与随后读这个final字段也不会重排序。这意味着只要对象正确构造构造器里没有把this泄漏出去final字段就可以安全地被发布到其他线程不需要额外的同步手段。所以不可变对象配合final字段是一种天然线程安全的设计模式把对象的所有字段都标记为final构造完成后不修改配合不可变集合就能绕开一大部分并发复杂度。7. 常见问题与排查技巧实录7.1 一张问题速查表现象根本原因解决方向改了共享变量其他线程一直看到旧值可见性缺失变量未用volatile或未加锁加volatile或synchronized双重检查锁拿到半初始化对象new对象重排序 未禁止重排单例字段用volatile修饰多个线程同时i结果小于预期复合操作非原子用AtomicInteger或synchronized明明加了锁别的线程还是读到旧数据读和写没有使用同一把锁读写两侧同步块统一锁对象启动线程后马上在主线程读子线程设置的值读不到缺少happens-before边界让主线程通过join等待子线程完成long/double偶尔读出半个值奇慢才见极老JVM上64位写未原子化升级JDK或直接用原子类7.2 线上排查的实操套路遇到并发引起的诡异问题我的排查顺序一般是这样第一步先确定是不是真的并发问题。把线上的偶现问题尝试用多线程压测复现如果单线程跑一万次都过多线程一压就出问题基本锁定。第二步把共享变量全部列出来逐个问三个问题有没有被多个线程写有没有读写发生在不同的锁内有没有用volatile任何一个回答出问题就往里面挖。第三步借助工具验证。实时线程状态用jstack锁竞争和内存指标用jstatGC日志打开。对于疑似可见性问题可以考虑在测试环境用压测工具构造高强度竞争再用Java的jtreg测试库里的并发压力测试思路套进去让问题在可控环境里暴露。如果连现场都抓不到可以用更保守的策略在怀疑的共享变量上加volatile如果语义允许或者在读写入口统一加同一把锁。很多时候最朴素的修复方式反而是最稳妥的。7.3 几个容易翻车的细节细节一synchronized锁对象的可见性。锁对象必须是一把全项目共享的钥匙如果A线程锁的是自己new出来的ObjectB线程锁的也是自己new出来的Object那它们各锁各的完全不互斥。锁的互斥性取决于锁对象是否同一个。细节二volatile只能保证单次读写的可见性不能保证读-改-写复合操作。比如count用volatile修饰依然会丢更新。这就是为什么计数器要选AtomicInteger。细节三不要在构造器里启动线程。如果构造器里启动了一个线程而该线程访问了正在构建的对象就会发生this逸出。即使字段是final如果逸出发生在构造完成前JMM也不能保证安全性。细节四锁粒度要敢拆。synchronized是一个重量级工具如果一个方法里有多个互不相关的共享变量拆成多个锁对象或者改用读写锁、StampedLock可以把并发度提上去。JMM的可见性保证不会因为锁拆细而失效只要每个共享变量的读写路径都受同一把锁保护。8. 写在最后JMM这把钥匙打开的不只是并发的大门从我这些年排查并发问题的经验来看真正让一个开发者从能写并发代码进阶到敢写并发代码的转折点往往就是彻底想通了JMM这几件事主内存和工作内存到底在说什么happens-before如何串起所有同步手段以及volatile、synchronized、final各自在什么位置发力。刚开始学的时候我也觉得JMM太抽象、太理论跟业务代码离得远。后来踩了几个线上坑才明白这层抽象恰恰是为了让上层的安全保证跨平台成立。没有这层模型你在x86上调通的高并发代码换到别的架构可能就翻车。如果你正在学习并发编程我的建议是不要急着去背诵8条happens-before规则而是先拿着这段经典代码去反复推演什么情况下其他线程会看到什么值然后对照规则一步一步画出变量读写的happens-before链条。这个过程走通之后再看volatile、synchronized的底层实现会觉得顺滑很多。最后分享一个小技巧养成一个习惯每次写完一段涉及共享变量的代码都自己在心里做一次JMM检查——这个变量的每次修改和读取能通过哪条happens-before规则串联起来如果串联不出来那它就是一颗潜在炸弹。坚持这个习惯半年你写并发代码的手感会有明显质变。
阅读完成 · 觉得有帮助?
咨询建站