在 Java 并发编程里,变量看不见、执行乱序、数据不安全是三大经典噩梦,也是面试必问、开发必踩的坑。
很多人只知道用synchronized、volatile、Lock,却不知道底层为什么会出现这些问题。一旦理解了本质,你写并发代码会豁然开朗。
这篇文章我会用最通俗、最详细、最底层的方式,把这三个问题一次性讲透:
- 为什么多线程下,一个线程修改了变量,另一个线程看不见?(可见性问题)
- 为什么代码执行顺序和我写的不一样?(重排序问题)
- 为什么多线程同时修改变量,结果会错乱?(原子性 / 线程不安全问题)
一、先铺垫:Java 内存模型(JMM)到底是什么?
要理解三大问题,必须先懂JMM(Java Memory Model)。
你可以把它理解成:Java 定义的一套线程与内存交互的规则。
它规定:
- 所有变量都存在主内存(Main Memory)
- 每个线程有自己的工作内存(Working Memory)
- 线程不能直接读写主内存,只能操作自己工作内存里的变量副本
结构如下:
php
主内存
↑ ↓
线程1工作内存 ←→ 线程2工作内存
这就埋下了所有并发问题的根源:线程之间不共享工作内存,彼此看不见对方的修改。
二、问题 1:多线程下变量看不见(可见性问题)
现象
线程 A 修改了变量,线程 B永远读不到最新值,甚至陷入死循环。
根本原因
CPU 缓存 + 不及时刷新主存 → 线程之间数据不同步
详细解释:
- 线程读取变量 → 复制到线程工作内存(CPU 缓存)
- 线程修改变量 → 只改自己缓存里的副本
- 不会立刻同步回主内存
- 其他线程依旧读自己缓存里的旧值
这就是可见性(Visibility)丢失。
结合底层:MESI 协议为什么不能完全解决?
之前我们讲过 MESI 缓存一致性协议,它能让多核 CPU 感知缓存失效。
但 CPU 做了优化:
- Store Buffer(存储缓冲)
- Invalidate Queue(失效队列)
为了快,CPU 不会立刻同步缓存,导致短暂时间内,线程依然看不见最新值。
代码示例(看不见的经典场景)
java
public class VisibilityProblem {
private static boolean flag = false;
public static void main(String[] args) {
// 线程1 等待 flag 变成 true
new Thread(() -> {
while (!flag) {
// 无限循环
}
System.out.println("线程1退出");
}).start();
// 线程2 修改 flag
new Thread(() -> {
try { Thread.sleep(1000); } catch (Exception ignored) {}
flag = true;
System.out.println("线程2已修改flag");
}).start();
}
}
结果:线程 1 永远死循环,看不见 flag 变成 true。
如何解决?
- volatile(强制读写主存 + 禁用缓存优化)
- synchronized
- Lock
三、问题 2:代码执行乱序(重排序问题)
现象
你写的代码顺序是 A → B → C但 JVM / CPU 实际执行是 B → A → C
为什么要重排序?
为了快! CPU 和 JVM 会在不影响单线程结果的前提下,乱序执行提高效率。
多线程下的灾难
单线程没问题,多线程会直接导致逻辑崩溃。
最经典案例:双重检查锁单例(DCL)
java
public class Singleton {
private static Singleton instance; // 没有 volatile
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 重点!!!
}
}
}
return instance;
}
}
new Singleton() 会被拆成 3 步:
- 分配内存
- 初始化对象
- instance 指向内存
重排序后可能变成:
- 分配内存
- instance 指向内存
- 初始化对象
这就会出现:instance != null,但对象还没初始化 → 线程使用时直接报错!
重排序的本质
- 编译器重排序
- CPU 指令重排序
- 内存系统重排序
JMM 无法禁止,但可以用 volatile 强制禁止。
volatile 如何禁止重排序?
通过内存屏障(Memory Barrier):
- 写屏障
- 读屏障
屏障前后的指令不能跨越屏障重排序。
四、问题 3:多线程修改变量不安全(原子性问题)
现象
i++ 看起来是一行代码多线程同时执行,结果永远少算。
根本原因
i++ 不是一步,而是三步:
- 读取 i
- 计算 i+1
- 写回 i
这三步不是原子操作,线程随时可能切换。
例子
- 线程 A 读取 i=10
- 线程 B 也读取 i=10
- 线程 A 加 1 → 11
- 线程 B 加 1 → 11
- 最终结果:11,而不是 12
丢失更新 = 线程不安全
什么是原子性?
一个操作不可分割,要么全部完成,要么全部不做。
为什么 volatile 不能解决原子性?
因为 volatile 只解决:
- 可见性
- 禁止重排序
不保证操作不可分割!
正确解决方案
- synchronized
- Lock
- AtomicInteger(CAS 无锁原子)
五、一张图总结三大问题
| 问题 | 学名 | 根本原因 | 解决方案 |
|---|---|---|---|
| 变量看不见 | 可见性问题 | CPU 缓存 + 不及时同步主存 | volatile、锁 |
| 执行乱序 | 有序性问题 | 指令重排序 | volatile、锁 |
| 计算错乱 | 原子性问题 | 操作非原子,线程切换 | 锁、原子类 |
Java 并发三大特性:可见性、有序性、原子性只要缺一个,就会出 bug。
六、最关键的总结(面试必背)
1. 变量为什么看不见?
- 线程读写的是自己的缓存副本
- 修改后没有立刻同步到主内存
- 其他线程读的是旧副本
2. 为什么代码会乱序?
- JVM/CPU 为了性能重排序指令
- 单线程没问题
- 多线程破坏依赖关系,导致逻辑错误
3. 为什么多线程不安全?
- 很多操作不是一步完成(如 i++)
- 线程切换会导致中间状态被覆盖
- 最终结果丢失、错乱
七、最终结论(最重要)
volatile 只能保证:可见性 + 有序性 不能保证:原子性
要真正安全,必须保证三大特性全部满足:
- 可见性
- 有序性
- 原子性
能同时保证三者的是:
- synchronized
- Lock
- 原子类(AtomicXxx)