深入理解JMM:三大并发问题的本质与实战排查

发布时间:2026/9/16 1:29:15

深入理解JMM:三大并发问题的本质与实战排查 很多准备Java面试的朋友应该都有过类似体验JMM、原子性、可见性、有序性这些词背得滚瓜烂熟面试时也能顺口说出一二可一回到工位上碰到一个偶现的并发问题照样得靠猜。日志顺序不对、计数器对不上、偶发死循环这些问题摆在面前的时候脑子里那套“八股文”完全帮不上忙。这篇文章想做的不是再给你一份背诵提纲而是把JMM和三大并发问题拆开揉碎讲清楚它们是怎么来的、长什么样、在真实代码里怎么识别、怎么处理。内容兼顾面试和实战建议准备跳槽的朋友和正在写并发代码的后端开发都看一看。1. 先看一个现象并发Bug为什么会让人无从下手1.1 一次“看似正常却翻车”的并发现场先从一个很常见的场景说起。假设系统里有一个计数器多个线程都在调用它的自增方法代码如下public class Counter { private int count 0; public void increment() { count; } public int getCount() { return count; } }单线程环境下这个类没有任何问题count就是一次普通的自增。但换成多线程环境比如用10个线程各自循环10000次调用increment()最后统计结果大概率不是100000而是比它小一些的数。这个现象第一次碰到的人都会觉得奇怪明明每个线程都执行了完整的自增逻辑为什么结果对不上原因不在于Java代码本身写错了而在于count在底层不是一个不可分割的操作。它至少要经过“读取当前值”、“计算加一后的值”、“写回内存”三步一旦多个线程在同一时刻走到中间步骤后写的覆盖先写的数据就丢了。这就是典型的原子性问题。1.2 还有一类更隐蔽的问题线程看见了“过期数据”再看一个例子public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { Thread t new Thread(() - { while (!flag) { // 空转 } System.out.println(线程t看到了flag变化退出循环); }); t.start(); Thread.sleep(100); flag true; System.out.println(主线程已将flag修改为true); } }如果你实际跑过这段代码很可能看到主线程打出了日志但子线程却一直在空转程序既不退出也不报错。主线程明明修改了flag子线程就是“看不到”。产生这种现象的原因是每个线程在工作时都有自己的缓存副本线程不一定会实时去主内存刷新最新的值。主线程改了flag子线程读到的可能还是旧值。这就是另一个并发编程中的核心障碍可见性问题。1.3 还有一个藏在暗处的问题执行顺序和代码顺序不一致前两个问题都能通过复现验证第三个问题则更“阴险”。现代CPU和编译器为了提升性能会调整指令的执行顺序只要不改变单线程的执行结果它就可能把后面的指令挪到前面来。这种优化在单线程下完全没有感知但在多线程环境下代码的执行顺序一旦被打乱两个线程之间本来依赖的先后关系就会被破坏从而出现匪夷所思的结果。这就是有序性问题。三大并发问题不是Java独有的而是并发编程这个领域绕不开的三座山。JMM就是一套规则和约定用来规范线程如何访问内存、什么情况下一个线程能看到另一个线程的修改、什么情况下指令可以重排从而把并发编程从“看运气”变成“看规则”。2. 为什么必须有JMM从硬件缓存一致性说起2.1 CPU的“心眼儿”每个核都有自己的缓存要理解JMM得先理解它到底解决的是哪一层的问题。现代CPU都是多核架构而CPU的计算速度远快于内存的访问速度。如果每次读取变量都直接和内存交互CPU大部分时间都在等待性能会非常难看。于是硬件层面引入了多级缓存L1、L2甚至L3。每个CPU核有自己的L1/L2缓存多个核共享L3和主内存。当一个线程运行在CPU0上读取变量x时它优先从自己的缓存里取当另一个线程运行在CPU1上也读取x时它同样优先从自己的缓存里取。两个线程看到的数据可能分别来自两个缓存副本而它们之间并不直接共享。主内存里的值可能在某个时刻被CPU0更新了但还没有同步回去CPU1就拿到了旧值。这就是缓存一致性Cache Coherence问题。硬件层面提供了MESI等缓存一致性协议来做缓存同步但这套协议的粒度是缓存行级别的它不能保证“所有时刻、所有变量的最新值对所有CPU立即可见”。协议更准确地说是在特定条件下保证同步而Java需要一套更高层次、语言层面的规则来约束程序员的代码行为。2.2 JMM与JVM运行时数据区不是一回事很多初学者会把JMM和JVM内存结构搞混。这两个东西虽然都带“内存”两个字但完全是两个层面的概念。JVM内存结构描述的是运行时数据区域堆、方法区、虚拟机栈、本地方法栈、程序计数器它回答的问题是“对象都存到哪”。而JMM是一个抽象模型它定义了主内存和线程工作内存之间的交互规则回答的是“线程之间如何通过内存通信”。在JMM这个抽象模型里主内存所有线程共享的存储区对应物理内存中的主要数据位置工作内存每个线程私有的存储区对应CPU缓存和寄存器等线程只能直接操作自己的工作内存不能直接读写主内存。这个“工作内存”是一个抽象概念不一定真实对应某一个具体的缓存但它能帮助理解为什么线程之间的数据不是“实时互通”的。JMM本身规范了8种内存交互操作lock、unlock、read、load、use、assign、store、write用来约束主内存和工作内存之间的数据流转。这些操作组合起来再配合一整套规则就构成了JMM对原子性、可见性、有序性的保障框架。2.3 JMM的落地方式特性要求 vs 具体工具JMM并不是拿着锤子直接告诉你“这个类要怎么写”而是定义了三条特性要求原子性一个或多个操作在执行过程中不被任何因素打断可见性一个线程修改了共享变量后其他线程能立即读到修改后的新值有序性程序执行的顺序按照代码的先后顺序执行不因指令重排而乱序。至于具体怎么实现这三条JMM给出了若干规则volatile、synchronized、final、Happens-Before规则等。理解了这套逻辑你就能明白为什么volatile能保证可见性和有序性却不能保证复合操作的原子性为什么final变量在某些场景下可以安全发布。这些后面会逐个展开。3. 三大并发问题的本质与识别方法很多技术文章喜欢把三大并发问题放在一起讲但实际开发中它们往往交织出现很难一眼判断当前Bug属于哪一类。我建议反过来记每个问题对应一类典型的代码形态和一类典型的故障现象。3.1 原子性问题复合操作的“中途被打断”原子性问题通常出现在“读-改-写”型操作里典型案例就是count、count--、list.size()判断后再add这类组合逻辑。它们的共同点是先读取共享数据基于读取结果做计算再把结果写回。这三步中间一旦被线程切换打断另一个线程也执行了同样的流程就会出现数据覆盖。实际排查时如果一个并发场景出现了“总量减少”、“重复写入”、“判断失效”这类现象优先就该怀疑原子性。比如用HashMap在高并发下做累加、用ArrayList在多个线程里同时add最终集合大小不对这些大概率都是复合操作被并发交错导致的。解决原子性问题有两条主线。一是用Atomic系列工具类它们基于CASCompare-And-Swap实现底层靠CPU的原子指令保证“比较并交换”这一步不可分割二是用锁机制把整个复合操作包起来让同一时刻只有一个线程能进入临界区。选哪条取决于竞争程度和代码复杂度。3.2 可见性问题多线程之间互相“看不见”可见性问题有一个很有代表性的实验主线程修改flag后子线程迟迟不退出。除了前面给出的标准示例工作中更常见的场景是日志服务、配置中心、开关切换这类地方。比如一个服务配置了一个“是否开启新功能”的开关运营在管理端改了配置服务端更新了内存中的标志位但正在运行的线程还在走旧逻辑这就是可见性问题的典型故障。可见性问题的根源在线程的工作内存与主内存之间的同步延迟。解决方式主要有三种用volatile修饰共享标志位让每次读取都从主内存拿最新值每次写入都立即刷回主内存用锁机制锁的释放会把工作内存中的数据刷新到主内存锁的获取会从主内存重新加载数据用Atomic类型底层CAS操作本身具备可见性语义。实际开发中切换开关、缓存状态位这类“单变量状态”最常用的就是volatile。它的开销比锁小得多使用起来也直观。3.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; } }new Singleton()这个操作在底层要经历分配内存、初始化对象、将引用指向内存地址。如果没有做任何约束CPU或编译器可能把“初始化对象”和“将引用指向内存地址”这两步调换顺序。极端情况下线程A先把引用指向了一块还没初始化的内存线程B此时通过第一次检查发现instance ! null直接返回了这个对象拿到的是一个“半成品”使用它的属性时就会出现空指针或默认值。这类问题的奇葩之处在于它非常依赖具体的硬件、JIT编译策略和线程调度时机可能几千次运行都不出现一旦出现就是线上偶发故障。修复方式很干脆把instance用volatile修饰禁止重排或者直接用枚举单例、静态内部类这类更高级的写法从根上绕开DCL的问题。3.4 三类问题在代码中如何区分为了便于实际判断我通常用下面这个表来快速归类问题类型典型代码形态典型故障现象主要应对手段原子性复合操作i、先判断再执行计数错误、重复写入synchronized、Atomic、Lock可见性共享状态位flag、开关、配置线程不退出、读旧值volatile、锁、Atomic有序性双重检查、对象发布拿到半初始化对象volatile、安全发布final/枚举记住这套映射关系遇到并发问题先对号入座再去想解决方案比每次从头猜一遍要高效得多。4. Happens-Before规则JMM给出的“同步裁判”4.1 不是所有变量都需要加锁规则的意义如果每次访问共享变量都要加锁或加volatile代码会变得非常沉重。JMM聪明的地方在于它没有要求“每次访问都强制同步”而是规定了一组Happens-Before关系只要两个操作之间存在Happens-Before关系前一个操作的执行结果对后一个操作可见并且前一个操作的执行顺序排在后一个操作之前。这意味着程序员不需要对所有变量手动同步只需要保证有依赖关系的操作之间存在这条关系即可。理解了这一点并发编程就从“处处加锁”变成了“在合适的位置建立规则”。4.2 六条核心规则逐条拆解JMM中Happens-Before规则比较多但实际开发中最常用的是下面这几条我用“人话”转述一下程序顺序规则同一个线程中书写在前面的操作Happens-Before书写在后面的操作。这是单线程语义的基础也是编译器重排时不能破坏的底线。监视器锁规则对一个锁的解锁Happens-Before后续对这个锁的加锁。这意味着synchronized块结束后的数据变更对下一个进入同一个锁的线程是可见的这也是为什么synchronized能同时保证原子性和可见性。volatile变量规则对一个volatile变量的写操作Happens-Before后续对这个变量的读操作。写后读读必见新值。传递性如果A Happens-Before B且B Happens-Before C那么A Happens-Before C。这个规则让多个同步手段可以组合构造一条逻辑链路。线程启动规则线程对象的start()方法Happens-Before该线程中的每一个动作。这就保证了启动线程时传给它的参数、设置的状态在子线程内部是可见的。线程终止规则线程中的所有操作Happens-Before其他线程检测到这个线程已经终止比如通过Thread.join()返回或者Thread.isAlive()返回false。4.3 锁、volatile、final的“可见性”都是哪里来的理解了这几条规则很多面试题就自然说通了volatile为什么能保证可见性因为volatile变量规则要求写后读必须能看见synchronized为什么能保证可见性因为监视器锁规则要求解锁后的修改对后续加锁者可见final为什么能安全发布对象因为JMM对final字段有特殊的初始化语义只要对象引用安全发布没有逃逸就能看到final字段的正确值不需要额外的同步。这些规则面试时能背出来不算本事能结合案例讲明白它们怎么协作才更有说服力。比如我面试别人时经常会问一个volatile变量自增1000次最后结果是1000吗答案显然不是因为volatile只保证可见性和有序性不保证复合操作的原子性。能把这条答清楚的人说明对JMM的理解不再停留在背条文。5. 面试高频场景拆解从八股文到真正的理解5.1 双重检查锁定为什么必须配volatile前面提过DCL问题这里再往深处说一层。有些人觉得synchronized已经保证了线程安全为什么还需要volatile答案是锁只能保证临界区内的独占和可见但DCL的问题发生在临界区之外。看一下代码流程线程A在synchronized块内完成了对象创建但如果new Singleton()的“初始化”和“引用赋值”被重排线程B在第一次判断instance null时可能看到了一个非null但尚未完成初始化的对象。由于线程B没有进入锁内锁规则保护不到它这种“半成品”读取就没有安全保障。给instance加上volatile就是告诉JVM和CPU在“引用赋值”这一步必须等“初始化完成”之后才能执行。这样线程B在临界区外读到了非null引用时必然拿到的是完整对象。从更实用的角度说如果不想和这些底层细节纠缠最简单的方案是使用静态内部类或枚举public enum SingletonEnum { INSTANCE; // 业务方法 }枚举单例天然线程安全、防反射攻击、序列化安全代码量最少但很多团队因为历史习惯或者对枚举的认知偏差仍然选择DCL。如果选择DCL请务必记住volatile这条生命线。5.2 Long和Double的原子性一个反直觉的边界样例很多Java开发知道八种基本类型里long和double是64位的。JMM规范里有一条不太起眼的规定对于没有被volatile修饰的long和double变量JVM允许把“写入”拆成两次32位操作来执行也就是说“高32位”和“低32位”可能分两次写入。这就导致一个线程可能读到另一个线程“写了一半”的变量高32位是新值、低32位还是旧值拼出一个你从未赋过的数。这个样例在实际生活中越来越难碰到因为64位虚拟机下JVM通常会原子地处理64位写入但面试官喜欢拿它考察对JMM细节的熟悉程度。回答时要抓住两点其一规范规定volatile修饰的long/double必须保证读写原子性其二非volatile的long/double在理论上允许非原子写入实际是否原子取决于具体平台实现。能补充一句“实际生产环境建议对long/double加volatile或者用AtomicLong/AtomicDouble”会显得更有实战经验。5.3 从“背规则”到“用规则”的思维升级面试中关于JMM的高频进阶问题往往是通过一个综合场景来考。比如线程A写了一组普通变量后写了一个volatile标志位线程B循环读这个标志位发现变化后再去读那组普通变量。请问线程B能读到完整的新值吗如果只背了volatile变量规则可能答得含糊。如果理解了传递性答案就很清晰线程A对普通变量的写入在程序顺序规则下Happens-Before它对这个volatile标志位的写入而这个写操作又Happens-Before线程B对这个标志位的读取再加上可传递性A对普通变量的写入对B也可见。这就是所谓的“volatile写-读”建立的整个内存屏障能让你在没有锁的情况下安全地发布一批相关数据。日常开发中很多无锁队列、状态机框架底层都是这么利用volatile的。6. 实战工具选型与常见误区6.1 场景匹配什么情况用volatile什么情况用锁前面讲了很多原理这一节是实操环节。面对一个具体的并发需求第一步不是翻API而是判断变量类型和操作模式。我一般这样划分单个状态标志位比如配置开关、初始化状态、运行标志用volatile。因为每次只是简单的读或写不涉及依赖旧值的复合操作。计数、求和、累加并且竞争不激烈用AtomicInteger、AtomicLong。它们基于CAS无锁性能好但要注意Atomic类也有ABA问题需要AtomicStampedReference等进阶方案。需要同时保证多个操作的原子性和互斥比如“检查余额、扣减金额、写流水”这类完整业务动作用synchronized或Lock。这种场景的粒度是代码块而不是单个变量。读多写少的场景比如缓存、配置表优先考虑ReadWriteLock或ConcurrentHashMap这类读写分离的容器而不是粗暴地用一把大锁。6.2 volatile不能做的三件事volatile是并发工具箱里最轻量但也是最容易被误解的工具。它有三件事做不了不能保证复合操作的原子性。比如count这种“读-改-写”加了volatile也白搭。不适合做“先判断再操作”的依赖场景。比如if (map.size() 10) map.put(...)这种逻辑判断和写入之间可能被其他线程插入。不能替代锁来实现多个变量之间的统一约束。如果要保证一组变量同时更新、同时可见与其用多个volatile拼凑不如用锁或不可变对象。记住了这些限制就能避免把volatile当成“万能线程安全钥匙”来用。6.3 并发Bug排查的个人经验最后分享一点实际工作中的排查经验。遇到并发问题很少有人能一眼看出根源我的定位思路通常是这样的先确定问题的线程模型哪些线程在写哪些线程在读写入频率和读取频率分别多高有没有明显的读写竞争窗口。再判断故障现象分类数据少了是原子性问题状态不更新是可见性问题偶发空指针/逻辑错乱要怀疑有序性问题。然后做最小化复现把业务代码抽离成一个尽量短的小程序用大量线程并发执行反复压制测。能稳定复现后再通过加volatile、加锁等方式做对照实验很快能锁定根因。最后做工具兜底不方便复现的考虑用JFR、Arthas等工具在线诊断观察线程栈和热点方法定位到底哪些操作在竞争。这套流程看起来朴素但确实帮我解决过多次线上偶发问题。不要一上来就翻资料找答案先动手把问题“现形”往往比背多少理论都有效。7. 写在最后的一点建议如果你问我现在还要不要花时间学JMM我的回答是不仅要学而且要学得比面试题更深一层。JMM是整个Java并发体系的基石synchronized、volatile、Lock、Atomic这些工具都只是JMM规则的具体落地。工具的使用方式可以靠文档快速掌握但只有理解了工具背后解决的问题才能真正驾驭它们。建议准备面试的朋友不要只背“volatile保证可见性、不保证原子性”这种结论而是自己动手写几个小实验亲眼看看可见性问题、原子性问题是怎么发生的。自己踩过一遍坑之后这些知识点就会真正长在身上。
延伸阅读

更多相关文章

2026/9/16 1:29:15

激光SLAM Cartographer从零安装到跑通官网数据集的完整指南

做激光SLAM的同学,早晚都会撞上Cartographer这堵墙。不管你是做扫地机、仓储AGV还是搞自动驾驶的感知融合,只要点云数据一上来,gmapping那种轻量级的构图方式就有点撑不住了,而Cartographer靠着submap回环检测那一套,能…

2026/9/16 1:24:15

MVCC原理与实战:ReadView、undo链及长事务排查

多版本并发控制(MVCC)这六个字,但凡做过后端开发或者数据库运维的人都见过。面经里它是高频八股,生产环境里它却是各种疑难杂症的病根:明明没有死锁,事务却一直报锁等待超时;明明数据量没涨多少…

2026/9/16 1:24:15

多模态图神经网络实现药物相互作用预测实战

简介:面向深度学习毕业设计、课程设计与期末大作业场景,这份压缩包提供了一套基于多模态图神经网络(Decagon)的药物相互作用预测完整实现。项目以图结构建模药物节点与相互作用边,融合药物化学结构、生物信息等多模态数…

2026/9/16 2:14:17

社交媒体重复内容与表演行为的成因与识别

1. 现象解析:社交平台上的重复内容与表演行为最近一份关于社交平台Moltbook的研究报告引发了广泛讨论。报告指出平台上存在大量重复内容和低价值互动,具体表现为:约30%的帖子是完全重复的内容,近70%的帖子被判定为"刷存在感&…

2026/9/16 2:14:17

AD-HRNet遥感语义分割:高分辨率特征与注意力机制融合实战

简介:面向遥感图像语义分割研究与应用开发者,这份源码包提供了结合注意力机制与膨胀卷积的AD-HRNet改进实现。资源以HRNet为骨干,融入注意力模块和多尺度膨胀卷积来增强特征表达,适用于高分辨率遥感影像的地物分类、建筑物提取等精…

2026/9/16 2:14:17

顺序表详解:从线性表存储结构到插入删除与时间复杂度分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/16 2:14:17

Codex作为微信小游戏确定性编译器的工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/16 2:14:17

网络追踪原理揭秘:IP地址、DNS与设备指纹如何暴露你的位置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/16 2:09:17

从零搭建AI知识库:RAG实战与准确率调优全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/9/15 4:54:30

拯救者Y7000黑屏故障排查与维修实战指南

1. 项目概述:一台黑屏的拯救者Y7000,到底卡在哪一步? 联想拯救者Y7000系列笔记本,从2018年第一代搭载i5-8300H开始,到后来的i7-9750H、i7-10750H、i5-11400H,再到2023年款的R7-7840HS,它始终是学…

2026/9/16 0:04:09

PHP源码部署实战:从环境配置到运行情侣游戏全攻略

简介:这是一套面向情侣互动场景的PHP完整源码,集成情侣飞行棋、真心话大冒险、情趣骰子等玩法,并内置完整分销制度,可自定义多种返佣比例,源码完全开源无加密,支持微信无感自动授权登录与第三方授权&#x…

2026/9/15 14:22:53

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/15 21:31:11

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/15 11:42:23

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码