资讯详情

资讯详情

深入理解Java内存模型:JMM与JVM内存模型的区别及并发实战

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代码层面哪些行为是合法的底层由缓存一致性协议去保证具体实现。如果完全没有JMMJava的语义就失去了跨平台的保障。每个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并发包里的CASCompare 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相关的很多问题具有概率性和平台相关性靠肉眼观察和单次运行很难得出结论。业界常用的工具是jcstressJava 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并发的底层逻辑。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →