📋 概述
一句话:多线程代码最可怕的不是出 bug,而是"同一个 bug,有时出现有时不出现"——这就是并发问题的本质:不可复现、难以调试、测试很难覆盖。
你有没有遇到过这样的场景?
- 本地跑了 100 次都没问题的代码,上线第一天就报了数据不一致
- 接口偶发超时,但日志里找不到任何异常
- 两个线程各自给 count 加 10000 次,结果有时是 20000,有时是 19847,有时又是 19923
这些"薛定谔的 bug"正是并发编程最让人头疼的地方。原因有三:
| 问题特征 | 说明 | 比喻 |
|---|---|---|
| 不可复现 | 同样的代码,跑一万次可能只出一次 | 鬼来电:你盯着它就不响,你一转身它就响 |
| 难以调试 | 加断点会改变线程时序,bug 反而消失了 | 测不准原理:观测行为改变了被观测对象 |
| 测试难覆盖 | 单元测试几乎无法稳定复现竞态条件 | 抽奖:想抽中"出错"那一次概率极低 |
本篇是"问题篇"的核心,回答一个根本问题:并发到底会带来哪些问题? 后续各篇(volatile / synchronized / 锁体系)讲怎么解决,本篇只讲问题本身 + 排查工具概览 + 问题到解决方案的导航地图。
💡 生活类比:交通问题
并发问题 = 交通问题。把线程想象成司机,共享资源想象成车道:
| 并发问题 | 交通类比 | 一句话说明 |
|---|---|---|
| 竞态条件 | 两车同时抢一个车道 | 谁先到谁走,但两车同时挤上去就撞了 |
| 死锁 | 两车互相堵在窄路上,谁也不让 | A 等 B 让,B 等 A 让,永远卡住 |
| 活锁 | 两人互相让路,结果左右晃了半天谁也没过去 | 都在动,但都没前进 |
| 饥饿 | 救护车被堵在车流里,永远到不了 | 有急事的线程永远抢不到 CPU |
| 可见性问题 | A 在黑板上改了数字,B 还在看自己手里的旧笔记 | 改了但别人看不到 |
| 有序性问题 | 你先穿袜子再穿鞋,但某天系统让你先穿鞋再穿袜子 | 步骤被重排导致逻辑出错 |
| 原子性问题 | "先看余额再扣款"被插队:两人同时看余额都有 100 元,各扣 100,实际余额只减了 100 | 复合操作被拆开执行 |
一句话记忆:路上的事故,本质上都是"抢道"引发的。并发问题的根源也是"多个线程抢同一份数据"。
🔍 7 大并发问题
1. 竞态条件(Race Condition)
问题本质:多个线程对共享数据的最终结果,取决于线程执行的时序——时序变了,结果就变了。
经典案例:count++ 为什么不对?
count++ 看起来是一步,实际上是三步:
两个线程同时执行 count++,如果交错执行:
Thread-1 读 count=5
Thread-2 读 count=5 ← 也读到5
Thread-1 计算 5+1=6,写回 count=6
Thread-2 计算 5+1=6,写回 count=6 ← 丢失了一次更新!
触发条件:多个线程读写同一变量 + 操作非原子 + 无同步措施。
后果:数据不一致、计数器偏低、集合大小不对、业务逻辑跳过关键步骤。
2. 死锁(Deadlock)
问题本质:两个或多个线程互相持有对方需要的锁,导致所有线程都永远等待。
触发条件:两把以上锁 + 多线程交叉获取 + 无固定顺序。
后果:程序完全卡死,CPU 使用率正常(线程都在等,不在跑),接口超时。
3. 活锁(Livelock)
问题本质:线程不断重试某个操作,但每次都因为对方的"配合"而失败。线程没有阻塞,但也没有进展。
生活类比:两人在走廊迎面相遇,同时让到左边,又同时让到右边,结果谁也过不去。
触发条件:线程在失败后立即重试 + 重试策略完全相同(如同时让步)。
后果:CPU 使用率飙高(线程在不断运行),但业务逻辑没有进展。
// 活锁示例:两人互相让路,永远走不过去
public class SimpleLivelock {
private static volatile boolean aliceYielding = true;
private static volatile boolean bobYielding = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5 && aliceYielding; i++) {
System.out.println("Alice: 我让一下...");
try { Thread.sleep(200); } catch (Exception e) {}
}
if (!aliceYielding) System.out.println("Alice 终于通过了!");
}, "Alice");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5 && bobYielding; i++) {
System.out.println("Bob: 我也让一下...");
try { Thread.sleep(200); } catch (Exception e) {}
}
if (!bobYielding) System.out.println("Bob 终于通过了!");
}, "Bob");
t1.start(); t2.start();
// 两人都在让路,循环结束时谁也没通过
t1.join(); t2.join();
System.out.println("Alice 让路次数: " + aliceYielding);
System.out.println("Bob 让路次数: " + bobYielding);
}
}
4. 饥饿(Starvation)
问题本质:某个线程长期得不到 CPU 时间片或锁资源,虽然系统整体在运行,但该线程的任务永远无法完成。
触发条件:线程优先级不合理 + 公平性缺失 + 长时间持有锁的线程。
后果:低优先级任务永远不完成、日志显示任务提交但无执行记录、系统整体吞吐下降。
5. 可见性问题
问题本质:一个线程修改了共享变量的值,但另一个线程读到的还是旧值。原因是 CPU 缓存和编译器优化导致每个线程看到的是"本地副本"。
触发条件:共享变量没有 volatile 修饰 + 没有 synchronized 保护 + 跨线程读写。
后果:线程陷入死循环(flag 改了但另一个线程看不到)、数据过期、条件判断失效。
6. 有序性问题
问题本质:JVM 和 CPU 为了优化性能会对指令进行重排序。单线程下重排不会出错(JMM 保证 as-if-serial),但多线程下重排可能破坏线程间的依赖关系。
触发条件:指令之间存在跨线程依赖 + 没有 volatile 或 synchronized 阻止重排。
后果:DCL 单例拿到半初始化的对象、初始化顺序错误导致 NPE。
7. 原子性问题
问题本质:一个操作看起来是一步,实际上被拆成了多步执行。多线程环境下,步骤之间可能被其他线程插入。
触发条件:复合操作(读-改-写)没有加锁或使用原子类。
后果:与竞态条件类似——计数器不准、余额计算错误、状态判断跳过关键步骤。
// 原子性问题:两个线程同时扣款,余额只扣了一次
public class AtomicityAccountDemo {
private static int balance = 100; // 初始余额 100 元
public static void main(String[] args) throws Exception {
Runnable withdraw = () -> {
int temp = balance; // ① 读余额
if (temp >= 50) { // ② 检查够不够
try { Thread.sleep(10); } catch (Exception e) {} // 模拟处理
balance = temp - 50; // ③ 扣款写回
}
};
Thread t1 = new Thread(withdraw, "用户A");
Thread t2 = new Thread(withdraw, "用户B");
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("初始余额: 100, 实际余额: " + balance);
System.out.println("两人都扣了 50,期望余额: 0, 实际: " + balance);
// 预期:余额 = 50 而不是 0,两次扣款被"合并"了
}
}
🔍 问题之间的关系
关键因果关系:
| 关系 | 说明 |
|---|---|
| 竞态条件 = 原子性问题 + 可见性问题 | 竞态条件是"果",原子性和可见性问题是"因" |
| 有序性问题可能引发可见性问题 | 指令重排导致写操作提前/延后,其他线程看到不一致的中间状态 |
| 活锁可能退化为 CPU 飙高 | 线程不断重试但不阻塞,CPU 使用率 100% 但无业务进展 |
| 死锁和饥饿可能共存 | 某些线程死锁等锁,另一些线程因死锁线程长期占资源而饥饿 |
| 原子性问题不一定是竞态条件 | 单个原子变量的 CAS 操作虽然"复合"但由硬件保证原子性,不算竞态条件 |
🔍 排查工具速览
本篇只做概览,详细的排查步骤和命令见 03_问题与应对/03_并发问题排查实战。
| 工具 | 一句话用途 | 最简命令 | 适用问题 |
|---|---|---|---|
| jstack | 导出线程栈快照,分析线程状态和锁持有关系 | jstack <pid> > dump.txt | 死锁、线程泄漏、BLOCKED 分析 |
| jconsole | GUI 可视化监控,一键检测死锁 | jconsole <pid> → Thread → Detect Deadlock | 死锁、线程监控 |
| arthas | 在线诊断神器,实时追踪线程和方法 | thread -b(死锁)/ thread -n 3(CPU 最高) | 全场景,尤其是 CPU 飙高和死锁 |
| top + printf | 系统级定位 CPU 最高的线程 | top -Hp <pid> → printf "%x" <tid> → jstack | CPU 飙高 |
| jstat | JVM 统计信息,看 GC 情况 | jstat -gcutil <pid> 1000 | GC 相关的 CPU 飙高 |
# 三板斧:定位 CPU 最高的线程(Linux)
top -Hp <pid> # 找 CPU 最高的线程 tid
printf "%x\n" <tid> # 转 16 进制
jstack <pid> | grep "<hex>" -A 30 # 在线程栈中定位
|
💻 可跑代码
以下 5 个 demo 都能直接 javac && java 跑,每个故意制造问题让你看到现象。
Demo 1:竞态条件(count++ 丢失更新)
public class RaceConditionDemo {
private static int count = 0;
public static void main(String[] args) throws Exception {
Runnable r = () -> {
for (int i = 0; i < 10000; i++) {
count++; // 非原子操作
}
};
Thread t1 = new Thread(r, "Thread-A");
Thread t2 = new Thread(r, "Thread-B");
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("期望: 20000, 实际: " + count);
// 预期输出:实际值 < 20000,如 "期望: 20000, 实际: 19847"
}
}
Demo 2:死锁(交叉锁)
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread-1: 持有 lockA,等待 lockB...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread-1: 拿到 lockB,完成!");
}
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread-2: 持有 lockB,等待 lockA...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("Thread-2: 拿到 lockA,完成!");
}
}
}, "Thread-2");
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("程序结束");
// 预期输出:程序卡住,永远不会打印"程序结束"
}
}
Demo 3:活锁(两人互相让路)
public class LivelockDemo {
static class Person {
private final String name;
private boolean yielding;
Person(String name, boolean yielding) {
this.name = name;
this.yielding = yielding;
}
String getName() { return name; }
boolean isYielding() { return yielding; }
void setYielding(boolean yielding) { this.yielding = yielding; }
}
public static void main(String[] args) throws InterruptedException {
Person alice = new Person("Alice", true);
Person bob = new Person("Bob", true);
final int[] steps = {0};
Runnable walk = () -> {
for (int i = 0; i < 10; i++) {
if (((Person) Thread.currentThread()).isYielding()) {
System.out.println(Thread.currentThread().getName() + " 让路...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
continue;
}
System.out.println(Thread.currentThread().getName() + " 通过了!");
steps[0]++;
}
};
// 两人都在让路,谁也过不去
alice.yielding = true;
bob.yielding = true;
Thread t1 = new Thread(() -> { for (int i = 0; i < 10; i++) { if (alice.yielding) { System.out.println("Alice 让路..."); try { Thread.sleep(100); } catch (Exception e) {} } else { System.out.println("Alice 通过!"); steps[0]++; break; } } }, "Alice");
Thread t2 = new Thread(() -> { for (int i = 0; i < 10; i++) { if (bob.yielding) { System.out.println("Bob 让路..."); try { Thread.sleep(100); } catch (Exception e) {} } else { System.out.println("Bob 通过!"); steps[0]++; break; } } }, "Bob");
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("步数: " + steps[0]);
// 预期输出:Alice 和 Bob 交替打印"让路...",步数为 0
}
}
Demo 4:可见性问题(flag 不加 volatile)
public class VisibilityDemo {
private static boolean flag = false; // 没有 volatile
public static void main(String[] args) throws Exception {
Thread reader = new Thread(() -> {
while (!flag) {
// 子线程可能永远看不到 flag 变为 true
}
System.out.println("子线程:看到 flag 变了!");
}, "Reader");
reader.start();
Thread.sleep(1000);
flag = true; // 主线程修改
System.out.println("主线程:flag 设为 true");
reader.join(3000);
if (reader.isAlive()) {
System.out.println("⚠️ 子线程仍在死循环!flag 改了但它看不到");
reader.interrupt();
}
// 预期输出:子线程可能永远不打印"看到 flag 变了"
}
}
Demo 5:原子性问题(i++ 竞争)
public class AtomicityDemo {
private static int counter = 0;
public static void main(String[] args) throws Exception {
int threadCount = 10;
int incrementsPerThread = 100000;
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < incrementsPerThread; j++) {
counter++; // 非原子操作
}
}, "Worker-" + i);
threads[i].start();
}
for (Thread t : threads) t.join();
int expected = threadCount * incrementsPerThread;
System.out.println("期望: " + expected + ", 实际: " + counter);
System.out.println("丢失: " + (expected - counter) + " 次更新");
// 预期输出:实际值远小于 1000000
}
}
Demo 6:有序性问题(DCL 不加 volatile)
public class DCLUnsafeDemo {
private static DCLUnsafeDemo instance; // 没有 volatile
static class DCLUnsafeDemo {
public String data;
DCLUnsafeDemo() {
// 模拟耗时初始化
data = "初始化完成";
System.out.println("构造函数:对象初始化完毕");
}
}
public static DCLUnsafeDemo getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (DCLUnsafeDemo.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new DCLUnsafeDemo(); // ← 这不是原子的!
}
}
}
return instance;
}
public static void main(String[] args) throws Exception {
// 多线程同时获取实例
for (int i = 0; i < 100; i++) {
new Thread(() -> {
DCLUnsafeDemo obj = getInstance();
if (obj.data == null) {
System.out.println("⚠️ 拿到半初始化对象!data == null");
}
}).start();
}
Thread.sleep(2000);
System.out.println("单例 data: " + (instance != null ? instance.data : "null"));
// 预期:可能出现 data == null(半初始化对象)
}
}
⚠️ 常见问题
Q1:并发问题能完全避免吗?
不能完全避免,但可以大幅降低发生概率。 遵循三个原则:① 优先使用不可变对象(String、Integer、Record);② 优先使用高层并发工具(线程池、ConcurrentHashMap、CompletableFuture)而非手写锁;③ 最小化共享可变状态。做到这三点,大部分并发问题就不会出现。
Q2:测试能发现并发 bug 吗?
常规单元测试几乎不可能。 竞态条件的出现取决于线程调度时序,代码跑 100 次可能只出 1 次。如果要测试并发代码,需要:① 使用 CountDownLatch/CyclicBarrier 精确控制多线程启动时机;② 跑 10000+ 次循环增加复现概率;③ 使用 jcstress(Java Concurrency Stress Test)框架做压力测试。但即使如此,也只能"提高发现概率",不能"保证发现"。
Q3:什么时候不需要关心线程安全?
当以下任一条件满足时,通常不需要线程安全:① 只有一个线程在访问数据(没有共享);② 多个线程但都是只读(没有写操作);③ 数据是不可变的(String、Integer、Record 等 final 类);④ 使用了线程安全的并发容器(ConcurrentHashMap、CopyOnWriteArrayList)。
Q4:并发问题和分布式问题有啥关系?
本质上是同一类问题在不同尺度上的体现。 并发问题是多个线程抢同一台机器上的数据;分布式问题是多个节点抢同一份数据。解决方案的思路也类似:并发用锁(synchronized/ReentrantLock),分布式用分布式锁(Redis/ZooKeeper);并发用 CAS,分布式用乐观锁(版本号)。理解并发问题是理解分布式系统的基石。
🎯 最佳实践
| 原则 | 做法 | 说明 |
|---|---|---|
| 优先不可变 | 用 String、Integer、Record、final 字段 | 不可变对象天然线程安全,无需加锁 |
| 优先高层工具 | 用 ConcurrentHashMap、AtomicInteger、线程池 | 不要手写锁,框架已经处理好 |
| 最小化共享 | 用 ThreadLocal、每个线程独立数据 | 不共享就没有并发问题 |
| 用锁保护共享状态 | synchronized/ReentrantLock 保护临界区 | 必须共享时,用锁串行化访问 |
| 按序加锁 | 所有线程按固定顺序获取多把锁 | 破坏死锁的循环等待条件 |
| tryLock 超时 | ReentrantLock.tryLock(timeout) | 避免无限等待导致死锁 |
| 锁内不做慢操作 | 网络/IO/sleep 移到锁外 | 减少持锁时间,降低竞争概率 |
| volatile 标志位 | volatile boolean flag 控制线程启停 | 解决可见性问题,比 synchronized 轻量 |
💡 面试要点
- 7 大并发问题是哪些? → 竞态条件、死锁、活锁、饥饿、可见性问题、有序性问题、原子性问题。能说清每个问题的本质和触发条件。
- 死锁的 4 个必要条件? → 互斥、占有并等待、不可抢占、循环等待。破坏任一条件即可预防。最常见的方案是按序加锁(破坏循环等待)和 tryLock 超时(破坏不可抢占)。
- 竞态条件的本质是什么? → 是原子性问题 + 可见性问题的综合表现。
count++不是原子操作(读-改-写三步),多线程交错执行就会丢失更新。 - 可见性问题的原因? → CPU 缓存(每个核有 L1/L2 缓存)+ 编译器/JVM 优化(把变量缓存到寄存器)。一个线程改了变量值,其他线程可能还在读自己的本地副本。解决方案:
volatile或synchronized。 - 如何预防并发问题? → 三个层次:① 设计层:不可变对象、最小化共享;② 代码层:优先用
ConcurrentHashMap等并发容器;③ 锁层:synchronized/ReentrantLock保护临界区。 - jstack 能检测所有并发问题吗? → 不能。jstack 只能检测 Java 级别的经典死锁。活锁、饥饿、可见性问题、有序性问题都需要通过代码分析或 arthas 等工具间接判断。
- 并发问题为什么测试难发现? → 因为取决于线程调度时序,具有不确定性。同一段代码跑 100 次可能只出 1 次。需要用
CountDownLatch精确控制时序,或用 jcstress 框架做压力测试。
📝 总结
| 要点 | 记住这一句 |
|---|---|
| 并发问题本质 | 不可复现、难以调试、测试难覆盖 |
| 7 大问题 | 竞态条件、死锁、活锁、饥饿、可见性、有序性、原子性 |
| 竞态条件 | 非原子操作 + 多线程交错 = 结果取决于时序 |
| 死锁 | 两把锁交叉持有 → 4 个必要条件 → 按序加锁预防 |
| 可见性 | CPU 缓存导致线程看不到最新值 → volatile 解决 |
| 排查三板斧 | top -Hp → printf "%x" → jstack 定位代码行 |
| 预防原则 | 不可变 > 高层工具 > 最小化共享 > 加锁保护 |