主题
并发三大问题与 Java 内存模型(JMM)入门
本文是 Java 并发系统学习系列的 L1 入门篇。前置:线程基础与生命周期。 学完可以配合面试题食用:happens-before 与内存屏障
上一篇文章我们搞清楚了"线程是什么、怎么创建"。但只要多个线程共享同一份数据,麻烦就来了:变量改了别人看不见、两条指令执行顺序和代码写的不一样、看似一行的操作其实是三步。这三个问题分别叫可见性、有序性、原子性,是并发 bug 的三大源头。Java 内存模型(Java Memory Model,JMM)就是用来描述并解决它们的规范。这篇从三个小实验出发,把 JMM 讲到"能指导写代码"的程度。
三个小实验,复现三大问题
不背定义,先看现象。
实验一:i++ 不安全(原子性被破坏)。i++ 看着是一行,字节码上是三步:读取 i、加 1、写回。两个线程同时执行,就可能都读到 10、各自加 1、都写回 11——两次自增只生效一次。开 20 个线程各加 10000 次,结果几乎必然小于 200000:
java
static int count = 0;
public static void main(String[] args) throws Exception {
Runnable task = () -> { for (int i = 0; i < 10000; i++) count++; };
Thread[] ts = new Thread[20];
for (int i = 0; i < 20; i++) { ts[i] = new Thread(task); ts[i].start(); }
for (Thread t : ts) t.join();
System.out.println(count); // 基本不会是 200000
}实验二:主线程改了 flag,工作线程看不见(可见性丢失)。一个线程死循环等 stop 变 true,主线程把它改成 true,循环却可能永远不退出——工作线程一直读的是自己缓存里的旧值。可复现代码见本文"动手实操"一节。
实验三:指令重排(有序性被破坏)。编译器和 CPU 为了性能会重排指令,单线程下结果不变(as-if-serial),但多线程交错时另一些线程可能看到"写操作顺序颠倒"的效果。经典例子是双检锁单例没加 volatile 时,另一个线程可能拿到"对象已分配、字段还没初始化"的半成品。
三大问题一句话总结:原子性是"操作会被打断",可见性是"改了别人看不见",有序性是"执行顺序没有保证"。
JMM:主内存、工作内存与 8 种操作
为什么会出现这些问题?因为 Java 试图屏蔽各硬件的差异:有的 CPU 是弱内存模型(ARM),有的是强内存模型(x86),缓存结构、刷新时机都不同。JMM 在它们之上定义了一套统一的抽象,规定所有线程的共享变量都存在主内存里,每个线程有自己的工作内存(可理解为寄存器 + CPU 缓存的抽象)。线程对变量的所有读写都发生在自己的工作内存,不能直接读写主内存。
线程间拷贝变量靠 8 种原子操作(了解即可,JLS 17.7 定义):lock、unlock、read、load、use、assign、store、write。比如线程 A 把变量写回主内存要走 assign(工作内存赋值)→ store(传输)→ write(写入主存);线程 B 读要走 read → load → use。三大问题在这个模型下都能解释:
- 可见性问题 = A 的 write 和 B 的 load 之间没有强制关系,B 可能永远不重新 load
- 原子性问题 = read-modify-write 跨越多个操作,中间可被其他线程插入
- 有序性问题 = 指令重排后,write 的顺序对外界观察者而言可能颠倒
mermaid
flowchart LR
subgraph 线程A
WA[工作内存A\nx=1副本]
end
subgraph 线程B
WB[工作内存B\nx=0旧副本]
end
MM[(主内存\n共享变量)]
WA -- assign/store/write --> MM
MM -- read/load/use --> WB注意两点:一是 JMM 是规范不是实现,HotSpot 在 x86 上很多可见性保证是硬件白送的,但这不改变我们该按规范写代码;二是这 8 种操作是 Java 5 之前的表述,Java 5 重写为 happens-before 语义,但"主内存/工作内存"这个心智模型至今仍然好用。
happens-before:判断"看得见"的唯一依据
happens-before 是 JMM 的核心概念。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 的执行顺序排在 B 前(这里指内存可见性意义上的顺序,不是时钟先后)。它回答的问题是:两个操作之间,什么时候才存在可见性保证? 没有 happens-before 关系的两个操作,JMM 不做任何承诺,JVM 可以任意重排。
规则不必背全文,记住五条高频的就够日常用:
- 程序顺序规则:单线程内,前面的操作 happens-before 后面的操作
- 监视器锁规则:对一个锁的解锁 happens-before 后续对同一把锁的加锁
- volatile 规则:对 volatile 变量的写 happens-before 后续对它的读
- 线程启动规则:Thread.start() happens-before 该线程内的任何操作(所以启动前设置的字段,子线程一定可见)
- 传递性:A hb B,B hb C,则 A hb C
用法举个例子:线程 A 把数据写入普通字段 data,再写 volatile 标志位 flag=true;线程 B 读到 flag==true。由 volatile 规则(写 hb 读)+ 程序顺序 + 传递性,B 读到的 data 一定是 A 写入的版本。这就是 volatile 实现"发布"的原理,也是并发工具的通用套路:用一个同步动作(锁/volatile/CAS)做边界,边界前的所有写操作对边界后的读都可见。
JMM 引出的两大工具:synchronized 与 volatile
理解 JMM 后,后续两大主角的定位就清晰了——它们都是 JMM 层面的"同步动作",但保证的范围不同:
- synchronized:保证原子 + 可见 + 有序。同一时刻只有一个线程能进入临界区(原子性);解锁前把工作内存刷回主内存、加锁时使工作内存失效(可见性);临界区内代码不会与临界区外的操作重排交叉(有序性)。代价是可能阻塞、上下文切换
- volatile:只保证可见 + 有序(禁止指令重排),不保证原子性。i++ 加了 volatile 照样丢更新,因为 read-modify-write 仍是三步。它是轻量级的"状态标志"工具,适合一写多读的场景
选型直觉:需要互斥( count++、修改复合状态)用 synchronized 或 CAS;只需要"一个线程改、其他线程立刻看见"(开关、配置、双检锁的实例引用)用 volatile。下一篇我们深入 synchronized 的原理与锁升级,再下一篇专讲 volatile 的使用边界。
动手实操
复现可见性问题并验证 volatile 修复。完整代码:
java
public class VisibilityDemo {
// 先用普通 boolean 跑,观察循环不退出;再改成 volatile 复跑
static boolean stop = false; // 实验一:普通变量
// static volatile boolean stop = false; // 实验二:volatile 修复
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() -> {
while (!stop) { /* 空转 */ } // JIT 可能把它优化成 if(!stop) while(true)
System.out.println("worker 退出");
});
worker.start();
Thread.sleep(1000); // 确保 worker 已经跑起来
stop = true; // 主线程修改,worker 可能看不见
worker.join(3000);
System.out.println(worker.isAlive() ? "复现成功:worker 仍在运行(可见性问题)"
: "worker 已感知 stop 变化");
}
}运行说明:
- 普通版在 JDK 8 默认配置下通常 1~3 秒内即可复现"worker 仍在运行";如果没复现,加
-XX:+DoEscapeAnalysis -XX:+PrintCompilation观察,或用-server -Xcomp强制全量编译后多跑几次(JIT 充分优化后循环体不再读 stop) - 改成
volatile boolean stop后必然正常退出——volatile 写 happens-before 后续读,worker 每次循环都强制从主内存刷新 - 再把 stop 改回普通变量、循环体里加一句
System.out.println,往往也能退出:synchronized(println 内部有锁)顺带保证了可见性,这正好印证监视器锁规则
常见误区与小结
- "volatile 能让 i++ 线程安全"——不能,它只保证可见与有序,不保证 read-modify-write 三步不被打断,计数仍需锁或 AtomicInteger
- "加了 synchronized 就万事大吉"——锁的是对象不是代码,两个线程用不同的锁对象(比如锁 Integer 缓存实例)照样竞态,下一篇细说
- "单线程写的代码顺序就是执行顺序"——as-if-serial 只保证单线程结果不变;一旦涉及共享变量跨线程,没有 happens-before 关系就别假设顺序
- "x86 上没复现就代表没 bug"——JMM 是跨平台规范,弱内存模型 CPU 或未来 JIT 优化随时可能让"碰巧正确"的代码翻车,按规范写而不是按当前机器的行为写
- 把 JMM 等同于 CPU 缓存——工作内存是抽象(含寄存器、store buffer、缓存),可见性问题还涉及编译器优化,层次比"缓存同步"更广
小结:这篇从三大问题出发建立了 JMM 的心智模型——主内存/工作内存的抽象,以及用 happens-before 判断可见性。它是后面所有内容的理论地基:synchronized 靠监视器锁规则保证三性,volatile 靠 volatile 规则保证两性,线程池、AQS、并发容器的正确性最终都回落到这条规范上。下一篇:synchronized 深入:用法、原理与锁升级。
参考
参考:
- JDK 官方文档 JLS §17.4(Java Memory Model):https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html
- 《Java 并发编程实战》第 3、16 章(共享对象、Java 内存模型)
- Doug Lea: The JSR-133 Cookbook for Compiler Writers(内存屏障 cookbook)