
❝本文基于Java 17
并发编程中90%的诡异BUG,都源于你对JMM的认知缺失:
这些问题的本质,都和CPU缓存、指令重排、多线程内存交互规则直接相关,而JMM(Java内存模型)正是Java官方定义的、用来规范多线程环境下内存交互行为的唯一标准,它屏蔽了不同CPU架构、操作系统的底层差异,让Java并发程序在所有平台上都能保持一致的行为。
❝重要区分:JMM ≠ JVM内存结构
并发编程的所有问题,都可以归结为三个核心特性的保障:原子性、可见性、有序性,JMM的核心目标就是为这三个特性提供统一的规范保障。
特性 | 问题根源 | 核心定义 |
|---|---|---|
原子性 | 线程切换导致的操作中断 | 一个操作要么全部执行完成,要么完全不执行,执行过程中不会被线程调度打断 |
可见性 | CPU多级缓存、缓存一致性协议的延迟 | 一个线程修改了共享变量的值,其他线程能立即看到这个修改的最新值 |
有序性 | 编译器、CPU的指令重排优化 | 程序的执行顺序,和代码的编写顺序保持一致 |
其中,原子性由synchronized、JUC原子类保障;而可见性与有序性的底层实现,完全依赖JMM的三大核心:指令重排、内存屏障、先行发生原则,下面我们逐层拆解。
为了最大化利用CPU的运算性能,编译器和CPU会在不改变单线程执行结果的前提下,对代码指令进行重新排序,这个过程就是指令重排。
举个通俗的例子:你做饭的代码顺序是「洗锅→接水→等水烧开→洗米→切菜」,重排后你会在等水烧开的间隙洗米、切菜,最终的结果和原顺序完全一致,但效率大幅提升,这就是指令重排的核心逻辑。

❝注意:Java前端编译器javac几乎不会做指令重排,绝大多数重排发生在JIT即时编译期和CPU运行期,这是很多博客的常见错误
as-if-serial语义是指令重排的核心约束:不管怎么重排,单线程环境下的程序执行结果不能被改变。
为了遵守这个语义,编译器和CPU不会对存在数据依赖的指令进行重排。
数据依赖:如果两个指令操作同一个变量,且其中一个是写操作,那么这两个指令就存在数据依赖,重排会改变单线程结果,因此绝对禁止。
示例:
int a = 1;
int b = a + 1;
这两行代码存在数据依赖,b的值依赖a的赋值结果,因此绝对不会被重排。
反例:
int a = 1;
int b = 2;
这两行代码没有数据依赖,完全可能被重排,先执行b=2,再执行a=1,单线程结果不变。
as-if-serial语义只保证单线程的执行结果,完全不考虑多线程环境的影响,这就是并发诡异问题的核心根源。
我们通过一个可直接运行的完整示例,直观看到指令重排带来的问题:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 指令重排问题复现示例
* @author ken
* @date 2024
*/
@Slf4j
publicclass ReorderDemo {
/** 共享变量a */
privatestaticint a = 0;
/** 标记变量flag */
privatestaticboolean flag = false;
/** 统计重排发生的次数 */
privatestaticfinal AtomicInteger REORDER_COUNT = new AtomicInteger(0);
/** 循环执行次数,提高重排发生概率 */
privatestaticfinalint LOOP_COUNT = 100000;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < LOOP_COUNT; i++) {
// 每次循环重置状态
a = 0;
flag = false;
CountDownLatch latch = new CountDownLatch(2);
// 写线程
Thread writerThread = new Thread(() -> {
a = 1;
flag = true;
latch.countDown();
});
// 读线程
Thread readerThread = new Thread(() -> {
if (flag) {
if (a == 0) {
// 进入此分支,说明发生了指令重排:flag=true先执行,a=1后执行
REORDER_COUNT.incrementAndGet();
log.error("指令重排发生!a={}, flag={}", a, flag);
}
}
latch.countDown();
});
writerThread.start();
readerThread.start();
latch.await();
}
log.info("循环执行结束,总次数:{},重排发生次数:{}", LOOP_COUNT, REORDER_COUNT.get());
}
}
代码说明:
a=1,再执行flag=true;读线程如果看到flag=true,正常逻辑下a应该等于1a=1和flag=true没有数据依赖,可能被重排为先执行flag=true,再执行a=1flag=true,但a还没被赋值,就会出现a=0的情况,也就是重排发生了运行结果:在JDK17的Server模式下,多次循环后一定会出现重排,日志会打印出重排发生的记录,完美复现了指令重排带来的并发问题。
as-if-serial语义只保证数据依赖,不保证控制依赖,这是另一个常见的重排陷阱。 示例代码:
int a = 0;
boolean flag = false;
// 写线程
a = 1;
flag = true;
// 读线程
if (flag) { // 控制依赖
int b = a + 1;
}
写线程的a=1和flag=true没有数据依赖,读线程的if(flag)是控制依赖,编译器和CPU会采用猜测执行的优化:提前计算a+1的值,等flag=true的时候直接赋值给b,这就会导致重排问题,和上面的示例逻辑完全一致。
既然指令重排会带来这么多问题,那怎么禁止重排?答案就是内存屏障。
内存屏障是CPU层面的一组特殊指令,它有两个核心作用:
JMM屏蔽了不同CPU架构的底层差异,在不同平台(x86、ARM等)会自动生成对应的内存屏障指令,开发者不需要关心底层实现,只需要遵循JMM规范即可。
JSR-133规范中,定义了四种标准的内存屏障,覆盖了所有重排禁止场景:
屏障类型 | 核心作用 | 禁止的重排类型 |
|---|---|---|
LoadLoad | 保证读操作的顺序性 | 禁止前面的Load读指令,和后面的Load读指令重排 |
StoreStore | 保证写操作的顺序性 | 禁止前面的Store写指令,和后面的Store写指令重排 |
LoadStore | 保证读先于写执行 | 禁止前面的Load读指令,和后面的Store写指令重排 |
StoreLoad | 全能屏障,性能开销最大 | 禁止前面的Store写指令,和后面的Load读指令重排,同时具备其他三种屏障的所有效果 |
我们日常开发中用的volatile关键字,底层就是通过内存屏障实现的,这也是volatile能保证有序性和可见性的核心原因。
JMM为volatile定义了严格的内存屏障插入策略,100%符合JSR-133规范:
我们用这个策略,来解决上面的指令重排问题:只需要给flag变量加上volatile修饰,就能彻底禁止重排。 修改后的核心代码:
/** 标记变量flag,添加volatile修饰,禁止指令重排 */
private static volatile boolean flag = false;
原理说明:
flag是volatile变量,写操作flag=true的前面有StoreStore屏障,禁止前面的a=1(Store写)和flag=true(volatile写)重排,保证a=1一定先于flag=true执行flag=true的后面有StoreLoad屏障,保证写操作完成后,所有读操作都能看到最新值flag的读操作后面有LoadLoad和LoadStore屏障,保证先读flag,再读a,不会重排修改后的代码,无论循环多少次,都不会再出现重排问题,完美解决了有序性和可见性问题。
除了volatile,JMM中其他关键字的底层也依赖内存屏障:
monitorenter(加锁)相当于volatile读,会插入LoadLoad、LoadStore屏障;monitorexit(解锁)相当于volatile写,会插入StoreStore、StoreLoad屏障,因此synchronized能保证原子性、可见性、有序性JDK9引入了VarHandle(变量句柄),用来替代不安全的Unsafe类,提供了更安全、更灵活的内存屏障控制能力,性能比volatile更优,是目前JDK官方推荐的并发编程工具。
下面是JDK17下VarHandle的完整使用示例,实现和volatile相同的效果,同时支持更细粒度的内存屏障控制:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
* VarHandle内存屏障使用示例
* @author ken
* @date 2024
*/
@Slf4j
publicclass VarHandleDemo {
privateint a = 0;
privateboolean flag = false;
privatestaticfinal VarHandle A_HANDLE;
privatestaticfinal VarHandle FLAG_HANDLE;
privatestaticfinalint LOOP_COUNT = 100000;
privatestaticfinal AtomicInteger ERROR_COUNT = new AtomicInteger(0);
// 静态初始化VarHandle,绑定对应的变量
static {
try {
MethodHandles.Lookup lookup = MethodHandles.lookup();
A_HANDLE = lookup.findVarHandle(VarHandleDemo.class, "a", int.class);
FLAG_HANDLE = lookup.findVarHandle(VarHandleDemo.class, "flag", boolean.class);
} catch (ReflectiveOperationException e) {
thrownew ExceptionInInitializerError(e);
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < LOOP_COUNT; i++) {
VarHandleDemo demo = new VarHandleDemo();
CountDownLatch latch = new CountDownLatch(2);
// 写线程,使用release模式写,对应StoreStore屏障
Thread writerThread = new Thread(() -> {
A_HANDLE.setRelease(demo, 1);
FLAG_HANDLE.setRelease(demo, true);
latch.countDown();
});
// 读线程,使用acquire模式读,对应LoadLoad屏障
Thread readerThread = new Thread(() -> {
if ((boolean) FLAG_HANDLE.getAcquire(demo)) {
if ((int) A_HANDLE.getAcquire(demo) == 0) {
ERROR_COUNT.incrementAndGet();
log.error("并发问题发生!a={}, flag={}", demo.a, demo.flag);
}
}
latch.countDown();
});
writerThread.start();
readerThread.start();
latch.await();
}
log.info("循环执行结束,总次数:{},错误次数:{}", LOOP_COUNT, ERROR_COUNT.get());
}
}
代码说明:
setRelease方法对应StoreStore屏障,保证前面的写操作不会和后面的release写重排getAcquire方法对应LoadLoad屏障,保证前面的acquire读不会和后面的读操作重排到这里你可能会问:难道我每次写并发代码,都要去关心底层的内存屏障和指令重排吗?
当然不需要。JMM给开发者提供了一套上层的、无需关心底层实现的判断规则,这就是先行发生原则(happens-before),它是判断多线程环境下,共享变量的访问是否存在竞争、操作是否线程安全的唯一依据。
JLS Java SE 17 Edition 17.4.5 章节明确规定:
❝如果一个操作A happens-before 操作B,那么A操作的执行结果,对B操作完全可见,且A操作的执行顺序排在B操作之前。 同时,happens-before关系具有传递性:如果A happens-before B,B happens-before C,那么A happens-before C。
很多人误以为「先行发生」就是「时间上先执行」,这是完全错误的!
举个例子:线程A在时间上先修改了一个普通变量,线程B在时间上后读取这个变量,因为没有任何先行发生规则的约束,所以A的修改对B不一定可见,哪怕时间上A先执行。
JMM内置了8条无需任何额外同步、天然存在的先行发生规则,所有的线程安全保障,都基于这8条规则实现,必须逐条吃透,100%准确记忆:
在同一个线程内,按照代码的控制流顺序,前面的操作happens-before后面的所有操作。
❝注意:是控制流顺序,不是代码顺序,包含分支、循环等场景;单线程内的重排不影响此规则,因为as-if-serial语义保证了执行结果和代码顺序一致。
对同一个锁的unlock解锁操作,happens-before后续对这个锁的lock加锁操作。
❝这是synchronized和ReentrantLock的核心规则,保证了加锁前的所有修改,对加锁后的线程完全可见。
对一个volatile变量的写操作,happens-before后续对这个变量的读操作。
❝JSR-133增强后的核心规则,保证了volatile写的所有结果,对后续的volatile读完全可见,也是volatile能解决可见性和有序性问题的规范依据。
对一个Thread对象的start()方法调用,happens-before这个线程内的所有操作。
❝也就是说,主线程启动子线程前的所有修改,子线程启动后都能完全看到。
一个线程内的所有操作,happens-before其他线程对这个线程的终止检测操作。
❝比如通过Thread.join()等待线程结束、Thread.isAlive()判断线程是否终止,只要检测到线程终止,线程内的所有修改都对当前线程可见。
对一个线程的interrupt()中断方法调用,happens-before被中断线程检测到中断事件的操作。
❝比如通过isInterrupted()、interrupted()方法检测到中断,一定能看到调用interrupt()之前的所有修改。
一个对象的初始化完成(构造函数执行结束),happens-before这个对象的finalize()方法的开始执行。
❝保证了finalize()方法执行时,对象的所有字段都已经完成初始化,不会看到半初始化的对象。
如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A happens-before 操作C。
❝这是先行发生原则的核心扩展能力,通过传递性,可以组合多条规则,形成完整的可见性保障。

我们通过两个经典案例,彻底搞懂先行发生原则的实际使用。
回到我们最开始的重排示例,给flag加上volatile修饰后,为什么能解决问题?我们用先行发生规则拆解:
a=1 happens-before flag=true(volatile写)flag=true(volatile写) happens-before 读线程的if(flag)(volatile读)if(flag) happens-before System.out.println(a)a=1 happens-before System.out.println(a)因此,a=1的修改对读线程的打印操作完全可见,绝对不会出现a=0的情况,完美解决了重排问题。
DCL单例是面试高频题,很多人只知道要加volatile,却不知道为什么,我们用先行发生原则彻底讲透。
首先看错误的DCL单例写法(没有volatile):
package com.jam.demo;
/**
* 错误的DCL单例示例(无volatile)
* @author ken
* @date 2024
*/
publicclass WrongDclSingleton {
privatestatic WrongDclSingleton INSTANCE;
private WrongDclSingleton() {}
public static WrongDclSingleton getInstance() {
if (INSTANCE == null) { // 第一次检查
synchronized (WrongDclSingleton.class) { // 加锁
if (INSTANCE == null) { // 第二次检查
INSTANCE = new WrongDclSingleton(); // 问题根源
}
}
}
return INSTANCE;
}
}
问题根源:INSTANCE = new WrongDclSingleton()这个操作,在底层会分为三个步骤:
这三个步骤中,2和3没有数据依赖,完全可能被重排,先执行3,再执行2。此时如果有另一个线程来调用getInstance(),会看到INSTANCE不为null,但对象还没有完成初始化,拿到了一个半初始化的对象,使用时就会出现空指针等异常。
正确的DCL单例写法(加volatile):
package com.jam.demo;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* 正确的DCL单例示例(带volatile)
* @author ken
* @date 2024
*/
@Tag(name = "DCL单例", description = "正确的双重检查锁单例实现")
publicclass CorrectDclSingleton {
/** 单例实例,添加volatile修饰,禁止指令重排 */
privatestaticvolatile CorrectDclSingleton INSTANCE;
/** 私有构造函数,防止外部实例化 */
private CorrectDclSingleton() {}
/**
* 获取单例实例
* @return 单例对象
*/
public static CorrectDclSingleton getInstance() {
// 第一次检查,无锁,提高性能
if (INSTANCE == null) {
// 加锁,保证原子性
synchronized (CorrectDclSingleton.class) {
// 第二次检查,防止多线程同时进入第一次检查后重复实例化
if (INSTANCE == null) {
INSTANCE = new CorrectDclSingleton();
}
}
}
return INSTANCE;
}
}
用先行发生原则解释正确性:
因此,其他线程读取INSTANCE时,要么看到null,要么看到完全初始化完成的对象,绝对不会看到半初始化的对象,完美解决了重排问题。
我们再看一个经典的可见性问题示例,用先行发生原则解释为什么加volatile就能解决:
package com.jam.demo;
import lombok.extern.slf4j.Slf4j;
/**
* 可见性问题复现与解决示例
* @author ken
* @date 2024
*/
@Slf4j
publicclass VisibilityDemo {
/** 停止标记,添加volatile修饰保证可见性 */
privatestaticvolatileboolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread workerThread = new Thread(() -> {
int i = 0;
// 读取stop变量,volatile读保证能看到主线程的修改
while (!stop) {
i++;
}
log.info("工作线程停止,循环次数:{}", i);
});
workerThread.start();
// 主线程休眠1秒,让工作线程充分运行
Thread.sleep(1000);
// 修改stop变量,volatile写保证对工作线程可见
stop = true;
log.info("主线程已设置stop为true,等待工作线程停止");
workerThread.join();
}
}
原理说明:
while(!stop)优化成while(true),工作线程永远不会停止JMM的三大核心,本质上是一套从底层到上层的完整规范体系:
并发编程的本质,就是在性能和线程安全之间找到平衡,而JMM就是这个平衡的核心标尺。只有彻底搞懂JMM的三大核心,你才能从根上理解并发编程的本质,彻底解决那些偶发的、难以复现的并发诡异问题。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.jam.demo</groupId>
<artifactId>jmm-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.version>6.1.5</spring.version>
<lombok.version>1.18.32</lombok.version>
<guava.version>33.1.0-jre</guava.version>
<fastjson2.version>2.0.49</fastjson2.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version>
<swagger.version>2.5.0</swagger.version>
</properties>
<dependencies>
<!-- Spring核心工具类 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<!-- Guava集合工具类 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<!-- Fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Swagger3 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${swagger.version}</version>
</dependency>
<!-- 日志依赖 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.12</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.3</version>
</dependency>
</dependencies>
</project>