ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多线程为什么会读到旧值?从 CPU 缓存、JMM 到 volatile 的全链路拆解

多线程为什么会读到旧值?从 CPU 缓存、JMM 到 volatile 的全链路拆解

先看一段看起来很简单的代码。

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 KB4–5 个周期约 1.5 ns
L2每个核心私有512 KB–2 MB10–20 个周期约 3–7 ns
L3多核共享16–96 MB40–60 个周期约 13–20 ns
主内存所有核心共享16–128 GB100+ 个周期约 33 ns 以上

也就是说,CPU 等一次主内存的时间,足够执行几十条指令。缓存就是为了弥补这段速度差:把经常访问的数据放在更快、更小的地方,让 CPU 尽量不用等。

这层关系其实和 Redis 缓存 MySQL 很像:把经常访问的数据放在更快、更小的地方,减少访问慢速存储的次数。

现代处理器通常就是三级缓存,结构如下:

为什么是三层?这是速度、容量、成本的折中:越靠近核心,做得越快,但容量小、成本高;越远离核心,容量大、便宜,但慢。每往下一级,容量大约提升一个数量级,延迟也明显增加。层级太少,缓存命中率不够;层级太多,布线和缓存一致性维护的成本又上来了,收益递减。所以主流处理器落在三级,但这不是硬性规定,部分处理器还有 L4。

另外,L1 通常分成指令缓存和数据缓存,所以物理上看起来像四块,逻辑上仍然是三级。

2.2 缓存的工作方式,与"窗口期"

CPU 读数据时,先把包含目标数据的一整块(缓存行,通常 64 字节)复制到自己的缓存;之后需要用时,直接读缓存。运算完成后,再把结果写回缓存,什么时候同步到主内存,由缓存一致性协议和写策略决定。

这就带来一个问题:每个核心都可能持有一份共享变量的副本。

拿i++来说,两个线程同时执行。先看图,再看逐时刻的状态表:

时刻线程 A线程 B主内存 i
t1从缓存读到 i=11
t2从缓存读到 i=11
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 是流水线作业:一条指令要经过取指、译码、执行、访存、写回等阶段,理想情况下每个阶段都排满。但只要遇到依赖或慢操作(比如访问主内存),流水线就会出现空泡。重排就是把后面不依赖当前结果的指令提前,填上这些空泡。

具体到三类重排:

  1. 编译器优化重排:编译器在不改变单线程语义的前提下,调整语句顺序。比如int a = 1; int b = 2;,两条赋值没有依赖,编译器可以先安排 b 再安排 a;它还会做常量折叠、公共子表达式消除等优化,重排只是其中一种。
  2. 指令级并行重排(ILP):现代处理器有多条流水线,可以同时执行多条指令。只要没有数据依赖,a = 1和b = 2可以重叠执行;CPU 等内存时,也可以先把后面独立的指令拿出来跑。
  3. 内存系统重排: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,可以把这个过程理解成两步(这只是帮助理解的模型,不代表硬件真的按这个顺序执行):

  1. 线程 A 把本地内存中修改过的a副本同步到主内存;
  2. 线程 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;// 3

1 happens-before 2、2 happens-before 3、1 happens-before 3。虽然 1 在 2 前面,但 1 和 2 换序不影响结果,所以 JMM 允许;3 依赖 1、2,所以 3 必须排在后面。

常用的 happens-before 规则有六条:

  1. 程序顺序:同一线程内,代码前面的操作 happens-before 后面的操作;
  2. 监视器锁:对同一把锁的unlockhappens-before 后续的lock;
  3. volatile:对同一个 volatile 变量的写 happens-before 后续的读;
  4. 传递性:A happens-before B,B happens-before C,则 A happens-before C;
  5. 线程启动:Thread.start()happens-before 新线程里的每一个动作;
  6. 线程终止:线程里的所有动作 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 通过内存屏障禁止重排序。屏障是一条"栅栏":前面的访存先完成,后面的访存不能提前越过去。

屏障示例保证什么
LoadLoadLoad1; LoadLoad; Load2Load1 的读先于 Load2 及其后续读完成
StoreStoreStore1; StoreStore; Store2Store1 的写先于 Store2 完成,并对其他处理器可见
LoadStoreLoad1; LoadStore; Store2Load1 的读先于 Store2 及其后续写完成
StoreLoadStore1; StoreLoad; Load2Store1 的写对其他处理器可见,且先于 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()在底层是三步:

  1. 分配内存:为对象分配一块内存空间;
  2. 初始化:执行构造方法,把字段赋成业务值;
  3. 引用赋值:把这块内存的地址写进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 管"看得见、排得对",不管"算得全"。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进