JMM到底是什么,别再和JVM内存模型搞混了
做Java并发编程绕不开一个核心概念:JMM,全称Java Memory Model,也就是Java内存模型。我见过太多同学在面试和技术讨论里,把JMM说成堆、栈、方法区那些东西,那是JVM内存模型(Runtime Data Area),完全是两码事。JMM不是描述对象怎么在内存里存放的,它描述的是多线程并发场景下,共享变量的读写是如何在多个线程之间达成一致的底层规则。
这篇文章想用实际开发的经验,把JMM这层窗户纸捅破。你不需要把JSR-133规范背下来,但你需要理解JMM到底解决了什么问题、它内部是怎么运作的、以及它在日常编码中怎么指导我们写出正确的并发程序。如果你是准备面试的Java开发,或者写并发代码经常感觉像在碰运气,这篇文章应该能帮上大忙。先说结论:JMM解决的根本问题只有一个——在多线程环境下,一个线程对共享变量的写入,什么时候、通过什么方式,能被另一个线程看到,以及这种可见性要付出什么样的代价。
1. 为什么需要一套内存模型
1.1 现代计算机的存储层级和缓存一致性
要理解JMM,先得看看硬件层面CPU和内存是怎么协作的。现代CPU的运算速度和内存的读取速度差距非常大,如果CPU每次读写都直接访问物理内存,那CPU大部分时间都在等待数据搬运。所以硬件层面做了层层缓存:每个CPU核心有自己的L1/L2缓存,多个核心共享L3缓存,再往下才是主内存(DRAM)。CPU运行时先把数据从主内存读到缓存,运算完再回写。
这就带来一个核心问题:缓存一致性。两个CPU核心同时读取了某个变量到各自的缓存,核心A修改了这个值,核心B还在用旧值,那程序就出错了。硬件通过MESI这类缓存一致性协议来保证多个核心之间观察到的同一个缓存行是同步的。但是,同步本身是有代价的,缓存行失效、总线通信、写缓冲等待,都会拖慢执行速度。为了性能,CPU和编译器还会做指令重排序——在单线程语义不变的前提下,把指令顺序打乱,让流水线利用更充分。
有了这套硬件背景,JMM的定位就很清晰了。Java是一种跨平台语言,不能假设代码只跑在x86上,也不能假设所有CPU都实现了相同的缓存一致性协议。JMM要做的是:在Java层定义一套统一的、跨平台的并发语义规范,让Java程序员不需要了解底层硬件细节,也能编写出行为可预测的并发代码。它要求每个JVM实现都必须遵守这套规范,但具体怎么映射到CPU指令,JVM自己决定。
1.2 并发三大特性:原子性、可见性、有序性
JMM的所有规则最终都围绕三个特性展开。
原子性指的是一个操作或多个操作要么全部执行且不被中断,要么全部不执行。Java中对变量的简单读写(除了long/double这种64位类型在某些32位JVM上可能拆成两次操作)是原子的,i++这种读-改-写操作不是原子的,所以并发场景下要靠锁或Atomic类来保证。
可见性指的是一个线程修改了共享变量后,其他线程能否马上看到这个修改。在JMM的规则下,每个线程有自己的工作内存(对应CPU缓存和寄存器),直接修改工作内存里的值不立即刷新回主内存的话,其他线程读到的就是旧值。volatile关键字、synchronized和Lock都能保证可见性。
有序性指的是程序执行的顺序符合代码逻辑顺序。但JVM和CPU为了性能会做指令重排序,单线程下没问题,多线程下就会出现奇怪的现象。Java中通过volatile、synchronized、final以及显式的内存屏障来约束重排序。
这三个特性互相交织,JMM就是围绕它们设计出一组规则。happens-before原则本质上是给程序员一个判断可见性和有序性的依据,它用“只要A happens-before B,那么A的操作对B可见”这样一句话,把复杂的内存屏障细节封装住了。
1.3 JMM的定位:规范抽象,而不是具体的堆栈模型
JMM是一种抽象的内存模型,它定义的是“线程-主内存-工作内存”三者之间的交互规则。这里说的主内存和工作内存,并不是JVM堆内存那种物理概念,而是JMM为了描述可见性规则而抽象出的逻辑概念。一个Java线程对应一个工作内存的抽象,里面保存了线程使用到的变量副本;所有线程共享主内存,里面放着变量的“正规军”值。
写并发代码时,默认情况下的行为是:线程把变量读入自己的工作内存操作,什么时候把修改同步回主内存,是不确定的,由JVM和底层硬件共同决定。有人会问:“那我平时写并发代码不也经常跑得挺好吗?”因为很多场景下你碰巧没有遇到问题——线程启动时、锁释放时、Thread.join时,这些点都有隐式的同步边界,覆盖掉了一部分问题。但一旦代码并发度高、长跑、压测,数据不一致的问题就会浮现出来。
所以,JMM就是那个让你能真正预测并发程序行为的“游戏规则说明书”。不遵守它,你的代码可能今天不出错,明天一出错就是线上事故。
2. JMM和JVM内存模型的区别,以及名字混淆的根源
2.1 两者的定义范围和关注点完全不同
JVM内存模型(JVM Memory Model / Runtime Data Area)描述的是JVM运行时把内存划分成哪些区域:程序计数器(Program Counter Register)、虚拟机栈(JVM Stack)、本地方法栈(Native Method Stack)、堆(Heap)、方法区(Method Area,在JDK 8以后实现为Metaspace)。它解决的是对象实例、类元数据、方法调用帧这些数据放在哪里,生命周期怎么管理,以及垃圾回收作用在哪些区域的问题。
JMM关心的是多线程程序里的数据可见性和重排序问题。它跟对象存放在堆还是栈没有关系。JMM的“主内存”并不仅仅指JVM堆,而是涵盖了所有共享变量的存储位置(通常在堆上,但也可以是静态字段、数组元素等);JMM的“工作内存”也不是虚拟机栈这种物理区域,它只是JMM规范里的抽象概念,实际对应的是CPU缓存、写缓冲区、寄存器等高速存储结构的组合。
2.2 为什么这两个概念会被混淆
混淆的根源有几个。第一,两者都叫“memory model”,中文都翻译成“内存模型”,这个译名本身就有歧义。第二,很多初学资料在介绍JVM内存区域时顺手说了“Java内存模型”,读者以为堆栈就是Java内存模型,于是把JMM等同于JVM内存划分。第三,面试中这两者经常一起出现,如果面试者只背了JVM区域的名称,没真正看过JMM规范,很容易张冠李戴。
我在实际中遇到的典型场景是:面试者能把堆、栈、方法区分得清清楚楚,但一问“volatile为什么能保证可见性”,就开始含糊其辞。反过来也有,聊JMM的happens-before规则头头是道,但问Metaspace存什么就懵了。这两个概念确实都需要掌握,但不是一回事。
2.3 掌握准确概念的一个辅助记忆方法
用一个类比来辅助区分。JVM内存模型像是办公室的物理布局图——哪个房间是工位(栈),哪个房间是仓库(堆),哪个房间是图书室(方法区),这决定了大家工作和存放物品的位置。JMM则像是办公室的沟通规则——大家说话的声音、写便签的传播范围、更新的公告栏多久刷一次,这决定了你贴的通知(变量修改)能不能被其他同事(线程)及时看见,以及会不会因为纸条传递顺序错乱而导致误解。
这个类比不算完全严谨,但能帮助你在头脑里快速建立边界。碰到“Java内存模型”这个词时,先看上下文问的是存储布局还是并发语义,就大致知道它指的是JVM Memory Model还是JMM。
3. JMM的核心机制拆解
3.1 主内存与工作内存的交互规则
JMM规定所有变量都存储在主内存中(Main Memory),每个线程拥有自己的工作内存(Working Memory)。线程对变量的所有操作,都必须在工作内存中进行,不能直接读写主内存中的变量。线程之间的共享变量值传递,必须通过主内存来完成。
这套模型类比成协作场景就是:每个员工(线程)办公桌上有一份共享台账(共享变量)的复印件,员工只能改自己桌上的复印件,改完必须放回公共档案柜(主内存),其他员工下一次从档案柜拿新的复印件才能看到改动。如果某个员工一直不把复印件放回档案柜,或者放着旧复印件不更新,大家看到的数据自然不一致。
JMM还定义了8种内存交互操作(JDK 9之后有新的访问模式描述,但核心逻辑不变):lock、unlock、read、load、use、assign、store、write。其中read和load、store和write是成对出现的。整个流程是:从主内存read一个变量到工作内存,再load进工作内存的变量副本;线程对变量进行操作时use变量副本的值,运算后assign赋值回变量副本;需要同步回主内存时store变量副本值,再write到主内存。lock和unlock则是加锁和释放锁的粒度为变量级别的显式同步。
这些操作并不是对程序员直接开放的,而是由JVM在合适的时机触发。你写代码时不会调用read/load来同步变量,但这些语义约束定义了JVM和CPU可以重排序的边界。
3.2 指令重排序与内存屏障
JMM允许编译器、处理器对指令序列进行重排序,只要不改变单线程执行的语义(as-if-serial语义)。这意味着,在单线程下看起来很自然的执行顺序,在多线程并发下可能产生不可预期的交错。基于这个原因,JMM规定了哪些重排序是允许的、哪些是不允许的,并通过内存屏障指令来阻止非法的重排序。
内存屏障(Memory Barrier)是一类CPU指令,主要分为四类:LoadLoad屏障(禁止上面的读操作和下面的读操作重排序)、StoreStore屏障(禁止上面的写操作和下面的写操作重排序)、LoadStore屏障(禁止上面的读操作和下面的写操作重排序)、StoreLoad屏障(禁止上面的写操作和下面的读操作重排序,同时要求写操作先被其他处理器感知)。
JVM在生成指令时,会在合适的位置插入内存屏障来限制重排序。比如volatile读之后会插入LoadLoad屏障和LoadStore屏障,保证volatile读不会被后面任何普通读写操作重排序到前面;volatile写之前会插入StoreStore屏障,保证volatile写之前的普通写入不会被重排序到volatile写之后;volatile写之后会插入StoreLoad屏障,保证volatile写不会被后面的普通读操作重排序到前面。
这里有个细节:x86处理器本身的内存模型较强(TSO模型),对于LoadLoad、StoreStore、LoadStore屏障基本是天然满足的,所以x86上JVM实际需要的屏障主要是StoreLoad屏障。但在ARM、PowerPC等弱内存模型架构上,所有屏障都需要真实插入。这也是为什么同一套Java代码在不同硬件上表现不同,在没有正确同步的情况下会出现诡异的并发Bug。
3.3 volatile、synchronized、final分别如何对接JMM
volatile是JMM中最轻量的同步机制。它的语义是:对一个volatile变量的写操作,会立即刷新到主内存;对一个volatile变量的读操作,会从主内存重新读取最新的值。同时,JMM禁止了volatile读写前后的重排序。这样volatile就提供了可见性保证,但不提供原子性保证。换句话说,用volatile修饰一个int变量,单线程内多次自增还是会有并发问题,因为自增是读-改-写三步,不满足原子性。
synchronized依赖JMM中的锁规则。加锁时,JMM要求清空工作内存,强制从主内存重新读取最新值;释放锁时,JMM要求把工作内存中的修改刷新回主内存。这样就形成了“线程A解锁之前的所有写操作,对于线程B加锁之后的读操作都是可见的”这个关键结论。synchronized同时保证原子性、可见性和有序性,但代价是阻塞和上下文切换。
final的语义有些特殊。JMM规定,在构造函数中初始化的final字段,构造完毕之后,其他线程可以看到构造后的final字段值,前提是没有this引用逸出。这是因为JMM会为final字段的重排序做出限制,final字段的写操作不能重排序到构造函数之外,确保安全发布final对象。
3.4 long和double的特殊非原子性
Java语言规范规定,对于long和double这种64位类型,在32位JVM上可能被当作两个32位操作处理,这就导致“半个字写”问题——另一个线程可能读到高32位是新值、低32位是旧值的混合状态。JMM的处理方式是:如果long/double变量没有用volatile修饰,不保证读写的原子性;一旦用volatile修饰,则必须保证原子性。
在64位JVM上,绝大多数实现会保证long和double引用的原子性,但JMM规范并未强制要求非volatile的64位类型一定原子。所以,为了跨平台的安全,共享的long/double变量建议要么加volatile,要么放在锁保护范围内。我见过一个实际案例:在32位环境上,某日志框架的时间戳字段用了long类型且没有同步,压测时出现了时间戳乱跳的情况,正是这种半写问题导致的。
4. happens-before规则,JMM给程序员的最实用武器
4.1 八条规则逐条解读
happens-before是JMM定义的一种偏序关系。如果操作A happens-before操作B,那么A的结果对B可见,且A的执行顺序在B之前(从内存可见性角度)。需要强调的是,happens-before并不是时间顺序,而是可见性顺序。八条规则如下:
程序次序规则:一个线程内,按代码顺序,前面的操作happens-before后面的操作。这个规则说的是单线程内的控制流顺序,不考虑重排序的情况,因为重排序不能改变单线程的语义。
管程锁定规则:对一个锁的解锁(unlock)happens-before后续对这个锁的加锁(lock)。重点在于“后续”是同一把锁上的后续加锁。这保证了锁内保护的所有写操作对竞争同一把锁的线程可见。
volatile变量规则:对一个volatile变量的写操作happens-before后续对这个volatile变量的读操作。注意是“后续”,即读操作发生的时间点必须晚于写操作,且读到的值必须是最新写入的值。
线程启动规则:对线程的start()调用happens-before该线程中任何一个动作。这意味着所有在start()之前写入的共享变量,在新线程开始执行后都能看到。
线程终止规则:线程中的所有动作happens-before该线程的终止检测,也就是其他线程通过Thread.join()返回,或通过Thread.isAlive()检测到线程已终止时,该线程中的所有操作结果对其他线程可见。
线程中断规则:对线程interrupt()方法的调用happens-before被中断线程检测到中断事件(抛出InterruptedException或调用isInterrupted()发现状态置位)。
对象终结规则:一个对象的构造函数结束happens-before它的finalizer开始执行。
传递性:如果A happens-before B,且B happens-before C,则A happens-before C。这是最常用的一条推论规则,它允许我们通过多条规则链推导出跨线程的可见性。
4.2 用规则推导一个实际的可见性场景
拿生产者-消费者场景举个例子。
// 共享变量 static boolean ready = false; static int num = 0; // 线程A num = 42; // 操作1 ready = true; // 操作2(volatile写) // 线程B while (!ready) { // 操作3(volatile读) // 自旋等待 } System.out.println(num); // 操作4按照规则:操作1写入num是普通写,操作2是volatile写,操作3是volatile读。volatile写规则保证操作2 happens-before操作3,因此一旦线程B看到ready为true,它必然能看到num的最新值42。程序次序规则保证操作1 happens-before操作2,传递性保证操作1的结果对操作3之后的读可见。所以最终打印的num必然是42,不会是0。
如果ready不是volatile,这段代码就是有问题的:即使线程B看到了ready=true,它也可能读到num的旧值0,因为普通写没有跨线程的可见性保证。这就是volatile最典型的使用场景——作为状态标志,同时通过它传递其他非volatile变量的写入结果。
4.3 happens-before和最直接的内存屏障之间如何对应
happens-before规则是JMM给程序员看的抽象接口,内存屏障是物理实现。编写Java代码时,我们依据happens-before规则判断对错,不需要关心具体插了哪些屏障。但排查汇编级别的并发问题时,了解两层之间的对应关系很有帮助。比如volatile写后面跟一个StoreLoad屏障,它会清空写缓冲并等待前面的写操作全部刷新到主内存;volatile读后面跟LoadLoad屏障则确保后面的读操作不能提前越过volatile读。
实际编码中,绝大多数时候用happens-before做推理就够了。但如果看到“重排序导致的问题”“缓存未失效”这类字眼,那就要往内存屏障的方向想。我曾经在排查一个问题时,发现某个高频路径的普通字段读到了一个极其陈旧的值(时间戳落后了几秒),后来分析是因为所在线程长时间没有进行任何可能触发同步的操作(没有加锁、没有volatile读写、没有Thread.yield),CPU缓存中的旧值一直没有失效。这种问题用happens-before推理最直观:因为没有建立任何跨线程的happens-before关系,所以读到旧值是允许的。
5. 结合锁、volatile、final来理解JMM的实际使用
5.1 单例模式DCL加volatile的经典案例
双重检查锁(DCL)是面试里最常考的模式,也是理解volatile和synchronized在JMM中如何协同的好例子。
public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { // 加锁 if (instance == null) { // 第二次检查 instance = new Singleton(); // 创建实例 } } } return instance; } }问题是:instance为什么必须加volatile?关键在于new Singleton()这个操作不是原子操作。它大致包含三步:分配内存空间、调用构造器初始化对象、把instance引用指向这块内存。在没有volatile的情况下,JVM和CPU可能把第3步重排序到第2步之前。此时另一个线程执行第一次检查,发现instance不为null,直接返回了这个还没初始化完成的对象,使用时就会出现空指针或部分初始化状态。
加上volatile之后,JMM禁止了volatile写(instance赋值)前面的普通写(构造器里的初始化)重排序到volatile写之后,也就是说,instance引用被发布出去之前,对象一定已经完整初始化。这就杜绝了“部分构造对象被提前暴露”的问题。
我遇到一些团队说他们不用DCL,改成静态内部类方式来实现单例,避免记忆volatile语义的负担。确实,静态内部类由classloader保证线程安全,更简单可靠。但理解DCL的volatile问题,仍然是检验JMM掌握程度的试金石。
5.2 锁内部的可见性保障和锁释放的细节
synchronized的可见性边界非常清晰:进入synchronized块时,该线程的工作内存会失效,后续读操作必须从主内存读取;退出synchronized块时,所有修改会刷新到主内存。在此基础上,如果两个线程竞争同一把锁,那么解锁和加锁之间就建立了一个happens-before关系。
Lock接口实现(如ReentrantLock、ReentrantReadWriteLock)在JMM语义上等价于synchronized。通过AQS(AbstractQueuedSynchronizer)内部的volatile state变量来传播锁状态,保证释放锁的线程对共享变量的修改,对获取锁的线程可见。
要注意的一个坑是锁的粒度:如果你用synchronized保护的是A变量,但另一个线程修改B变量时也用了同一把锁,那没问题;但如果你在修改B变量时用的不是同一把锁,锁跨线程的可见性保障就完全不成立。JMM不保证任何“隐式的、未约定的”同步。很多时候并发Bug不是没有加锁,而是加错了锁、锁对象不一致,导致happens-before关系没有建立。
5.3 final的可见性保障和this引用逸出的风险
final字段在并发下有额外的编译保障。JMM要求,只要对象正确构造(构造函数没有把this引用泄露出去),并且构造完成后不可变,那么其他线程通过安全的方式获得该对象引用(比如通过static字段或其他同步机制发布),就一定能看到所有final字段的最终值。这是因为JVM会将final字段的构造函数写入与构造函数之外的读操作做排序限制。
this引用逸出是final机制失效的经典场景。比如在构造函数里启动一个线程,线程体中读取这个对象的字段,此时这个对象还没构造完成,线程拿到的可能是部分初始化的对象。即使字段是final,也无法保证该线程读到正确值,因为this引用在构造函数完成前就泄露了。正确的做法是在构造函数结束后,再通过专门的方法去启动依赖该对象的线程。
5.4 ThreadLocal与线程封闭的可见性
ThreadLocal本身并不直接依赖JMM的跨线程可见性,它把数据绑定在某个线程内部,从根本上避免了共享。但要注意的是,ThreadLocal中的值实际上是存储在当前线程的ThreadLocalMap中的,这个Map的Entry是WeakReference类型的。如果父线程创建了ThreadLocal值,子线程通过InheritableThreadLocal来获取,那么在子线程创建时,父线程的值会一次性拷贝到子线程,后续父线程的值变了子线程是看不到的。
老实说,这个机制倒没有什么JMM的坑,更多是生命周期管理的问题(比如线程池复用时,ThreadLocal残留导致数据混乱)。但从JMM角度理解线程封闭价值在于:完全不共享的变量,自然不涉及可见性、原子性、有序性问题。这是并发编程“最简单”的解题思路——不要共享。
6. 一个真实并发Bug的排查过程,来看JMM在实战中的应用
6.1 症状与初步猜测
我曾经参与过一个订单处理系统的问题排查。现象很诡异:在流量高峰时,某些订单状态在数据库里被更新成功,但应用内存中的订单对象状态长时间没有刷新,导致后续对同一订单的操作读到了“过期”状态。系统用了多线程并发处理的架构:一个线程负责从MQ拉到订单消息并更新内存Map,另一个线程从该Map读取订单状态做后续业务。
当时我们第一反应是数据库缓存问题,但排查后发现数据库始终是最新的,问题出在应用内存里。继续看代码,发现更新内存Map的线程没有加锁,读取的线程也没有加锁,Map用的是ConcurrentHashMap。单个Key的put和get是原子的,但问题在于“订单状态”和“订单实体”是两个字段,更新流程是先构建新的订单对象再调用map.put(),这个put虽然原子,但是读取线程读到的可能是旧版本的对象。
6.2 用happens-before推导定位根因
进一步分析发现,更新流程里除了map.put()之外没有任何同步操作(没有锁、没有volatile、没有AtomicReference)。读取流程同样只有map.get()。这两个线程之间没有建立任何happens-before关系。虽然ConcurrentHashMap保证单个操作的线程安全,但“线程A更新Map的可见性”和“线程B读取Map的可见性”之间并没有强制同步边界。
换句话说,ConcurrentHashMap只保证它是线程安全的容器,不会保证里面的元素引用变化能被其他线程立即看到。所以读取线程可能长时间读到自己工作内存里的旧对象(甚至旧对象的字段状态),这就是可见性问题。
定位后我们做了两个修复:第一,把持有订单对象的引用改成AtomicReference,通过CAS更新,利用volatile的写读语义建立happens-before关系,让写线程的最新对象对读线程可见;第二,在读取侧增加请求级别的校验,如果本地副本落后,主动从数据库重新加载最新状态。
6.3 这个案例提炼出的经验
从这次排查中学到的东西我总结成了几条经验,在日常写并发代码时非常有用。
第一,线程安全不等于可见性。一个类标榜线程安全(线程安全的容器、线程安全的计数器),只意味着它自身的内部状态不会被多线程搞坏,但不意味着跨线程的“新值可见”是隐式成立的。要传递新值,还是得依靠happens-before规则。
第二,写代码时时时刻刻问自己:读线程和写线程之间,建立了哪条happens-before关系?如果答不上来,那你就是在赌并发不出错。JMM不允许你运气好就当作正确。
第三,同步工具的选择顺序应该是:优先无共享(线程封闭、不可变对象),其次用现成的并发工具(ConcurrentHashMap、Atomic类、阻塞队列、显式锁),再考虑裸的volatile和synchronized。越是底层的手段,越要求你对JMM有深入理解。
第四,线上疑难并发问题,建议给JVM加参数-XX:+PrintAssembly或者使用JITWatch工具,看看热点代码生成的汇编指令,搜索lfence、mfence这类屏障指令,能直观看到JVM在哪里插了屏障。这不是日常必需技能,但在排查强实时性系统的可见性问题时能帮上大忙。
7. 提到JMM时大家常犯的几个理解误区和补充建议
7.1 误区:volatile保证原子性
这是最普遍的错误认知。volatile只保证可见性和有序性,不保证复合操作的原子性。例如多个线程同时对一个volatile int执行value++,最终结果仍然会小于期望值。正确做法是用AtomicInteger或者synchronized保护自增操作。
7.2 误区:synchronized一定会导致严重的性能损耗
JVM对synchronized做了很多优化:偏向锁、轻量级锁、锁升级、锁消除、锁粗化。在低竞争甚至无竞争场景下,synchronized的开销非常低。很多人无脑用ConcurrentHashMap替代HashTable,却不知道HashTable的全局锁在高并发读多写少时也不见得差(因为ConcurrentHashMap在JDK 8之后用CAS+synchronized锁桶,但读路径也是volatile读,开销并不低)。JMM层面的知识能帮助你判断同步成本和正确性的权衡。
7.3 误区:64位JVM上long/double读写天然原子
JMM规范并没有做出这种保证。虽然绝大多数主流的64位JVM实现会把64位读写原子化(尤其在x86 64位环境下),但如果你依赖这一行为做跨平台并发,就是把自己的正确性建立在实现细节上。老老实实加volatile或者锁保护。同理,任何“我觉得某个JVM行为应该如此”的想法都是并发编程的大敌。
7.4 关于性能的度量:同步不等于慢,但屏障不是免费的
volatile读写会插入内存屏障,影响指令重排序和CPU流水线效率。在高频路径上无节制地使用volatile,可能让性能退化明显。但反过来,不同步导致的并发错误带来的损失远超性能损失。平衡点在于:确认自己的并发场景真的需要同步,然后选择粒度最小的同步手段。
举个例子:如果共享变量是配置项,初始化后不再变化,那么把它设计成不可变对象再发布(比如通过AtomicReference一次性赋值),后续读操作就不需要加锁或加volatile,性能最佳。如果共享变量频繁变化且对实时性要求极高,那就需要认真设计内存屏障的布局了。
7.5 JMM的学习路径建议
我一直觉得JMM不应该靠背概念学,而是靠场景驱动。你先动手写一个明显有可见性问题的多线程Demo,跑起来观察不同步时的表现;然后加上volatile、synchronized,再观察变化;然后再通过happens-before规则推导其他工具(比如ConcurrentHashMap、CountDownLatch、CyclicBarrier)的可见性保证。这样建立起来的知识是活的,而不是死记硬背。
对于准备面试的同学,我的建议是:把happens-before八条规则记下来并理解,把volatile和synchronized的JMM语义吃透,把DCL单例的volatile问题讲清楚,基本就能应对大部分并发底层的提问了。但更重要的是,把这些知识用到实际代码里。
回到最开始的问题,JMM和JVM内存模型的区别。JVM内存模型是你工作的场所,JMM是你在场所里进行多人协作时的沟通规则。把场所和规则分开理解,并发编程的很多困惑就自然消散了。希望这篇能帮你把JMM这层底层语义梳理清楚。