深入剖析:为什么多线程下变量会看不见、乱序、不安全?
前言
在 Java 并发编程中,共享变量的可见性问题、指令重排序问题、原子性问题,是导致多线程程序出现诡异 Bug(死循环、计算错误、空指针、状态不一致)的三大核心根源。这些问题并非语言设计缺陷,而是现代计算机硬件架构 + 编译器优化 + Java 内存模型(JMM) 共同作用的必然结果。本篇笔记从硬件底层 → 操作系统 → JVM 内存模型 → 编译优化 → 代码层面逐层拆解,完整解释:
- 为什么一个线程改了变量,另一个线程永远读不到旧值(可见性)
- 为什么代码明明写的顺序 A→B→C,实际执行却是 A→C→B(有序性 / 乱序)
- 为什么
i++明明一行代码,多线程下结果却总是少算(原子性 / 不安全) - 最终形成一套可落地的问题识别 + 原理理解 + 解决方案体系。
全文结构:
- 多线程不安全的本质:共享 + 并发 + 非原子操作
- 硬件根源:CPU 缓存架构与缓存一致性
- JMM 抽象:主内存与线程工作内存模型
- 可见性问题:为什么线程间 “看不见” 对方修改
- 重排序问题:编译器 / CPU 为什么要 “乱序执行”
- 原子性问题:一行代码为什么不是 “一步完成”
- 三大问题汇总与 happens-before 规则
- 从根源解决:volatile/synchronized/final/ 原子类原理
- 典型场景与实战避坑
一、多线程不安全的本质
先给出最本质结论:多线程下共享变量出现 “可见性、乱序、不安全”,本质只有一句话:
多个线程在没有正确同步的情况下,同时读写共享变量,且硬件 / 编译器为了极致性能,打破了「单线程直觉」。
拆成三个必要条件:
- 共享:变量是堆内存中的共享对象 / 成员变量(不是局部变量)
- 读写:至少一个线程写,至少一个线程读
- 不同步:没有使用
volatile、synchronized、Lock、原子类等同步机制
只要同时满足这三点,理论上就一定可能出现可见性、重排、原子性问题,只是在低并发、小数据量下 “碰巧不触发”,高并发下必现。
局部变量为什么永远安全?
- 局部变量在线程栈上,线程私有,不共享
- 不存在 “多线程同时读写同一个变量”,自然无三大问题
二、硬件底层根源:CPU 缓存架构(一切问题的起点)
要理解可见性,必须先理解CPU 与内存的速度鸿沟。
2.1 CPU 与内存的性能差(数量级差距)
- CPU 指令执行:纳秒级(1ns)
- 内存访问:百纳秒~几百纳秒
- 硬盘访问:毫秒级
如果 CPU 每次都直接读写主内存,99% 的时间都在等待内存,CPU 利用率极低。
为此,现代 CPU 设计了多级缓存架构:
plaintext
CPU → L1 缓存 → L2 缓存 → L3 缓存 → 主内存(RAM)
- L1/L2:核心独有(每个 CPU 核心自己的)
- L3:同插槽多核共享
- 主内存:所有核心共享
访问速度:L1 ≈ 1nsL2 ≈ 3nsL3 ≈ 10ns内存 ≈ 60~100ns
2.2 缓存带来的问题:数据不一致
CPU 执行时:
- 从主内存把数据读到缓存
- 在缓存里计算、修改
- 择机写回主内存
问题:每个核心有自己的私有缓存,线程 A 在核心 1 修改了变量,只改了自己的缓存,没刷回主内存;线程 B 在核心 2 读到的,还是主内存的旧值,或自己缓存的旧值。
这就是可见性问题的硬件根源。
2.3 缓存一致性协议(MESI)为什么不能完全解决?
CPU 厂商定义了缓存一致性协议(如 Intel 的 MESI),试图保证多核缓存一致。
MESI 四种状态:
- M(Modified):已修改,仅本缓存有效
- E(Exclusive):独占,与内存一致
- S(Shared):共享,多核心持有
- I(Invalid):无效,需重新读内存
MESI 只能保证 “最终一致”,不能保证 “实时一致”:
- 当核心 1 修改变量,会发出消息通知其他核心该缓存行失效
- 但这个通知是异步的,有延迟
- 且 CPU 为了避免频繁消息通信,引入了Store Buffer、Invalid Queue 等优化,进一步打破 “实时可见”
结论:硬件层面本身就不保证 “一个线程写完,另一个线程立刻读到新值”,这是性能优先的设计。
三、Java 内存模型(JMM):抽象层的可见性规则
Java 不想让开发者直接面对 CPU 架构差异(x86/ARM/ARM64 缓存规则不同),因此定义了JMM:一套统一的内存访问规范。
3.1 JMM 核心抽象:主内存 vs 线程工作内存
JMM 规定:
- 所有共享变量存在于主内存(堆内存)
- 每个线程有自己的工作内存(CPU 寄存器 + L1/L2 缓存 + 线程本地缓冲区)
- 线程不能直接读写主内存,只能操作自己工作内存中的副本
- 线程对变量的所有操作,都必须先拷贝到工作内存 → 修改 → 再同步回主内存
流程:
plaintext
主内存 → 读取 → 线程工作内存(副本)→ 修改 → 写入 → 主内存
3.2 无同步时的行为(问题根源)
在没有 volatile/ synchronized 时,JMM 对 “何时从主内存读、何时写回主内存”几乎没有约束:
- 线程可以一直使用工作内存的旧副本,永远不主动读主内存
- 线程修改后,可以长时间不刷回主内存
- 线程之间完全不知道对方的修改
这就是可见性问题的 JVM 层面解释。
简单代码示例:
java
运行
boolean stop = false;
// 线程 1
while (!stop) {
// 循环
}
// 线程 2
stop = true;
现象:线程 2 把 stop = true,但线程 1 可能永远死循环。原因:
- 线程 1 缓存了
stop = false - 没有强制刷新,线程 1 一直读工作内存旧值
- JMM 允许这种 “长期不可见” 的优化
四、可见性问题深度解析:为什么 “看不见”
4.1 可见性定义
可见性:当一个线程修改了共享变量,其他线程能立即、可靠地看到修改后的值。
无同步 → 无可见性保证。
4.2 导致不可见的三大机制
4.2.1 缓存未刷新(最常见)
线程修改变量 → 只写到Store Buffer / 缓存 → 没有立即刷回主内存。
4.2.2 编译器优化:寄存器分配
JIT 编译器会做激进优化:
java
运行
while (!stop) {}
被优化为:
java
运行
boolean local = stop;
while (!local) {}
把变量永久读到寄存器,不再从内存加载,导致永远看不见新值。
4.2.3 指令重排间接导致不可见
后面会讲:重排会让 “写变量” 的动作晚于后续代码,导致其他线程看到 “变量没改”。
4.3 不可见的典型后果
- 线程死循环(如上
stop标记) - 配置更新不生效(开关、阈值、地址)
- 状态机混乱(线程 A 认为状态 = 1,线程 B 认为 = 0)
- 并发容器内部状态不一致
4.4 可见性问题的 “随机性”
很多人说:“我本地跑没问题,一上生产就出问题”。原因:
- 本地 CPU 负载低、核心少、切换少 → 碰巧缓存及时同步
- 生产高并发、多核心、频繁线程切换 → 缓存不一致被放大
可见性问题不是 “偶尔出现”,是 “理论必然”。
五、重排序问题:为什么代码会 “乱序执行”
5.1 什么是重排序
重排序:编译器、JIT、CPU 为了优化性能,在不改变单线程执行结果的前提下,改变指令实际执行顺序。
代码写的:
plaintext
A;
B;
C;
实际执行可能:
plaintext
A;
C;
B;
5.2 重排序的三个层面
- 编译器重排(javac / JIT)
- 指令级并行重排(CPU 乱序执行)
- 内存系统重排(缓存读写重排)
5.3 为什么要重排序?(性能刚需)
5.3.1 CPU 乱序执行
CPU 遇到长延迟指令(如读内存),不会干等,而是先执行后面不依赖它的指令。
示例:
plaintext
A = 读内存(慢)
B = 1 + 2
CPU 会先算 B=1+2,再等 A,提升吞吐量。
5.3.2 编译器优化
合并读写、消除冗余、寄存器优化,都会改变指令顺序。
5.4 重排序在单线程下无害,多线程下灾难
as-if-serial 规则:
不管怎么重排,单线程执行结果不变。
但多线程下,没有同步时,重排会破坏线程间协作逻辑。
5.5 经典重排导致的 Bug:DCL 单例空指针
java
运行
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 1
synchronized (Singleton.class) {
if (instance == null) { // 2
instance = new Singleton(); // 3
}
}
}
return instance;
}
}
new Singleton() 本质分三步:
- 分配内存:
memory = allocate() - 初始化对象:
ctorInstance(memory) - 指向引用:
instance = memory
正常顺序:1→2→3重排后可能:1→3→2
后果:
- 线程 A 执行到 3(
instance非空,但对象还没初始化) - 线程 B 进入
if (instance == null)判断为 false - 线程 B 直接返回未初始化的对象,调用方法直接 NPE
这就是重排序导致的线程间安全问题。
5.6 重排序与可见性的关系
重排会导致:
- 写操作被延后 → 其他线程看不见新值
- 读操作被提前 → 读到旧值
- 变量之间的依赖关系被打破
重排序 + 缓存不可见 = 多线程 Bug 的主要来源。
六、原子性问题:为什么 i++ 不安全
6.1 原子性定义
原子性:一个操作是不可分割、不可中断的,要么全部执行成功,要么完全不执行。
在多线程中:
- 操作开始 → 必须完整结束
- 中间不能被其他线程插入
6.2 为什么一行 Java 代码不是原子操作
Java 代码是高级语言,一行代码对应多条 CPU 指令。
以 i++ 为例:
java
运行
i++;
对应字节码 / CPU 指令:
- 读取:从内存 / 缓存读 i 到寄存器
- 加 1:寄存器中计算
- 写入:写回内存 / 缓存
这三步是可分割的,线程在任意一步都可能被切走。
6.3 多线程下 i++ 的经典丢失写
场景:i = 0,两个线程同时执行 i++
- 线程 1:读 i=0
- 线程 2:读 i=0
- 线程 1:计算 i=1
- 线程 2:计算 i=1
- 线程 1:写回 i=1
- 线程 2:写回 i=1
预期结果:2实际结果:1写丢失。
6.4 哪些操作是原子的?
- 除了
long/double外,基本类型变量的单次读 / 写(32 位) volatile修饰的long/double的单次读 / 写
但:
i++、i--、a = a + 1、if (state == A) state = B全都是复合操作,非原子。
6.5 原子性问题 = “不安全” 的直接体现
所谓 “线程不安全”,90% 场景就是:共享变量 + 复合操作 + 无同步。
七、三大问题汇总:可见性、有序性、原子性
多线程共享变量不安全,就是三大特性同时缺失:
表格
| 问题名称 | 本质 | 表现 |
|---|---|---|
| 可见性 | 缓存 / 编译器优化导致线程间读不到最新值 | 死循环、状态不一致、配置不生效 |
| 有序性 | 重排序打破代码执行顺序 | DCL 单例 NPE、先使用后初始化、状态错乱 |
| 原子性 | 一行代码对应多条指令,可被中断 | 写丢失、计数错误、并发修改覆盖 |
JMM 对未同步的多线程,不保证三大特性中的任何一个。
八、JMM 如何规范同步:Happens-Before 规则
JMM 不直接描述 “何时刷新缓存、何时禁止重排”,而是用Happens-Before(先行发生) 规则,定义线程间的可见性保证。
8.1 Happens-Before 核心含义
如果 A Happens-Before B,那么:
- A 的操作结果对 B 可见
- A 的执行顺序排在 B 之前
8.2 常用 Happens-Before 规则(必须掌握)
-
程序顺序规则单线程内,前面代码 Happens-Before 后面代码。
-
volatile 变量规则对 volatile 变量的写,Happens-Before 后续读。→ 保证可见性 + 禁止重排。
-
锁规则(synchronized/Lock)对锁的解锁,Happens-Before 后续加锁。→ 可见性 + 原子性 + 有序性全包。
-
线程启动规则
thread.start()Happens-Before 线程内任何动作。 -
线程终止规则线程内所有操作 Happens-Before
thread.join()返回。 -
传递性A HB B,B HB C → A HB C。
只要满足 HB,就不会出现可见性 / 乱序问题。
九、从根源解决三大问题
9.1 解决可见性 + 有序性:volatile
volatile 直接针对可见性 + 重排序:
9.1.1 volatile 两大语义
-
可见性写 volatile 变量 → 立即刷回主内存读 volatile 变量 → 强制从主内存重读
-
禁止重排序通过内存屏障(Memory Barrier) 实现:
- 写屏障:写 volatile 前,所有前面写操作都刷回主内存
- 读屏障:读 volatile 后,所有后面读操作都从主内存读
9.1.2 volatile 底层:内存屏障
JVM 在 volatile 指令前后插入屏障:
- StoreStore
- StoreLoad
- LoadLoad
- LoadStore
最终在 x86 上体现为:volatile 写 → 加 lock 前缀指令lock addl $0, 0(%rsp)
作用:
- 强制缓存刷回主内存
- 使其他 CPU 缓存行失效
- 禁止重排
9.1.3 volatile 不保证原子性
volatile int i; i++ 依然不安全。volatile 只保证单次读写原子,不保证复合操作。
9.2 解决全部三大问题:synchronized / Lock
9.2.1 synchronized 三大保证
- 原子性:同一时间只有一个线程执行,复合操作不可中断
- 可见性:解锁时强制刷回主内存,加锁时强制重读
- 有序性:锁内代码重排受限,且满足 HB 规则
9.2.2 锁的内存语义
- 加锁:相当于读 volatile
- 解锁:相当于写 volatile
所以锁天然包含 volatile 的所有能力,且多了原子性。
9.3 解决原子性:CAS + 原子类(AtomicInteger)
原子类(AtomicInteger、AtomicBoolean):
- 底层用 CAS(Compare And Swap) 原子指令
- 结合
volatile保证可见性 - 无锁,高并发下性能优于锁
CAS 流程:
plaintext
预期值 = 当前值
if (当前值 == 预期值) {
赋值新值
}
整个过程是一条 CPU 原子指令,不可分割。
9.4 天然安全:final 关键字
final 字段:
- 构造函数正常初始化完成后
- 对其他线程一定可见,不会看到半初始化状态
可避免 “对象引用已发布,但字段未初始化” 的重排问题。
十、典型场景实战:从问题到方案
10.1 场景 1:线程停止标记(可见性问题)
坏代码:
java
运行
boolean stop = false;
// 线程1
while (!stop) { }
// 线程2
stop = true;
问题:线程 1 可能死循环。
修复:
java
运行
volatile boolean stop = false;
10.2 场景 2:DCL 单例(重排序问题)
坏代码:
java
运行
private static Singleton instance;
修复:
java
运行
private static volatile Singleton instance;
10.3 场景 3:计数器(原子性问题)
坏代码:
java
运行
int count = 0;
count++;
修复:
java
运行
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
10.4 场景 4:状态转移(可见性 + 原子性)
java
运行
int state = 0;
if (state == 0) {
state = 1;
}
问题:判断 + 赋值非原子,多线程同时进入。
修复:
- 加锁
- 或使用
AtomicInteger+compareAndSet
十一、总结:多线程变量不安全的完整逻辑链
从底层到上层,完整因果链:
- CPU 与内存速度差距巨大 → 必须用多级缓存
- 多核私有缓存 → 缓存不一致 → 可见性问题
- CPU / 编译器追求性能 → 乱序执行 → 重排序问题
- 高级语言一行代码对应多条指令 → 复合操作非原子 → 原子性问题
- JMM 对无同步代码不做任何保证 → 三大问题同时爆发
- 表现为:看不见、乱序、计算错误、死循环、NPE
最终解决方案:
- 只需要可见性 + 禁止重排 →
volatile - 需要原子性 →
synchronized/Lock/AtomicXXX - 只读不变量 →
final - 不共享变量 → 局部变量 / ThreadLocal
一句话记忆:
多线程共享变量不同步,硬件会缓存不可见,编译器 / CPU 会重排,代码会被打断,必然出现可见性、乱序、不安全。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)