先看一段看起来很简单的代码。
inta=0;booleanflag=false;// 线程 Aa=10;flag=true;// 线程 Bif(flag){System.out.println(a);}假设线程 B 在 A 执行完flag = true之后才进入分支,此时它读到的flag是true。那么,它打印出来的a一定是 10 吗?
不一定,有可能是 0。
这里要先说明一个前提:上面假设a和flag是共享变量(比如类的字段)。如果它们是方法里的局部变量,每个线程各有一份,根本不存在共享问题。
明明 A 先执行、B 后执行,为什么 B 看不到 A 的修改?这不是"代码写对了,语言却出错",而是我们写代码时漏考虑了并发场景下的两类前提。这两类前提,正好对应两种并发问题。
一、并发问题的两类根源
1.1 第一类:CPU 缓存与指令重排序
这类问题里,线程甚至不需要"同时改同一个变量",只要一个线程写、另一个线程读,就可能读到旧值。根因有两个:
- CPU 缓存:写操作先落在本地缓存,没及时写回主内存,另一个线程读到旧副本 →可见性问题;
- 指令重排序:写操作的实际生效顺序和代码顺序不一致,另一个线程先看到"结果"、后看到"前提" →有序性问题。
对着开头的代码逐行看:
inta=0;// ① 共享变量,初始值 0booleanflag=false;// ② 共享变量,初始值 false// 线程 Aa=10;// ③ A 修改 aflag=true;// ④ A 修改 flag// 线程 Bif(flag){// ⑤ B 检查 flagSystem.out.println(a);// ⑥ B 读取 a}- ③ 和 ④ 在 A 的本地内存里执行,什么时候写回主内存并不保证。如果 ③ 一直没写回,⑤ 即使读到了
flag = true,⑥ 读到的a仍是 0 →可见性。 - ③ 和 ④ 没有数据依赖,可能被重排成 ④ → ③。B 在 ④ 之后、③ 之前进入分支,⑥ 读到的
a仍是 0 →有序性。 - 单看每一行都没问题,问题在于 ③④ 和 ⑤⑥ 之间没有建立任何"先后可见"的约束。
本篇讲这一类,也就是 JMM 和 volatile 要解决的问题。另一类线程交错放到 1.2,只做简单铺垫。
1.2 第二类:多线程交错执行
多个线程同时读写同一份共享状态,一个"读-改-写"的操作可能被打断。
publicclassVolatileAtomicityDemo{publicstaticvolatileintinc=0;// 共享计数器publicstaticvoidmain(String[]args)throwsInterruptedException{ExecutorServicepool=Executors.newFixedThreadPool(5);// 5 个工作线程for(inti=0;i<5;i++){pool.execute(()->{for(intj=0;j<500;j++){inc++;// 每个线程执行 500 次}});}pool.shutdown();pool.awaitTermination(1,TimeUnit.MINUTES);// 等所有任务结束System.out.println(inc);// 期望 2500}}对着代码看:
newFixedThreadPool(5)创建 5 个工作线程;- 每个线程循环 500 次,执行
inc++; inc++不是一行原子操作,而是"读inc→ 加 1 → 写回inc"三步。
两个线程可能同时读到同一个旧值,各自加 1 后写回同一个结果,中间的更新就丢了。5 × 500 本该是 2500,实际往往更小。
这里即使把inc声明成volatile,结果也可能不对:volatile 只保证每次读拿到最新值,不保证"读-改-写"三步整体不被打断。这类问题属于原子性问题,靠锁、CAS、原子类解决,下一篇专门讲。
需要说明的是,这类问题的本质是线程交错,不只是"多核并行"。哪怕单核 CPU 靠时间片轮转,两个线程的指令照样可能交错,结果一样会错。
| 类型 | 触发条件 | 破坏的特性 | 典型例子 | 解决方向 |
|---|---|---|---|---|
| 缓存与重排 | 副本未同步、指令换序 | 可见性、有序性 | 状态标志、DCL 单例 | volatile、锁 |
| 线程交错 | 多线程同时读写共享状态 | 原子性 | i++、先检查后操作 | 锁、CAS、原子类 |
二、根因一:CPU 缓存导致"看不见"
第一类问题的根因有两个:CPU 缓存和指令重排序。这一节先把缓存讲透,下一节再讲重排。
2.1 缓存的作用
这里的"周期"不是抽象单位,而是CPU 时钟周期,它代表时间。以 3 GHz 主频为例,一个周期约 0.33 ns。CPU 执行一条简单指令通常只要几个周期;访问主内存却要一百多个周期,差了两个数量级。
把周期换算成时间更直观:
| 层级 | 归属 | 典型容量 | 典型延迟(周期) | 大致时间(3 GHz) |
|---|---|---|---|---|
| L1 | 每个核心私有 | 32–64 KB | 4–5 个周期 | 约 1.5 ns |
| L2 | 每个核心私有 | 512 KB–2 MB | 10–20 个周期 | 约 3–7 ns |
| L3 | 多核共享 | 16–96 MB | 40–60 个周期 | 约 13–20 ns |
| 主内存 | 所有核心共享 | 16–128 GB | 100+ 个周期 | 约 33 ns 以上 |
也就是说,CPU 等一次主内存的时间,足够执行几十条指令。缓存就是为了弥补这段速度差:把经常访问的数据放在更快、更小的地方,让 CPU 尽量不用等。
这层关系其实和 Redis 缓存 MySQL 很像:把经常访问的数据放在更快、更小的地方,减少访问慢速存储的次数。
现代处理器通常就是三级缓存,结构如下:
为什么是三层?这是速度、容量、成本的折中:越靠近核心,做得越快,但容量小、成本高;越远离核心,容量大、便宜,但慢。每往下一级,容量大约提升一个数量级,延迟也明显增加。层级太少,缓存命中率不够;层级太多,布线和缓存一致性维护的成本又上来了,收益递减。所以主流处理器落在三级,但这不是硬性规定,部分处理器还有 L4。
另外,L1 通常分成指令缓存和数据缓存,所以物理上看起来像四块,逻辑上仍然是三级。
2.2 缓存的工作方式,与"窗口期"
CPU 读数据时,先把包含目标数据的一整块(缓存行,通常 64 字节)复制到自己的缓存;之后需要用时,直接读缓存。运算完成后,再把结果写回缓存,什么时候同步到主内存,由缓存一致性协议和写策略决定。
这就带来一个问题:每个核心都可能持有一份共享变量的副本。
拿i++来说,两个线程同时执行。先看图,再看逐时刻的状态表:
| 时刻 | 线程 A | 线程 B | 主内存 i |
|---|---|---|---|
| t1 | 从缓存读到 i=1 | 1 | |
| t2 | 从缓存读到 i=1 | 1 | |
| t3 | 计算 1+1=2,写回 | 2 | |
| t4 | 计算 1+1=2,写回 | 2 |
正确结果应该是 3,实际却是 2。两个线程都基于旧值计算,后写的覆盖了先写的。这段"读完之后、写回之前"的时间,就是常说的窗口期。
CPU 用缓存一致性协议(比如 MESI,缓存行有 Modified / Exclusive / Shared / Invalid 四种状态)来保证副本之间最终能保持一致:一个核心改了数据,会让其他核心的对应副本失效,下次读时重新取。
但要说清楚:MESI 解决的是"副本一致",不是"复合操作原子"。它能让别的核心看到你写的新值,却不能阻止两个核心同时完成"读-改-写"这三步。可见性和原子性,是两件不同的事。
三、根因二:指令重排序
3.1 什么是指令重排序
指令重排序,是指编译器、JIT、CPU 在不改变单线程程序执行结果的前提下,调整指令实际执行顺序的行为。代码里写的顺序,是"程序员视角的顺序";指令真正执行的顺序,可能和它不一样。
举个最简单的例子:
inta=1;// 1intb=2;// 2intc=a+b;// 3- 1 和 2 没有数据依赖,可以先执行 2 再执行 1,也可以让两条指令重叠执行;
- 3 依赖 1、2 的结果,不可能排到它们前面。
| 指令 | 能否被提前 | 原因 |
|---|---|---|
int b = 2; | 能,可以排到a = 1;前面 | 和 a 没有数据依赖 |
int c = a + b; | 不能排到 a、b 前面 | 依赖 a、b 的结果 |
单线程之所以感觉不到,是因为as-if-serial语义:不管怎么重排,单线程的执行结果必须和按代码顺序执行一样。换句话说,重排可以改变"过程",但不能改变"单线程看到的结果"。
3.2 指令重排序的作用
重排不是为了捣乱,是为了让 CPU 少等。现代 CPU 是流水线作业:一条指令要经过取指、译码、执行、访存、写回等阶段,理想情况下每个阶段都排满。但只要遇到依赖或慢操作(比如访问主内存),流水线就会出现空泡。重排就是把后面不依赖当前结果的指令提前,填上这些空泡。
具体到三类重排:
- 编译器优化重排:编译器在不改变单线程语义的前提下,调整语句顺序。比如
int a = 1; int b = 2;,两条赋值没有依赖,编译器可以先安排 b 再安排 a;它还会做常量折叠、公共子表达式消除等优化,重排只是其中一种。 - 指令级并行重排(ILP):现代处理器有多条流水线,可以同时执行多条指令。只要没有数据依赖,
a = 1和b = 2可以重叠执行;CPU 等内存时,也可以先把后面独立的指令拿出来跑。 - 内存系统重排:CPU 写数据时先写进 store buffer(写缓冲区),不立刻落主内存;读数据时也可能走 load buffer。于是“先写后读”在程序里是顺序的,到了内存系统里可能变成“读先于写生效”。这不是 CPU 乱来,而是为了不让写操作阻塞后续指令。
举个例子:做饭时等水开,你会先去切菜,而不是站在锅边等。重排就是 CPU 的“等水开时先切菜”:单线程的最终结果不变,但整体时间被利用起来了。
当然,重排有边界:有数据依赖的指令不能换序,比如c = a + b不能排到a、b前面。单线程靠 as-if-serial 保证结果;多线程就不一定了,这就是 3.3 要讲的问题。
3.3 多线程下重排序为什么会出问题
问题在于,编译器只对单线程负责。它不知道你写flag是为了通知另一个线程,也不知道另一个线程会依赖a和flag的先后顺序。
回到开头的代码:
a=10;flag=true;这两条写没有数据依赖,编译器或 CPU 完全可能让flag = true先生效。线程 B 看到flag为true,进入分支,读到的a却还是 0。
这不是编译器"故意捣乱",它只是在单线程视角下做了合法优化。多线程的语义,需要我们人为地用规则约束。这套用来约束多线程下可见性与有序性的规范,就是 JMM。
四、JMM:多线程下要遵守的内存规范
4.1 JMM 是什么,为什么需要它
JMM 全称Java Memory Model,即 Java 内存模型。先纠正一个常见误解:它不是JVM 运行时数据区(堆、栈、方法区),也不是把内存条切成"主内存区"和"工作内存区"。它是 Java 语言定义的一组并发编程规范。
为什么 Java 要自己定义一套?因为不同操作系统、不同 CPU 的内存模型不一样。x86 比较强,ARM 比较弱;如果直接复用操作系统的内存模型,同一套代码换个平台就可能跑出不同结果。Java 要跨平台,就必须自己抽象一套统一的规则,屏蔽底层差异。
这套规则主要做两件事:
- 抽象线程与主内存之间的关系;
- 规定从 Java 源代码到 CPU 指令的转化过程中,哪些重排允许、哪些必须禁止。
对开发者来说,好处是不用直接和硬件打交道,用volatile、synchronized、Lock这些关键字和类就能写出并发安全的程序。
4.2 主内存与本地内存
JMM 把所有内存抽象成两块:
- 主内存:所有线程共享,存放共享变量,比如实例字段、静态字段、数组元素;
- 本地内存(规范里常叫工作内存):每个线程私有,存放该线程读过的共享变量的副本。它涵盖了缓存、写缓冲区、寄存器以及编译器优化后的临时存储,是一个抽象概念,并不真实存在。
方法里的局部变量、形参、方法返回值不在线程间共享,所以不属于 JMM 说的共享变量,也不受这套规则约束。注意,局部变量本身不共享,但它引用的对象可能分配在堆上,被其他线程共享。
线程只能操作自己本地内存里的副本,不能直接访问别的线程的本地内存。举个具体的例子:线程 A 把a改成 10,想传给线程 B,可以把这个过程理解成两步(这只是帮助理解的模型,不代表硬件真的按这个顺序执行):
- 线程 A 把本地内存中修改过的
a副本同步到主内存; - 线程 B 到主内存读取
a的最新值。
但要注意:JMM 不是运行时的"保姆",它只负责定规则,不会自动帮你维护可见性。规则是 JMM 定的,代码是你写的;只有你按规则写,JMM 才保证结果符合预期。
具体来说,你得用volatile、synchronized、start/join这些手段,在写和读之间建立 happens-before 关系。关系建立了,JVM 才保证写的结果对读可见;没有建立,JMM 就允许底层按优化后的方式执行。拿开头的代码举例:flag是普通变量时,A 的写和 B 的读之间没有 hb,JMM 就允许 B 读到 0;把flag改成volatile后,写和读之间有了 hb,JMM 才保证 B 看到a = 10。
4.3 八种交互操作
JMM 定义了八种操作,描述变量如何在主内存和本地内存之间来回:
| 操作 | 作用对象 | 含义 |
|---|---|---|
lock | 主内存变量 | 标记为线程独占 |
unlock | 主内存变量 | 解除锁定,其他线程才能锁 |
read | 主内存变量 | 把变量的值传输到本地内存 |
load | 本地内存变量 | 把read得到的值放进副本 |
use | 本地内存变量 | 把副本的值交给执行引擎 |
assign | 本地内存变量 | 把执行引擎得到的值赋给副本 |
store | 本地内存变量 | 把副本的值传回主内存 |
write | 主内存变量 | 把store传回的值写入主内存变量 |
配套还有几条规则,最常用的几条是:
read和load必须成对出现,store和write必须成对出现;- 不允许丢弃最近的
assign,也就是改了副本不能当没改; - 变量只能在主内存被
lock,同一时刻只能有一个线程锁住它; - 对一个变量执行
lock会清空本地内存中该变量的副本,执行unlock前必须把修改同步回主内存。
4.4 happens-before:判断"谁对谁可见"的尺子
JMM 需要在程序员和编译器之间找平衡:
- 程序员想要一个强内存模型,按规则写代码就能理解结果;
- 编译器和处理器想要一个弱内存模型,尽量少受约束,好做优化。
happens-before 就是这条平衡线:
如果一个操作 A happens-before 另一个操作 B,那么 A 的执行结果对 B 可见,并且 A 的执行顺序排在 B 之前。
注意,这里的"之前"是可见性顺序,不要求底层真的按这个顺序执行。只要重排后的结果和按 happens-before 执行的结果一致,JMM 就允许重排。
intuserNum=getUserNum();// 1intteacherNum=getTeacherNum();// 2inttotalNum=userNum+teacherNum;// 31 happens-before 2、2 happens-before 3、1 happens-before 3。虽然 1 在 2 前面,但 1 和 2 换序不影响结果,所以 JMM 允许;3 依赖 1、2,所以 3 必须排在后面。
常用的 happens-before 规则有六条:
- 程序顺序:同一线程内,代码前面的操作 happens-before 后面的操作;
- 监视器锁:对同一把锁的
unlockhappens-before 后续的lock; - volatile:对同一个 volatile 变量的写 happens-before 后续的读;
- 传递性:A happens-before B,B happens-before C,则 A happens-before C;
- 线程启动:
Thread.start()happens-before 新线程里的每一个动作; - 线程终止:线程里的所有动作 happens-before 其他线程从
join()返回。
4.5 三大特性
| 特性 | 含义 | 谁会被破坏 | 靠什么保证 |
|---|---|---|---|
| 原子性 | 一个操作不可分割,要么全做,要么全不做 | i++这类"读-改-写"复合操作 | synchronized、CAS、原子类 |
| 可见性 | 一个线程修改后,另一个线程能看到 | CPU 缓存、写缓冲区 | volatile、synchronized、final |
| 有序性 | 程序按预期顺序执行,不被随意重排 | 编译器、CPU 重排序 | volatile、synchronized、happens-before |
现在再回头看开头:A 的写和 B 的读之间没有任何 happens-before 关系,所以 JMM 允许 B 读到旧值;A 的两条写之间也只需要满足单线程语义,所以允许flag先生效。问题不在代码"写错了语法",而在我们没给这两段代码建立可见性与顺序上的约束。
五、volatile:把"可见 + 有序"落地
5.1 volatile 在 JMM 里的位置
volatile 是 JMM 提供的一个关键字:JMM 规定它应该有什么语义,JVM 负责把语义映射成具体 CPU 指令和内存屏障。
它只保证两件事:可见性、有序性。原子性不归它管。
5.2 可见性:写要刷出,读要拿最新值
对 volatile 变量:
- 写:
assign之后必须立刻store+write,把新值写回主内存; - 读:
use之前必须read+load,每次都要拿主内存的最新值。
这里的"立刻"是语义效果,不是说硬件真的跳过缓存直接读内存。CPU 仍然会走 L1/L2/L3,真正起作用的是 MESI 缓存一致性协议 + volatile 带来的内存屏障:写完之后让其他核心的副本失效,读之前确保拿到最新值。
5.3 有序性:四种内存屏障
volatile 通过内存屏障禁止重排序。屏障是一条"栅栏":前面的访存先完成,后面的访存不能提前越过去。
| 屏障 | 示例 | 保证什么 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | Load1 的读先于 Load2 及其后续读完成 |
| StoreStore | Store1; StoreStore; Store2 | Store1 的写先于 Store2 完成,并对其他处理器可见 |
| LoadStore | Load1; LoadStore; Store2 | Load1 的读先于 Store2 及其后续写完成 |
| StoreLoad | Store1; StoreLoad; Load2 | Store1 的写对其他处理器可见,且先于 Load2 及其后续读 |
StoreLoad 的开销是四种里最大的,它同时约束前后所有访存,常被称为全能屏障(Full Barrier)。
volatile 读写的插入策略是:
- volatile 写:前面插 StoreStore,后面插 StoreLoad;
- volatile 读:后面插 LoadLoad 和 LoadStore。
这样安排之后,volatile 写之前的所有操作,对随后读这个 volatile 变量的线程来说,都变得可见。这句话不是口号,它就是下面 DCL 和 happens-before 示例能成立的原因。
5.4 DCL 单例:为什么必须加 volatile
publicclassSingleton{privatevolatilestaticSingletonuniqueInstance;privateSingleton(){}publicstaticSingletongetUniqueInstance(){if(uniqueInstance==null){// 第一次检查,不加锁synchronized(Singleton.class){if(uniqueInstance==null){// 第二次检查,加锁uniqueInstance=newSingleton();}}}returnuniqueInstance;}}new Singleton()在底层是三步:
- 分配内存:为对象分配一块内存空间;
- 初始化:执行构造方法,把字段赋成业务值;
- 引用赋值:把这块内存的地址写进
uniqueInstance。
这里正好回答一个很容易卡住的问题:"初始化"和"指向内存"到底有什么区别?
- 第 1 步做完,对象已经存在了,但字段还是默认值:
int是 0,引用是null,boolean是false。 - 第 2 步的"初始化",不是分配内存,而是执行构造方法,把字段从默认值改成你写的值。做完这一步,对象状态才算完整。
- 第 3 步的"指向",是把对象地址写进引用变量。它是一个发布动作:在这之前,别的线程没有路径通过
uniqueInstance访问这个对象;在这之后,引用不为null,对象就算"可见"了。
打个比方:第 1 步是拿到地、建好毛坯房;第 2 步是装修;第 3 步是把钥匙交给你。如果先交钥匙、后装修,你拿到钥匙进门,看到的是一间没装修完的房子。
new的三步里,1 不能排到 3 后面,因为 3 要用 1 分配的地址,存在数据依赖;但2 和 3 之间没有依赖,可以重排成1 → 3 → 2:
线程 A 执行到第 3 步时,uniqueInstance已经不为null。线程 B 此时调用getUniqueInstance(),第一次检查发现不为空,直接返回——但它拿到的对象,字段还是默认值,是一个半成品。
加上 volatile 之后:
uniqueInstance = new Singleton()中的第 3 步成了volatile 写;- 写前面插入 StoreStore,挡住第 2 步沉到第 3 步后面;
- 写后面插入 StoreLoad,保证写完成后其他线程才可能读到。
所以线程 B 只要看到uniqueInstance不为null,就说明初始化一定已经完成。
补充一个实现层面的细节:x86 是较强的内存模型(TSO),硬件层面不允许写-写重排,DCL 在 x86 上"看起来能跑";但编译器 / JIT 仍可能重排,而且 JMM 允许这种重排。代码不能依赖某一种 CPU 的"运气",该加的 volatile 必须加。
5.5 volatile 与 happens-before:顺带发布普通变量
publicclassVolatileHappensBeforeDemo{privateinta=0;privateintb=0;privatevolatilebooleanflag=false;// 线程 A 执行publicvoidwriter(){a=1;// 1 普通写b=2;// 2 普通写flag=true;// 3 volatile 写}// 线程 B 执行publicvoidreader(){if(flag){// 4 volatile 读intx=a;// 5 普通读inty=b;// 6 普通读System.out.println("x="+x+", y="+y);}}}happens-before 链是这样的:
- 1、2 happens-before 3(程序顺序规则);
- 3 happens-before 4(volatile 规则);
- 4 happens-before 5、6(程序顺序规则);
- 根据传递性,1、2 happens-before 5、6。
所以,当线程 B 在 4 处读到flag == true时,5、6 读到的x、y一定是 1、2。volatile 不仅保证flag本身的可见性,还通过 happens-before 的传递性"顺带"保证了它前面普通变量的可见性。
这也是实际开发中 volatile 最常见的用法:状态标志位。一个线程写标志,其他线程读标志,标志之前的准备工作对读方可见,而且不需要加锁。
六、回到开头
给flag加上volatile:
volatilebooleanflag=false;线程 A 的a = 10与flag = true之间是程序顺序,flag写与 B 的flag读之间是 volatile 规则,B 的读与println(a)之间是程序顺序。传递下来,只要 B 看到flag == true,a就一定是 10。
最后用三句话收束:
- 缓存让一个线程的修改"看不见" → 需要可见性;
- 重排让执行顺序"对不上" → 需要有序性;
- 交错让"读-改-写"被打断 → 需要原子性(下一篇)。
JMM 是规范,volatile 是这套规范里"可见性 + 有序性"的一种实现手段。它不签原子性,也替代不了锁。
记住一句:volatile 管"看得见、排得对",不管"算得全"。