并发问题的三大根源
理解根源,才能对症下药。
先看三个经典问题
问题一:两个线程同时改一个数,结果不对
问题二:线程 A 改了,线程 B 看不到
问题三:程序卡死了,谁也不动这三个问题的根源是什么?先从 CPU 和内存的关系说起。
CPU 和内存的速度鸿沟
CPU 计算速度是纳秒级,内存访问速度是百纳秒级。差距 100 倍。
CPU 计算: 1 ns
内存访问: 100 ns ← 差了 100 倍!
所以 CPU 加了缓存:
CPU → L1/L2/L3 缓存 → 内存根源一:可见性——线程间数据不可见
问题演示
java
public class VisibilityDemo {
private static boolean ready = false;
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Thread reader = new Thread(() -> {
while (!ready) {
// 理论上 ready=true 时循环退出
// 但可能永远看不到 ready=true
}
System.out.println(number); // 可能输出 0
});
Thread writer = new Thread(() -> {
number = 42;
ready = true; // 修改后,其他线程可能看不到
});
reader.start();
writer.start();
}
}为什么会这样?
线程 A(CPU 0): 线程 B(CPU 1):
number = 42(写 CPU0 缓存) while (!ready) {
ready = true(写 CPU0 缓存) // ready 还在 CPU1 缓存里
// 永远是 false
}每个 CPU 有自己的缓存,线程在不同的 CPU 上运行时,看不到彼此的修改。
Happens-Before 规则
JVM 通过 Happens-Before 规则保证可见性:
| 规则 | 含义 |
|---|---|
| 程序顺序 | A 先于 B 执行 → A happens-before B |
| 监视器锁 | unlock happens-before lock |
| volatile | volatile 写 happens-before 读 |
| 线程启动 | start() happens-before 线程内操作 |
| 线程终止 | 线程操作 happens-before join() |
| 传递性 | A→B, B→C → A→C |
java
// 使用 volatile 建立 happens-before
private volatile boolean ready = false;
// 线程 A:
x = 42;
ready = true; // volatile 写
// 线程 B:
if (ready) { // volatile 读
int y = x; // 保证能看到 x=42
}解决可见性问题
java
// 方案1:volatile
private volatile boolean ready = false;
// 方案2:synchronized
private int value;
public synchronized void write(int v) {
value = v; // 释放锁 happens-before
}
public synchronized int read() {
return value; // 获取锁后读取
}根源二:有序性——指令重排
CPU 和编译器会对指令重排以提升性能,但重排有规则:
as-if-serial 语义
不管怎么重排,单线程执行结果必须和顺序执行一样。
java
// 源代码
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
// 可能被重排为(因为 1 和 2 没有依赖)
int b = 2; // 2
int a = 1; // 1
int c = a + b; // 3多线程下的重排问题
java
// 线程 A:
ready = true; // 1
System.out.println(x); // 2
// 线程 B:
while (!ready) { } // 等 ready
int y = x; // 用 x如果指令重排为:
java
// 线程 A:
System.out.println(x); // 2 先执行了!
ready = true; // 1 后执行线程 B 可能看到 x=0 但 ready=true。
解决有序性问题
java
// 方案1:volatile(阻止重排)
private volatile boolean ready = false;
// 方案2:synchronized
private int x;
public synchronized void writer() {
x = 42;
ready = true; // 这两行不会被重排
}volatile 的内存屏障:
volatile 写:
StoreStore 屏障 → 写操作 → StoreLoad 屏障
volatile 读:
LoadLoad 屏障 → 读操作 → LoadStore 屏障根源三:原子性——操作不是一步完成
counter++ 不是原子的
java
public class AtomicDemo {
private int counter = 0;
public void increment() {
counter++; // ❌ 不是原子操作
}
}counter++ 实际分解为三步:
1. MOV EAX, [counter] // 从内存读 counter 到 CPU 寄存器
2. ADD EAX, 1 // 在 CPU 中 +1
3. MOV [counter], EAX // 写回内存两个线程同时执行:
线程 A:读 counter=0 → +1 → 还没写回
线程 B:读 counter=0 → +1 → 写回 counter=1
线程 A:写回 counter=1
结果:counter=1,丢了 A 的更新!解决原子性问题
java
// 方案1:synchronized
private int counter = 0;
public synchronized void increment() {
counter++;
}
// 方案2:原子类
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet();
}三大根源总结
| 根源 | 原因 | 解决 |
|---|---|---|
| 可见性 | CPU 缓存 | volatile / synchronized |
| 有序性 | 指令重排 | volatile / synchronized |
| 原子性 | 操作非一步 | synchronized / 原子类 |
synchronized 和 volatile 的区别:
| volatile | synchronized | |
|---|---|---|
| 可见性 | ✅ | ✅ |
| 有序性 | ✅ | ✅ |
| 原子性 | ❌(读 Write 除外) | ✅ |
| 性能 | 轻量 | 重量 |
实战:选择正确的同步方式
java
// 场景1:flag 标志位
private volatile boolean running = true; // ✅ volatile 够了
// 场景2:计数器
private AtomicInteger counter = new AtomicInteger(0); // ✅ 原子类
// 场景3:复合操作(如 check-then-act)
private int value = 0;
public synchronized void incrementIfPositive() {
if (value > 0) { // 检查
value++; // 行动
} // ❌ 不能分开,必须 synchronized
}
// 场景4:需要原子性的 flag
private AtomicBoolean processing = new AtomicBoolean(false);总结
- 可见性:CPU 缓存导致线程间数据不可见,用 volatile 或 synchronized 解决
- 有序性:编译器和 CPU 会重排指令,用 volatile 或 synchronized 解决
- 原子性:
counter++分解为三步,用 synchronized 或原子类解决 - 理解三大根源,才能正确选择同步方案
