1. 为什么需要JMM:多线程Bug现场就是最好的引入
先讲一个我前几天帮同事排查的例子。他写了一个很简单的计数器:
public class Counter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } }开20个线程,每个线程执行一万次increment,跑完发现count根本不是20万,有时候只有十几万,有时候甚至更离谱。更诡异的是,他在本地Windows上跑,错误率没那么高,一上Linux服务器,数字波动得更厉害。
这就是典型的"一次编写到处踩坑"。不是算法写错了,不是线程没join干净,而是Java在底层做了一系列我们看不见的优化,这些优化在单线程环境下完全没问题,一旦多线程共享变量,就全爆了。
1.1 眼见不为实:被Java编译器和操作系统联手"欺骗"
很多人都以为代码写出来是什么样,执行就是什么样。实际上,从你按下运行键开始,代码要经历三个层面的"篡改":
第一层是编译器优化。JVM在生成字节码、JIT编译成机器码时,会通过指令重排序、寄存器缓存等手段提升执行效率。第二层是CPU层面的乱序执行。现代CPU为了填满流水线,会动态调整指令执行顺序。第三层是CPU多级缓存架构,导致不同核心看到的同一份内存数据可能不一致。
这三层优化在单线程下是安全的,因为最终结果和串行执行完全一致。可一旦多个线程同时操作同一个变量,你写的"先做什么再做什么"的顺序,以及"读到的值是不是最新的",全都变得不可靠。
这就是我必须讲的第一个结论:Java内存模型(JMM)不是一份技术规范文档,而是一份Java和其他硬件/编译器之间达成的"契约"。它规定了什么时候、什么条件下,一个线程对共享变量的修改,对另一个线程是可见且有序的。没有这个契约,多线程编程基本靠猜。
1.2 JMM解决的核心三件事
JMM围绕着三个特性展开,这三个词在面试题里出现的频率非常高:
- 原子性:一个或多个操作在CPU执行过程中不被中断,要么全部执行成功,要么全部不执行。典型的如
i++不是原子操作。 - 可见性:一个线程修改了共享变量的值,其他线程能否马上看到?
- 有序性:程序代码的执行顺序是否按源代码顺序?编译器、CPU的指令重排序会打乱顺序。
这三个特性单独看都不难,难在它们之间会互相影响。比如volatile能保证可见性和有序性,但保证不了原子性;synchronized能同时保证三个,但代价是锁的竞争开销。
JMM真正的意义在于,它给"什么样的程序是正确同步的、什么样的操作是线程安全的"下了一个明确的定义。你不需要关心底层CPU缓存具体怎么同步、JIT编译器具体怎么重排,只需要按照JMM的规则去写,程序就一定安全。这就是"内存模型"这个词的真正分量。
2. JMM的底层框架:主内存与工作内存的协作机制
先记住一个抽象模型,虽然它和物理硬件不完全对应,但理解JMM全靠它。
2.1 主内存和工作内存到底指什么
JMM规定,Java内存分为两部分:
- 主内存:所有线程共享的内存区域,存储所有共享变量的最新值。在物理实现上,对应堆内存中的对象实例数据。
- 工作内存:每个线程私有的内存区域,保存该线程用到的变量的副本。在物理实现上,对应CPU缓存、寄存器、写缓冲区的组合。
线程操作变量时,不能直接读写主内存,必须先把变量从主内存复制到自己的工作内存,操作完再写回主内存。
这就解释了一个经典现象:线程A改了变量,线程B读到的还是旧值。因为A改的是自己工作内存里的副本,还没刷回主内存,或者刷回了主内存但B的工作内存里还留着旧副本。
我打个比方,这就像公司里有个公告栏(主内存),每个员工手边有张便签纸(工作内存)。A员工在自己便签纸上改了方案内容,忘记抄写到公告栏,B员工过来看公告栏,当然看不到更新。这时A说"我改了呀",B说"我没看到呀",两个人都没说谎,但数据就是不一致了。
2.2 8种内存交互操作
JMM定义了8种原子操作,用来描述变量在主内存和工作内存之间怎么传递。这8个动作是面试里比较细的考点,但如果能理解它,你对"volatile什么时候刷回主内存"这种问题会非常通透。
| 操作 | 作用 |
|---|---|
| lock | 锁定主内存变量,标识为线程独占 |
| unlock | 解锁主内存变量,解除独占 |
| read | 从主内存读取变量,准备传输到工作内存 |
| load | 将read读到的值放入工作内存的变量副本 |
| use | 将工作内存的变量值传给执行引擎 |
| assign | 将执行引擎计算出的值赋给工作内存变量 |
| store | 将工作内存变量值传递给主内存,准备写入 |
| write | 将store传来的值写入主内存变量 |
一个变量从主内存"旅行"到工作内存再回去,完整链路是这样的:
read -> load -> use(执行引擎读取使用) assign -> store -> write(执行引擎计算后写回)加上lock、unlock负责在并发环境下对主内存变量加锁。
JMM还规定了一些使用规则,比如read和load、store和write必须成对出现,不允许单独丢弃一个;不允许线程把assign的最新数据在工作内存中滞留而不写回主内存;新变量只能在主内存中诞生,不能在私有工作内存中直接使用一个未初始化的变量。
这些规则如果你记不住,可以只在脑海里留一个概念:工作内存就是线程的"中间层",它的存在是为了性能,但带来了数据同步问题。JMM要做的,就是在"性能"和"一致"之间找到平衡点。
2.3 千万别把JMM和Java内存结构搞混
这里必须专门辟个谣。很多初学者看到"JMM是内存模型",就以为它说的是堆、栈、方法区。这是完全不同的两码事:
- Java内存结构(运行时数据区):描述JVM在运行Java程序时,把内存划分为堆、虚拟机栈、本地方法栈、方法区、程序计数器这几块。解决的是"数据放在哪里"的问题。
- Java内存模型(JMM):描述多线程环境下,共享变量在"主内存"与"工作内存"之间如何传递、如何保证一致性和顺序性。解决的是"多个线程之间怎么协作"的问题。
前者是静态的内存区域划分,后者是动态的并发一致性协议。如果面试官问"JMM是什么",你回答"堆和栈",那基本就凉了。正确的回答路径应该是:JMM定义了主内存、工作内存,以及它们之间的交互规则,用来解决原子性、可见性、有序性问题。
3. happens-before原则:判断并发安全性的尺子
如果让你手动保证每条内存访问都正确,代码根本没法写。JMM设计者也很清楚这一点,所以他们定了一套happens-before规则,凡是被这套规则覆盖的操作,JMM就保证前一个操作的结果对后一个操作可见。你不需要自己加同步,只要操作之间存在happens-before关系,编译器、CPU的重排序就不允许破坏这种关系。
3.1 八个happens-before规则,你只需要记最常用的四个
技术规范里列了8条,但实际工作和面试中最常用到的是以下这些。
程序次序规则:在一个线程内,书写在后面的代码,happens-before于书写在前面的代码。注意"一个线程内"这个前提。同一线程内,重排序后的结果必须和串行执行一致,所以这条规则本质上是as-if-serial语义的体现。
volatile变量规则:对一个volatile变量的写操作,happens-before于后续对这个变量的读操作。这条规则是volatile实现可见性的根本依据。只要对变量加了volatile,写完之后任何线程来读,都能读到最新的值。
锁规则:对一个锁的解锁操作,happens-before于后续对这个锁的加锁操作。这意味着如果你在临界区里修改了共享变量,退出synchronized代码块后,其他线程进入同一个锁保护的代码块时,一定能看到这些修改。
线程启动规则:对线程的start()调用,happens-before于被启动线程中的任何操作。也就是说,主线程在调用start()之前对共享变量做的修改,子线程启动后一定能看到。
传递性:如果A happens-before B,B happens-before C,那么A happens-before C。这条规则非常重要,因为它可以把上面的规则串联起来,形成一条完整的可见性链条。
3.2 用一段代码理解happens-before的实际作用
比如这样一个场景:
public class HappensBeforeDemo { private int value = 0; private boolean ready = false; // 线程A执行 public void writer() { value = 42; ready = true; } // 线程B执行 public void reader() { if (ready) { System.out.println(value); } } }如果不加任何同步,线程B有可能打印出0,也可能什么都不打印,也可能打印出42。因为ready和value之间没有happens-before关系,编译器可能把value=42重排到ready=true后面,也可能线程B先看到ready=true但value还没写回主内存。
如果给ready加上volatile:
private volatile boolean ready = false;根据volatile变量规则和传递性:线程A里value=42书写在ready=true之前,所以value=42 happens-before ready=true;ready是一个volatile写,happens-before线程B对ready的读;再通过传递性,value=42就happens-before了线程B的读操作。此时线程B看到ready=true后,读value必然得到42。
这就是happens-before规则最实用的地方:你不用管底层到底怎么同步,只需要按规则判断代码是否安全。
3.3 happens-before不等于时间上先发生
这里要警惕一个误区。happens-before并不是"前一个操作在时间上先于后一个操作发生",而是"前一个操作的结果对后一个操作可见"。这两个概念的区别很微妙。
比如两个线程A和B,A把变量x从0改成1,B在时间上稍晚一些读了x。但因为没有happens-before关系,B完全可能读到0。时间上的先后并不能保证结果可见,只有happens-before规则能保证。
我自己在项目里见过很多次类似的Bug,都是"我明明先执行了A,再执行B,为什么B读到的还是旧值"。这种问题的根源,往往就是两个线程之间缺少happens-before关系,而不是代码逻辑有问题。理清这一点,排查并发问题的思路会清晰很多。
4. volatile与synchronized:两种同步手段的底层逻辑
很多面试者能把volatile和synchronized的区别背出来,但问到底层为什么一个能保证可见性、一个能保证原子性,就答不上来了。这里用JMM的视角拆开讲。
4.1 volatile的可见性靠什么保证
volatile的两大核心能力是可见性和禁止指令重排序。
从可见性角度说,JMM规定volatile变量的读写必须满足以下规则:
- 每次use前都必须先从主内存load最新值,不允许直接使用工作内存中的缓存副本。
- 每次assign后都必须立即store到主内存,不允许在私有工作内存中拖延。
翻译成人话就是:volatile变量不经过缓存副本的"私有缓存"阶段,每次读都强制从主内存拿,每次写都强制立即刷回主内存。
从禁止重排序角度说,JMM在volatile变量的读写操作前后插入了内存屏障,限制编译器和CPU的乱序执行。这个在下一章详聊,这里先引出这个能力。
4.2 为什么volatile保证不了原子性
仍然是count++的例子。如果把count声明为volatile:
count++ 底层实际分为: 1. 读取count的值到工作内存 2. 把值+1 3. 把新值写回主内存volatile只保证了第1步读的是最新值、第3步写会立即刷回主内存。但第1步和第3步之间有一个窗口期,两个线程可能同时读到了旧值42,都在自己工作内存里算出了43,再先后写回主内存,结果count只增加了1而不是2。
所以volatile不是万能药,它只适合用在"一个线程写、多个线程读"的场景,或者作为状态标志位使用。一旦涉及多个线程同时修改同一个变量,就必须考虑原子性问题。
4.3 synchronized的底层:monitor锁与内存同步
synchronized的原理可以从两个层面看。
第一层是字节码层面。进入synchronized代码块时,字节码会执行monitorenter指令,退出时执行monitorexit指令。每个对象都关联一个monitor锁,一个线程获取了monitor锁,其他线程就得阻塞等待。
第二层是内存语义层面。根据JMM的锁规则,synchronized的锁释放会和后续的锁获取建立happens-before关系。执行流程中:
- 线程加锁时,JMM会清空该线程工作内存中涉及共享变量的内容,必须重新从主内存加载最新值。
- 线程解锁时,JMM会把该线程工作内存里所有修改过的共享变量立即刷回主内存。
这样一清一刷,就保证了临界区内的共享变量在锁的边界处,数据是最新且一致的。而且synchronized临界区内的操作不会被重排序到临界区之外(至少不会跨过锁的边界),这又保证了有序性。
所以synchronized能同时保证原子性、可见性、有序性,但因为这把锁的存在,会引入线程上下文切换和阻塞唤醒的开销。
在Java 6之后,synchronized还引入了偏向锁、轻量级锁、重量级锁的升级机制,避免了每次都用操作系统级别的互斥量,性能比早期版本好了很多。这个锁升级过程也是面试高频点,这里不展开,只要知道synchronized并不是"一开始就很重"就行。
5. 指令重排序与内存屏障:JMM的底层防线
要说JMM里最抽象、最难的部分,指令重排序和内存屏障排第二,没别的敢排第一。但这两个概念恰恰是理解JMM设计意图的钥匙。
5.1 重排序的三类来源和as-if-serial兜底
指令重排序不是JVM一家的事,它有三个来源:
| 重排序类型 | 来源 | 特点 |
|---|---|---|
| 编译器重排序 | JIT编译器 | 在不改变单线程语义前提下,调整语句执行顺序 |
| 处理器乱序执行 | CPU动态调度 | 在执行阶段打乱指令顺序以填满流水线 |
| 内存系统重排序 | 缓存与写缓冲区 | 使不同处理器看到的读写顺序不一致 |
JMM有一个兜底原则:as-if-serial语义。不管怎么重排序,单线程程序的执行结果不能被改变。这句话反过来说更有意思:只要能保证单线程结果一致,编译器、CPU可以上天入地随便优化。
比如这段代码:
int a = 1; int b = 2; int c = a + b;a和b的赋值顺序如果对最终结果没影响,编译器就可能把b=2放到a=1之前执行。但c=a+b一定不能在a、b赋值之前执行,因为那样会改变单线程结果。
理解这一点,就明白为什么JMM只限制"同步的可见性与有序性",却允许大量优化存在——因为它要在性能和安全之间保持平衡,而不是一味地牺牲性能换安全。
5.2 数据依赖和重排序的边界
如果两个操作之间存在数据依赖关系,编译器就不会重排它们。数据依赖分三种:
- 写后读:a=1; b=a; 第二条必须读到a的值。
- 写后写:a=1; a=2; 交换顺序结果不同。
- 读后写:b=a; a=1; 交换顺序结果不同。
只要有这两种操作之间存在数据依赖,重排序就被禁止。
但问题来了,数据依赖只约束单个处理器、单个线程内的操作。在多线程环境下,线程A和线程B之间的数据依赖关系,编译器根本感知不到。所以JMM才会引入内存屏障,在关键位置强制建立顺序和可见性。
5.3 内存屏障的四种基本类型
内存屏障(Memory Barrier)是JMM和CPU架构之间的一个抽象层。JMM规定,在某些指令位置插入屏障,禁止特定类型的重排序。从操作类型上分,有四类:
| 屏障类型 | 指令示例 | 作用 |
|---|---|---|
| LoadLoad屏障 | Load1; LoadLoad; Load2 | 确保Load1的数据装载先于Load2及后续装载指令 |
| StoreStore屏障 | Store1; StoreStore; Store2 | 确保Store1的数据对其他处理器可见先于Store2 |
| LoadStore屏障 | Load1; LoadStore; Store2 | 确保Load1的数据装载先于Store2及后续存储指令 |
| StoreLoad屏障 | Store1; StoreLoad; Load2 | 确保Store1的数据对其他处理器可见先于Load2的装载,是最强大的屏障 |
JMM针对volatile变量做了一套屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障,禁止上面的普通写和下面的volatile写重排序。
- 在每个volatile写操作的后面插入一个StoreLoad屏障,防止volatile写和后面可能出现的volatile读/写重排序。
- 在每个volatile读操作的后面插入LoadLoad屏障和LoadStore屏障,防止volatile读和后续普通读/普通写重排序。
- 另外还有规则防止普通写和volatile写重排。
这些规则背起来确实枯燥,但你可以从设计意图上去理解:volatile要保证的是"写完之后,其他线程读到的就是最新值"。所以写的前后要拦住一切可能破坏这个保证的重排序,读的时候也要确保读到的是新鲜数据,不被后续操作影响。
6. JMM实战:从单例模式看并发隐患的根源
理论聊完,必须落到代码上。这里用两个最常见的实际场景,看看JMM到底怎么影响你的日常开发。
6.1 双重检查锁定的单例,为什么必须加volatile
一个经典的面试连环问就是"双重检查锁定的单例要不要加volatile,为什么"。
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; } }如果不加volatile,这段代码在某些场景下会出问题。问题不在锁本身,而在instance = new Singleton()不是原子操作。它底层分为三步:
- 分配内存空间
- 在内存上初始化Singleton对象
- 把内存地址赋值给instance引用
问题在于,JIT编译器和CPU可能把第2步和第3步重排序,变成"先赋值地址,再初始化对象"。如果此时另一个线程进来,发现instance != null,直接拿去用,但对象里的字段可能还是默认值,程序就会跑出莫名其妙的结果。
加了volatile之后,根据前面的屏障规则,volatile写之前的普通操作不能重排到写之后,所以"对象初始化"这个动作一定会发生在"把new出来的地址赋值给instance"之前,就杜绝了"拿到半初始化对象"的问题。
6.2 共享变量的状态标志位
另一个常见应用场景是状态标志位。比如一个后台任务开关:
public class TaskController { private volatile boolean running = true; public void stop() { running = false; // 此时立刻刷回主内存 } public void run() { while (running) { // 执行任务 } } }因为running是volatile,主线程调用stop()之后,run()循环所在的线程必然能看到running变为false,从而退出循环。如果不加volatile,循环可能因为一直读取工作内存里的旧值而永远跑下去。注意这里没有任何多个线程同时修改running的问题,只有一个线程写、多个线程读,所以volatile完全够用,不需要synchronized。
6.3 如何排查一个并发问题
当你在项目里遇到数据不一致的情况,排查顺序建议这样走:
第一步,先确认被共享的变量有没有被多个线程同时写。如果是,先看是不是原子性问题,考虑Atomic类或加锁。
第二步,如果变量是一个线程写在另一个线程读,检查是否存在happens-before关系。没有的话,就要考虑加volatile、final,或通过锁、线程启动等方式建立关系。
第三步,如果代码逻辑正确但行为不符合预期,重点排查是不是存在重排序问题。这种Bug在本地不容易复现,通常需要在高并发、多核心环境下才会暴露。
第四步,接上JProfiler、dump线程栈,观察处于阻塞或等待状态的线程,确认加锁范围是否过大、是否存在死锁。
这个排查链路我用了很多次,绝大多数并发异常都能归结到"原子性、可见性、有序性"这三个维度之一。找到问题所对应的维度,解法自然就出来了。
7. 面试高频陷阱:关于JMM的6个误区
这一节针对初学者和面试者,把常见误区一次性理顺。
7.1 误区一:volatile比synchronized快,所以能多就用
错。volatile只解决可见性和有序性,不解决原子性。多线程更新同一个变量,用volatile照样丢数据。它的适用面比synchronized窄得多。而且volatile禁用了大量优化,写操作要强制穿透到主内存,在频繁写的场景下未必比锁快。
7.2 误区二:把变量声明为final,它就不会有可见性问题
不完全对。final修饰的字段在构造方法中正确初始化后(且对象逸出之前),JMM保证其他线程能看到正确值。但如果final字段的引用在构造方法里"逸出"了,比如在构造方法里启动一个新线程去读这个字段,那就不一定保证正确了。
7.3 误区三:原子类型能解决所有并发问题
AtomicInteger能解决count++的原子性问题,但它不能保证业务逻辑上的复合操作原子性。比如"先检查余额再扣款"这种操作,如果不用锁或CAS+版本号,单靠原子类一样会有竞态问题。
7.4 误区四:内存屏障越多越好
错误想法。内存屏障会阻止编译器和CPU优化,用得越多性能损耗越大。JMM的设计目标是在"足够安全"的前提下"尽可能优化"。所以只有真正需要的地方才会插入屏障,业务代码中大部分普通读写是没有屏障的,这也正是多线程Bug难以复现的原因。
7.5 误区五:synchronized一定比Lock接口慢
Java 6之后的synchronized经过锁升级优化,在低竞争场景下性能很不错,甚至可能优于ReentrantLock。只有在高竞争、需要可中断、可超时、公平锁等特性时,Lock接口才更合适。拿"synchronized一定慢"说事,是过时的观念。
7.6 误区六:JMM等于"主内存+工作内存"
如果你面试只答出这个模型,会显得浅。JMM的核心包含三个层面:主内存与工作内存模型、8种内存交互操作、happens-before规则。面试官如果真的深挖JMM,他真正想看的是你对happens-before、volatile屏障、重排序的理解,而不是简单的模型图解。
8. JMM优化实践:从代码层面减少并发风险与性能损耗
前面主要讲理论,这里给几个我在实际项目中验证过的优化思路,既保证线程安全,也不至于把性能拖垮。
8.1 最小化同步范围
synchronized包住的代码越少,线程竞争的时间越短,吞吐量越高。比如处理一批数据时,不要把整个循环都锁住,只锁住需要同步更新的那部分结果即可。
8.2 优先考虑volatile和final
如果共享变量是状态标志、配置开关、一次写入多次读取的不可变对象,优先用volatile或final,不要动不动就用锁。锁是重量级的,能用轻量手段解决就不用重型武器。
8.3 善用原子类与并发容器
AtomicInteger、AtomicLong、ConcurrentHashMap这些类内部已经用CAS、分段锁做了精细优化,比自己在业务代码里加synchronized更高效。比如计数器直接:
private AtomicInteger count = new AtomicInteger(0); public void add() { count.incrementAndGet(); }8.4 注意伪共享问题
这是一个容易被忽略的性能陷阱。当多个线程修改的变量恰好位于同一个CPU缓存行时,会因为缓存行失效导致性能骤降。Java 8及之后可以用@Contended注解或布局填充来避免,但需要JVM参数开启。
8.5 用长耗时操作规避锁
如果一个操作很耗时但只有其中一小部分需要同步,把耗时的部分移出临界区。比如网络请求、数据库查询这些操作,如果放在synchronized里,整个系统的吞吐量会被明显拖垮。实际项目里,这类问题比理论上的并发正确性更常见。
这些优化实践都围绕一个核心原则:JMM给了你安全底线,但底线之上的性能空间需要你自己设计。多想想每个共享变量天然需要的同步程度,不要一刀切用锁,也不要一刀切不用锁。
回到开头的计数器问题。修好它,最简单的办法就是把它换成AtomicInteger,或者给increment方法加synchronized。但更重要的是,你要理解为什么它修好了,因为这才是面试官想听到的深度。JMM看起来是一堆抽象概念,但它之所以存在,就是因为真实世界里并发Bug太容易发生了,必须用一个严格的契约来约束看似不可控的底层优化。把这个契约吃透,多线程开发里的大多数问题,你都能在酿成大祸之前提前嗅到味道。