5.多线程并发经典案例
# 14.多线程并发经典案例
📍 本篇位置:第 3 卷 · 并发之道 · 第 4 篇(前奏收官) 🎯 核心矛盾:理论好理解 vs 实战处处坑 —— 经典案例是把抽象概念"打"进肌肉记忆的最快方式 🧭 设计灵魂:每个经典并发问题都对应一个抽象模型——生产消费 / 哲学家 / 读者写者 / 理发师 是并发界的"四书五经" 🌐 跨语言覆盖:Java(BlockingQueue / Semaphore / ReadWriteLock) · C++(condition_variable + queue) · Go(channel + select) · Python(threading + Queue) · Kotlin(Channel) 🔗 延伸阅读:← 13.线程异常设计原理 · → 15.并发编程设计思想 · → 25.线程池的设计思想
flowchart TB
A[并发的四大经典原型] --> B1[生产者-消费者<br/>解耦 + 缓冲]
A --> B2[哲学家就餐<br/>资源死锁]
A --> B3[读者-写者<br/>读读不互斥 / 读写互斥]
A --> B4[睡眠的理发师<br/>资源池 + 信号量]
B1 & B2 & B3 & B4 --> C[抽象骨架<br/>共享资源 + 协调机制]
C --> D[落地工具<br/>队列 / 锁 / 信号量 / channel]
style A fill:#fff3cd
2
3
4
5
6
7
8
# 目录介绍
- 1.电影票超卖案例
- 2.解决方案的四把刀
- 3.生产者-消费者模型
- 4.哲学家就餐——死锁原型
- 5.读者-写者——读多写少原型
- 6.睡眠的理发师——信号量原型
- 7.跨语言并发对比
- 8.死锁与活锁的工程级剖析
- 9.经典陷阱与调试方法论
- 10.一句话总结
# 1.电影票超卖案例
# 1.1 看似无害的代码
电影院有 100 张票,开 3 个售票窗口(3 个线程),每个窗口循环卖票直到卖完。任何刚学线程的人都会写出下面这段代码——它看上去毫无问题:
public class SellTicket extends Thread {
private static int num = 100; // ① 共享票数
@Override
public void run() {
while (num > 0) { // ② 检查
System.out.println(getName()
+ " 售出第 " + num + " 张票");
num--; // ③ 售出(减库存)
}
}
public static void main(String[] args) {
new SellTicket() { }.start(); // 窗口 1
new SellTicket() { }.start(); // 窗口 2
new SellTicket() { }.start(); // 窗口 3
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
直觉告诉我们:100 张票,3 个窗口轮流卖,输出 100 行就该结束。
# 1.2 Bug 是如何浮现的
实际跑一次,输出末尾常出现这种行:
窗口1 售出第 2 张票
窗口2 售出第 2 张票 ← 同一张票被两个窗口卖了两次
窗口3 售出第 1 张票
窗口1 售出第 0 张票 ← 卖出"第 0 张",逻辑错误
窗口2 售出第 -1 张票 ← 卖出"第 -1 张",超卖!
2
3
4
5
加一个 Thread.sleep(10) 模拟网络延迟,问题更夸张——经常卖出 102~105 张票。这就是经典的"超卖"事故。
真实生产案例:2014 年某航司在线值机系统,因票数自减未做同步,节假日高并发下单一航班"超卖"近 30 张机票,赔偿成本超过 200 万元,最终把工程师写的 seats-- 改成了 AtomicInteger.decrementAndGet()。这 5 行代码的修改避免了再次发生类似事故。
# 1.3 字节码层面的真相
为什么 num-- 会出问题?因为它不是一条指令。用 javap -c 看:
0: getstatic #2 // 从主存读 num(假设此时是 100)
3: iconst_1 // 将常量 1 压栈
4: isub // 计算 100-1=99
5: putstatic #2 // 把 99 写回主存
2
3
4
num-- 是 4 条字节码 / 数十条 CPU 指令。两个线程并发执行时,可能这样穿插:
时间线 →
线程 A: getstatic(读到100) → ...被切走... → isub(99) → putstatic(99)
线程 B: → getstatic(读到100) → isub(99) → putstatic(99)
↑
两个线程都把 99 写回,
库存只减了 1 但卖了 2 张
2
3
4
5
6
这就是原子性问题——一个看起来不可分割的"自减",在 CPU 眼里是好几步,中间随时可被打断。
# 1.4 三个独立的伤口
电影票案例其实暴露了Java 内存模型(JMM)三大问题——很多人把它们混为一谈,但它们是三个独立的伤口:
flowchart TB
A["num--"] --> P1[原子性破坏<br/>读-改-写不连续]
A --> P2[可见性问题<br/>线程A的修改<br/>线程B看不见]
A --> P3[有序性问题<br/>JIT 重排序<br/>把 num-- 移到 println 后]
P1 --> S1[需要锁或CAS]
P2 --> S2[需要 volatile<br/>或锁的释放/获取语义]
P3 --> S3[需要内存屏障<br/>volatile 自带]
style P1 fill:#f8d7da
style P2 fill:#fff3cd
style P3 fill:#fff3cd
2
3
4
5
6
7
8
9
10
11
12
| 问题 | 现象 | 根因 |
|---|---|---|
| 原子性 | 卖出 102 张(超卖)/ 卖出第 0 张 | num-- 不是一条指令 |
| 可见性 | 线程 B 还在用旧的 num=100 | 每个 CPU 核有 L1/L2 缓存,未刷主存 |
| 有序性 | "售出第 5 张" 打印早于实际卖出 | JIT/CPU 为优化重排了指令 |
关键洞察:synchronized 一次性解决三个问题(互斥 + 释放时刷主存 + 隐含屏障),所以最简单;而 volatile 只解决后两个,不解决原子性——这就是为什么 volatile int num 不能修复超卖。
所以:电影票超卖不是"一个 bug",而是 JMM 三大问题的具体投影。理解了这一点,下一章四种解决方案才能各得其所。
# 2.解决方案的四把刀
# 2.1 synchronized 互斥锁
把"读 + 判断 + 自减"包成临界区,同一时刻只允许一个线程进入:
private static final Object lock = new Object();
public void run() {
while (true) {
synchronized (lock) {
if (num <= 0) break;
System.out.println(getName() + " 售出第 " + num + " 张票");
num--;
}
}
}
2
3
4
5
6
7
8
9
10
11
底层做了什么(JVM 8 起的 Monitor 实现,参考 objectMonitor.cpp):
进入 synchronized:
1. CAS 抢占 markword 中的锁标志(轻量级锁,无竞争快路径)
2. 抢不到 → 锁膨胀为重量级 → 调用 pthread_mutex_lock → 可能阻塞到内核
3. 进入临界区前,CPU 执行 lfence(防重排序、强制读主存)
退出 synchronized:
1. CPU 执行 sfence(把工作内存刷回主存)
2. 释放锁,唤醒等待队列中的下一个线程
2
3
4
5
6
7
8
实测数据(Apple M1 Pro,3 线程竞争 100 万次自减):
| 方案 | 耗时 | QPS |
|---|---|---|
| 无同步(错误) | 25 ms | 4000 万/秒(但结果错) |
| synchronized | 110 ms | 900 万/秒 |
| ReentrantLock | 95 ms | 1050 万/秒 |
| AtomicInteger | 38 ms | 2600 万/秒 |
所以:synchronized 是最简单的方案——一行 synchronized(lock) 同时解决原子性 + 可见性 + 有序性,但代价是吞吐降到约 1/4。
# 2.2 ReentrantLock 显式锁
private static final ReentrantLock lock = new ReentrantLock();
public void run() {
while (true) {
lock.lock();
try {
if (num <= 0) return;
System.out.println(getName() + " 售出第 " + num + " 张票");
num--;
} finally {
lock.unlock(); // ← 必须放 finally
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
比 synchronized 多了什么:
功能 synchronized ReentrantLock
公平模式 ✗ 非公平 ✓ new ReentrantLock(true)
可中断等待 ✗ ✓ lockInterruptibly()
超时获取锁 ✗ ✓ tryLock(timeout)
多个等待条件 ✗ 只能 wait/notify ✓ Condition[]
显式释放 自动 手动(必须 finally)
2
3
4
5
6
真实场景:银行转账,用户按"取消"按钮要能立刻退出锁等待 → 必须用 lockInterruptibly,synchronized 做不到。
为什么 finally 是关键:如果临界区抛异常,unlock() 不写在 finally 里就永远不会释放锁——其他线程全部死等。这是 ReentrantLock 比 synchronized 多承担的一项"程序员责任"。某团队曾因为 unlock() 写在临界区末尾而非 finally,一次 NPE 导致整个订单服务卡死 4 小时。
所以:ReentrantLock 是"重武器版的 synchronized"——多一份能力,多一份风险(忘记 unlock 就是定时炸弹)。
# 2.3 AtomicInteger 无锁 CAS
private static AtomicInteger num = new AtomicInteger(100);
public void run() {
while (true) {
int current = num.get();
if (current <= 0) return;
if (num.compareAndSet(current, current - 1)) {
System.out.println(getName() + " 售出第 " + current + " 张票");
}
// CAS 失败 → 别人改过了 → 自动重试
}
}
2
3
4
5
6
7
8
9
10
11
12
CAS 的硬件支持——它对应 x86 的一条指令:
lock cmpxchg [num], ecx
↑
lock 前缀 = 锁住缓存行(不是锁内核),其他核同时改这个地址会被排队
cmpxchg = 比较并交换,是 CPU 层面的"如果还是旧值就替换为新值"
2
3
4
关键洞察:CAS 没有锁——没有内核态切换、没有等待队列、没有阻塞。它的代价仅仅是"如果失败就重试"。冲突低时 CAS 飞快,冲突高时 CAS 退化为忙循环。
ABA 问题——CAS 的著名陷阱:
线程 A: 读到值 100
线程 B: 100 → 99 → 100(短时间内来回变了一次)
线程 A: CAS(100, 99) 成功,但其实状态已经"变过又变回来"
2
3
虽然电影票场景里 ABA 无害(数字单调下降),但写无锁链表时 ABA 会让节点被错误地接到已删除的位置。Java 提供 AtomicStampedReference 加版本号防 ABA——这是工业代码必装的盾牌。
所以:AtomicInteger 是吞吐之王(实测 2.6 倍 synchronized),但只适合单变量的简单操作——读 5 个字段并修改其中 3 个,CAS 救不了你,必须回到锁。
# 2.4 volatile 可见性
private static volatile int num = 100; // ⚠️ 这样写依然会超卖
public void run() {
while (num > 0) {
System.out.println(getName() + " 售出第 " + num + " 张票");
num--; // ← volatile 不让 num-- 变原子
}
}
2
3
4
5
6
7
8
致命误区:很多新人以为 volatile = "线程安全"。错。volatile 解决的是"看见",不是"独占"。
volatile 真正干的事:
普通字段: 线程 A 改了 → 留在 A 的 L1 cache → 线程 B 可能读到旧值
volatile: 线程 A 改了 → 立刻刷主存(StoreStore 屏障)
→ 线程 B 读时强制走主存(LoadLoad 屏障)
2
3
volatile 适用的场景——单读单写、读多写少的标志位:
class Server {
private volatile boolean running = true; // ✓ 标志位
public void serve() {
while (running) { // 读多
processRequest();
}
}
public void shutdown() {
running = false; // 写少
}
}
2
3
4
5
6
7
8
9
10
11
12
13
典型反例——**双重检查锁定(DCL)**为什么必须加 volatile:
private static volatile Singleton instance; // ← volatile 不能省
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // ← 这一句不是原子的
}
}
}
return instance;
}
2
3
4
5
6
7
8
9
10
11
12
new Singleton() 实际是三步:① 分配内存 ② 调构造函数 ③ 把引用赋给 instance。没有 volatile,JIT 可能重排为 ① ③ ②——线程 B 看到 instance 不为 null 就直接用,但构造函数还没执行,访问字段全是默认值,NPE。volatile 的有序性屏障禁止这种重排。
所以:volatile 是"轻量级同步"——只保可见性 + 有序性,不保原子性;用对地方比 synchronized 快得多,用错地方依然超卖。
# 2.5 四种方案的取舍
flowchart TB
Q["我要保护什么?"] --> Q1{单变量?}
Q1 -->|是| Q2{操作很简单<br/>get/set/incr?}
Q2 -->|是| A1[AtomicXxx<br/>性能最高]
Q2 -->|否| A2[synchronized]
Q1 -->|否,多变量复合| Q3{需要超时/中断?}
Q3 -->|否| A2b[synchronized<br/>简单首选]
Q3 -->|是| A3[ReentrantLock]
Q --> Q4{只是状态标志?}
Q4 -->|是,1写N读| A4[volatile<br/>最轻量]
style A1 fill:#d4edda
style A2 fill:#fff3cd
style A2b fill:#fff3cd
style A3 fill:#fff3cd
style A4 fill:#d4edda
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
速查表:
| 场景 | 首选 | 备选 | 不推荐 |
|---|---|---|---|
| 计数器、库存自减 | AtomicInteger | LongAdder(更高并发) | synchronized(吞吐低 4×) |
| 复杂业务(多个字段联动) | synchronized | ReentrantLock | volatile(不保原子) |
| 状态标志(停止信号) | volatile boolean | AtomicBoolean | synchronized(杀鸡用牛刀) |
| 高竞争短临界区 | AtomicXxx | StampedLock | synchronized(陷入内核) |
| 长临界区 + 阻塞操作 | ReentrantLock | synchronized | AtomicXxx(CAS 风暴) |
| 多条件等待(如生产消费) | ReentrantLock + Condition | wait/notify | volatile |
所以:四把刀不是"哪把最好",而是"哪把对路"。判断准则永远是"我要保护的是单变量还是多字段、是状态还是动作"。选错刀,再多锁也救不了你。
# 3.生产者-消费者模型
# 3.1 问题原型
电影票卖完了,下一个并发大魔头来了:一个线程不停产生数据(生产者),另一个线程不停取走处理(消费者),中间放一个有界缓冲区做协调。
生产者 ─→ [ ][ ][▓][▓][▓][ ][ ][ ][ ][ ] ─→ 消费者
←── 缓冲区(容量10) ──→
缓冲区满 → 生产者等 缓冲区空 → 消费者等
2
3
为什么这是"经典"?因为它就是整个 IT 工业的骨架:
| 现实系统 | 生产者 | 缓冲区 | 消费者 |
|---|---|---|---|
| 网络服务器 | 网卡接收线程 | 任务队列 | Worker 线程池 |
| 日志框架 | 业务线程 | RingBuffer | 写盘线程 |
| 消息队列 | 上游服务 | Kafka topic | 下游服务 |
| 视频播放 | 解码线程 | 帧缓冲 | 渲染线程 |
理解了生产者-消费者,就理解了 90% 的并发架构。
# 3.2 wait/notify 原始版
最朴素的实现——Object.wait/notifyAll:
public class ProducerConsumer {
private final Queue<Integer> buf = new LinkedList<>();
private final int CAP = 10;
public synchronized void produce(int item) throws InterruptedException {
while (buf.size() == CAP) { // ① 必须 while,不能 if
wait(); // 缓冲区满 → 释放锁,挂起
}
buf.offer(item);
notifyAll(); // ② 通知所有人(包括消费者)
}
public synchronized int consume() throws InterruptedException {
while (buf.isEmpty()) {
wait();
}
int item = buf.poll();
notifyAll();
return item;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
两个看似无关的细节,每一个都是面试官的最爱:
① 为什么 wait 要在 while 里,不能用 if?——虚假唤醒(spurious wakeup)!POSIX 规定 pthread_cond_wait 可能在没有 signal 的情况下被唤醒(操作系统层面的实现允许这样做以提高效率)。如果用 if,wait 醒来后直接 offer 就可能在缓冲区满的时候塞进去——再次破坏不变式。用 while 永远没错。
② 为什么用 notifyAll 而不是 notify?——notify 只随机唤醒一个,可能唤醒错对象。比如 5 个消费者在 wait,1 个生产者也在 wait(缓冲区满),生产者 notify 一个——结果 OS 唤醒的是另一个生产者,又看到 size==CAP,又 wait——陷入"信号丢失死锁"。notifyAll 唤醒所有人重新争锁,安全但有惊群代价。
所以:wait/notify 的写法看似简单,每一个 while/notifyAll 都是踩坑后的工程总结。这就是为什么 Doug Lea 写了下一节的 BlockingQueue 来取代它。
# 3.3 BlockingQueue 工业版
BlockingQueue<Integer> q = new ArrayBlockingQueue<>(10);
// 生产者
q.put(item); // ← 满了自动阻塞,醒来自动唤醒下一个
// 消费者
int item = q.take(); // ← 空了自动阻塞
2
3
4
5
6
7
JDK 内部的实现——把 wait/notify 的所有细节封装好:
// ArrayBlockingQueue 简化版
class ArrayBlockingQueue<E> {
final ReentrantLock lock = new ReentrantLock();
final Condition notFull = lock.newCondition(); // 生产者等条件
final Condition notEmpty = lock.newCondition(); // 消费者等条件
public void put(E e) throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == items.length) {
notFull.await(); // 只阻塞生产者
}
enqueue(e);
notEmpty.signal(); // 只唤醒一个消费者(无惊群)
} finally {
lock.unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
与原始 wait/notify 的核心差异:
| 维度 | wait/notify | BlockingQueue |
|---|---|---|
| 等待条件 | 单一(同一对象) | 多条件(notFull/notEmpty 分离) |
| 唤醒精度 | notifyAll 惊群 | signal 精准唤醒 |
| 异常处理 | 手动 | 内置 InterruptedException |
| 容量管理 | 自己写 | 内置 |
| 出错风险 | 高(while/notifyAll 易漏) | 低(API 友好) |
JDK 提供的多种 BlockingQueue:
| 实现 | 容量 | 内部结构 | 适用 |
|---|---|---|---|
| ArrayBlockingQueue | 有界 | 数组 + 单锁 | 通用首选 |
| LinkedBlockingQueue | 可选有界 | 链表 + 双锁(put/take 不互斥) | 高并发 |
| SynchronousQueue | 0 | 直接交付 | Executors.newCachedThreadPool 内部用 |
| PriorityBlockingQueue | 无界 | 优先堆 | 任务有优先级 |
| DelayQueue | 无界 | PQ + 时间轮 | 定时任务 |
所以:99% 的生产场景,直接用 BlockingQueue,不要自己写 wait/notify。Doug Lea 已经把 20 年并发坑都填好了。
# 3.4 Disruptor 极致版
如果连 BlockingQueue 都嫌慢——LMAX Disruptor,金融业最快的环形队列,单线程 600 万消息/秒。
它快在哪?
传统队列: Disruptor:
┌─────┐ ┌─────┬─────┬─────┬─────┐
│锁 │ │ slot│ slot│ slot│ slot│ ← 预分配,无 GC
├─────┤ └──↑──┴──↑──┴──↑──┴──↑──┘
│节点*│ ← new+gc 生产者 cursor 消费者 cursor
└─────┘ (CAS 推进,无锁)
每次 put 都 new
每次锁竞争内核切换
2
3
4
5
6
7
8
三大设计:
- 环形缓冲(RingBuffer):预分配槽位,不 new 不 GC——避免了对象分配 + GC 暂停
- 无锁 CAS:生产者用
cursor.compareAndSet推进游标——避免内核态切换 - Cache Line Padding:每个游标独占 64 字节缓存行——避免伪共享(False Sharing)
实测对比(同一硬件,1000 万消息):
| 方案 | 耗时 | 吞吐 | 内存占用 |
|---|---|---|---|
| ArrayBlockingQueue | 4.2 s | 240 万/秒 | 高(每条 new) |
| LinkedBlockingQueue | 3.8 s | 260 万/秒 | 高 |
| Disruptor | 1.7 s | 588 万/秒 | 极低(预分配) |
Disruptor 用在哪:LMAX 交易所、Log4j 2 异步日志、Storm(早期)、各大金融柜面系统。
所以:生产者-消费者从 wait/notify 一路演化到 Disruptor,本质是**"如何更快地完成跨线程数据交付"**——锁→Condition→CAS→预分配+无伪共享,每一步都在去掉一层物理开销。理解这条演化链,就理解了高性能并发的全部精髓。
# 4.哲学家就餐——死锁原型
# 4.1 问题描述
Dijkstra 1965 年提出——5 位哲学家围坐圆桌,每人面前一盘意面,相邻两人之间一根筷子(共 5 根)。哲学家循环做两件事:思考、吃饭。吃饭必须同时拿到左右两根筷子。
哲学家1
筷子5 筷子1
哲学家5 哲学家2
筷子4 筷子2
筷子3
哲学家3 / 哲学家4
2
3
4
5
6
# 4.2 朴素实现的死锁
class Philosopher implements Runnable {
Object leftChopstick, rightChopstick;
public void run() {
while (true) {
think();
synchronized (leftChopstick) { // ① 拿左
synchronized (rightChopstick) { // ② 拿右
eat();
}
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
死锁是怎么形成的:
时刻 T0:5 位哲学家同时想吃饭,都拿起左手筷子
哲学家1 持有筷子1(待筷子2)
哲学家2 持有筷子2(待筷子3)
哲学家3 持有筷子3(待筷子4)
哲学家4 持有筷子4(待筷子5)
哲学家5 持有筷子5(待筷子1) ← 完美的环
时刻 T∞:每个人都在等右边的人放筷子,永远不会放
2
3
4
5
6
7
8
这就是死锁的四个必要条件(Coffman 条件,1971)——全部满足才会死锁:
| 条件 | 在哲学家场景中的表现 |
|---|---|
| 互斥 | 一根筷子同时只能一人持有 |
| 持有并等待 | 持有左筷子时不松手,等右筷子 |
| 不可剥夺 | 没有"强行抢走筷子"的机制 |
| 循环等待 | 1→2→3→4→5→1 形成环 |
破解死锁 = 打破任意一个条件。下一节展示三种破解方案。
# 4.3 三种破解方案
方案 A:破坏循环等待——按序加锁
给所有筷子编号,永远先拿编号小的:
public void run() {
Object first = leftChopstick.id < rightChopstick.id ? leftChopstick : rightChopstick;
Object second = leftChopstick.id < rightChopstick.id ? rightChopstick : leftChopstick;
synchronized (first) {
synchronized (second) {
eat();
}
}
}
2
3
4
5
6
7
8
9
10
为什么有效:5 个人都按"先小后大"的全局顺序加锁,绝不可能形成 1→2→3→4→5→1 的环——必然有一个人是"先拿小号筷子",等不到大号的那个人会被某个"先拿小号"的人解救。
工业用途:数据库内核就是这么干的——所有事务对多行加锁时,必须按主键升序加锁,否则就会出现 deadlock。
方案 B:破坏持有并等待——一次性拿全
public void run() {
while (true) {
if (tryLockBoth(left, right)) { // 用 tryLock 同时尝试两把
try { eat(); }
finally { unlockBoth(); }
} else {
Thread.sleep(random.nextInt(10)); // 失败就退避
}
}
}
2
3
4
5
6
7
8
9
10
实现:ReentrantLock.tryLock(timeout)——拿不到就放弃左手,等会儿再试。
优点:实现简单。缺点:可能"活锁"(livelock)——每个人都同时拿、同时放,但永远凑不齐两根。需要随机退避算法(类似以太网 CSMA/CD)。
方案 C:破坏互斥——引入中间裁判(信号量)
只允许 4 个哲学家同时尝试拿筷子(剩下 1 个强制等待):
Semaphore room = new Semaphore(4); // ← 餐厅最多 4 人同时入座
public void run() {
while (true) {
room.acquire(); // 申请入座(最多 4 人)
synchronized (leftChopstick) {
synchronized (rightChopstick) {
eat();
}
}
room.release();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
为什么有效:5 根筷子 5 人争——必然死锁;4 人争 5 根筷子——鸽巢原理保证至少有一人能同时拿到两根。
这就是 Java 中信号量(Semaphore)的经典用法:限流、池化、防死锁。Connection Pool、线程池的核心都是这个原理。
所以:哲学家就餐告诉我们——死锁不是"某个 bug",而是四个条件的必然结果。工程上要么按全局顺序加锁(最常用)、要么用 tryLock 退避、要么用信号量限制并发度——三选一即可破解。
# 4.4 Chandy-Misra 算法
上面三种方案都有缺点——按序加锁要求全局已知所有筷子,tryLock 可能活锁,信号量限流损失了 1/5 并行度。Chandy-Misra 算法(1984)是一个分布式优雅解,至今仍是分布式互斥的范式:
核心思想:给每根筷子一个**"脏"标志**——
初始化:
每根筷子都是"脏"的,分配给编号较小的那位哲学家
当哲学家 P 想吃饭:
对每根需要的筷子 c:
如果 P 已持有 c → 直接用
如果 c 在邻居 Q 手里:
P 向 Q 发"请求消息"
Q 收到请求:
如果 c 是脏的 → 擦干净 → 给 P
如果 c 是干净的 → 留着自己用,等吃完再给
吃完一顿:
所有筷子都变"脏"
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么不死锁?
脏/干净标志破坏了"持有并等待"中"持续持有"的部分:
- 持有"干净"筷子 → 我正要用 → 不让
- 持有"脏"筷子 → 别人请求时必须给
可证明:脏筷子不会形成等待环(数学上是无环图)
2
3
4
5
Chandy-Misra 的工程价值:它不需要全局编号——每个节点只需要和邻居通信。这就是为什么它成为分布式系统互斥协议的基础——Zookeeper 的分布式锁、Chubby、etcd 的 leader election 都汲取了它的思想。
# 4.5 真实工程映射
哲学家就餐看着抽象,真实系统中每天都在上演:
| 工程场景 | 哲学家映射 | 经典死锁 |
|---|---|---|
| MySQL 行锁 | 事务=哲学家,行=筷子 | 两个事务交叉更新 A、B 行 |
| Redis 分布式锁 | 服务=哲学家,资源=筷子 | 微服务 A 持锁 X 等锁 Y,B 持 Y 等 X |
| JVM 多 monitor | 线程=哲学家,对象锁=筷子 | jstack 报 Found Java-level deadlock |
| 数据库连接池 | 业务=哲学家,连接=筷子 | 业务 A 持连接 1 又申请连接 2 |
| 读写文件锁 | 进程=哲学家,文件=筷子 | 两进程交叉打开同一组文件 |
真实生产事故(某电商,2021):
-- 事务 A
UPDATE balance SET amt=amt-100 WHERE uid=1; -- 锁住 row1
UPDATE balance SET amt=amt+100 WHERE uid=2; -- 等 row2
-- 事务 B(并发)
UPDATE balance SET amt=amt-50 WHERE uid=2; -- 锁住 row2
UPDATE balance SET amt=amt+50 WHERE uid=1; -- 等 row1
-- 完美的循环等待 → MySQL 报 Deadlock found, retry
2
3
4
5
6
7
8
9
修复方案:业务规定所有多行更新必须按 uid 升序——这就是 4.3 节"按序加锁"在数据库的应用。这一行规约能消除 99% 的事务死锁。
所以:哲学家就餐不只是面试题——它是所有"多资源协调"问题的元模型。每次你看到死锁报错,都可以问自己一句话:"这是哪些'哲学家'在抢哪些'筷子'?" 答案出来,破解方案就出来了。
# 5.读者-写者——读多写少原型
# 5.1 问题描述
数据库、配置中心、缓存——典型的读多写少:99% 的请求是读,1% 是写。如果每次读都加互斥锁,性能极低。理想方案:
读读不互斥(多个读者可同时读)
读写互斥 (写时不能读)
写写互斥 (只能一个写者)
2
3
# 5.2 ReadWriteLock 实现
ReadWriteLock rwLock = new ReentrantReadWriteLock();
public Object get(String key) {
rwLock.readLock().lock(); // 读锁可共享
try {
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
public void put(String key, Object val) {
rwLock.writeLock().lock(); // 写锁独占
try {
cache.put(key, val);
} finally {
rwLock.writeLock().unlock();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
性能实测(10 线程,95% 读 / 5% 写):
| 方案 | 吞吐量 | 读延迟 |
|---|---|---|
| synchronized | 800 万/秒 | 高 |
| ReentrantReadWriteLock | 2400 万/秒 | 中 |
| StampedLock 乐观读 | 4500 万/秒 | 极低 |
关键陷阱——写者饥饿:默认非公平模式下,源源不断的读者会让写者永远拿不到锁。配置 new ReentrantReadWriteLock(true) 公平模式可缓解,但牺牲吞吐。
# 5.3 写者饥饿深度剖析
写者饥饿不是理论问题——它在生产环境是真实的、致命的事故来源。
真实案例(某互联网公司配置中心,2019):
// 配置中心:99% 读,1% 写
ReadWriteLock rwLock = new ReentrantReadWriteLock(); // 默认非公平
public Config getConfig() {
rwLock.readLock().lock();
try { return config; }
finally { rwLock.readLock().unlock(); }
}
public void updateConfig(Config newConfig) {
rwLock.writeLock().lock(); // ← 大流量下永远拿不到
try { config = newConfig; }
finally { rwLock.writeLock().unlock(); }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
事故现场:促销期 QPS 暴涨 10 倍,配置中心写线程被卡 4 小时——4 小时内所有新发布的限流配置无法生效,最终触发雪崩。jstack 显示:
"config-write-1" #45 WAITING (parking)
at sun.misc.Unsafe.park
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire
- waiting to acquire lock <0x7f...>
(等待队列前面有 4823 个读线程持续来)
2
3
4
5
6
问题根源——非公平 ReadWriteLock 的优先策略:
读锁请求到达:
当前是读锁状态? → 直接共享(即使有写者在等) ← 写者饥饿源头
当前是写锁状态? → 进入等待队列
写锁请求到达:
当前无锁? → 立即获取
当前有读? → 进入等待队列
当前有写? → 进入等待队列
2
3
4
5
6
7
8
关键缺陷:当一个读锁持有者还没释放时,新来的读者会"插队"加入读锁——形成"读者持续涌入"的链式结构,写者永远在队尾。
三个修复方案对比:
| 方案 | 实现 | 优点 | 代价 |
|---|---|---|---|
| 公平模式 | new ReentrantReadWriteLock(true) | 严格 FIFO,无饥饿 | 吞吐降低 30~50% |
| 写优先策略 | 自实现:写者等待时拒绝新读者 | 写不饥饿 | 实现复杂 |
| StampedLock 乐观读 | JDK 8+ | 读不阻塞写,写不饥饿 | API 复杂 |
| CopyOnWrite | 写时复制 | 读零阻塞 | 写慢,内存翻倍 |
该公司最终的修复:切换到 StampedLock + 配置版本号——读者用乐观读零阻塞,写者直接拿独占锁。
# 5.4 StampedLock 乐观读
JDK 8 引入的"读多写少终极方案":
StampedLock sl = new StampedLock();
public Point read() {
long stamp = sl.tryOptimisticRead(); // ← 不加锁,只拿版本号
double x = this.x, y = this.y; // 读字段
if (!sl.validate(stamp)) { // 验证:期间没人改过
// 有人改过,回退到悲观读
stamp = sl.readLock();
try { x = this.x; y = this.y; }
finally { sl.unlockRead(stamp); }
}
return new Point(x, y);
}
2
3
4
5
6
7
8
9
10
11
12
13
核心思想——乐观读完全不上锁,读完检查版本号——读期间没人改就拿走,有人改就回退到悲观读。
99% 读的场景,乐观读几乎零开销——这就是为什么 StampedLock 在配置中心、本地缓存里大放异彩。
StampedLock 的三个关键陷阱:
| 陷阱 | 现象 | 解法 |
|---|---|---|
| 不可重入 | 同一线程二次 readLock → 死锁 | 自己保证不重入 |
| 不响应中断 | interrupt() 无效 | 用 readLockInterruptibly |
| 乐观读读到半改值 | 读了 x 但没读 y 时 x 变了 | 必须 validate(stamp) 后用 |
# 5.5 CopyOnWrite 终极读优化
读多写极少的场景(如订阅者列表、白名单),CopyOnWrite 把读做到了极致:
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
// 读:完全无锁,直接遍历
for (Listener l : listeners) {
l.onEvent(event);
}
// 写:复制整个数组 + CAS 替换引用
listeners.add(newListener);
2
3
4
5
6
7
8
9
JDK 实现(简化版):
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1); // ← 复制整个数组
newElements[len] = e;
setArray(newElements); // ← 原子替换引用
return true;
} finally {
lock.unlock();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
适用场景速查:
| 场景 | 是否用 CopyOnWrite |
|---|---|
| 监听器列表(注册一次,触发多次) | ✅ 完美匹配 |
| 配置项白名单(启动加载,运行期不变) | ✅ 完美匹配 |
| 高频写入的集合 | ❌ 内存爆炸 |
| 大数据量集合(10 万+) | ❌ 复制成本太高 |
所以:读者-写者问题揭示了一条铁律——读多写少时,不要用对称的互斥锁。读读共享、写写互斥、读乐观、写悲观,是这类场景的最优解;如果读到了"99% 读 + 极少写 + 集合不大",CopyOnWrite 是终极方案。
# 6.睡眠的理发师——信号量原型
# 6.1 问题描述
Dijkstra 1965 年提出(同年还提出哲学家就餐——并发界的双子星):理发店有 1 把椅子(理发位)+ N 把候座椅。
顾客来 → 看候座椅满没满
├── 满 → 离开
└── 没满 → 坐下等
├── 理发师在睡 → 叫醒
└── 理发师在剪 → 等
理发师 → 看候座椅有没有人
├── 没人 → 睡觉
└── 有人 → 叫一个来理发
2
3
4
5
6
7
8
9
这个问题的核心:多生产者(顾客)+ 单消费者(理发师)+ 有限缓冲(候座椅)——本质和生产消费同构,但加了容量上限。
# 6.2 信号量解法
Semaphore(信号量)是 1965 年 Dijkstra 提出的同步原语——本质是一个带阻塞语义的计数器:
public class BarberShop {
private final Semaphore customers = new Semaphore(0); // 等待的顾客数
private final Semaphore barber = new Semaphore(0); // 理发师就绪信号
private final Semaphore mutex = new Semaphore(1); // 互斥保护 waiting
private final Semaphore seats = new Semaphore(N); // 候座椅数(关键)
private int waiting = 0;
// 顾客线程
public void customerArrive() throws InterruptedException {
if (!seats.tryAcquire()) {
return; // 候座椅满 → 直接离开
}
mutex.acquire();
waiting++;
customers.release(); // 叫醒/通知理发师
mutex.release();
barber.acquire(); // 等理发师就绪
seats.release(); // 让出候座椅
getHairCut();
}
// 理发师线程
public void barberWork() throws InterruptedException {
while (true) {
customers.acquire(); // 没人就睡
mutex.acquire();
waiting--;
barber.release(); // 通知顾客可以理发了
mutex.release();
cutHair();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
信号量的三种语义:
binary semaphore (0/1):等同于互斥锁(mutex)
counting semaphore (N):限制并发度上限
zero semaphore (0):纯通知机制(无资源)
2
3
# 6.3 工程映射:连接池/限流
理发师问题的真实工程身份就是"资源池"——所有"有限资源 + 多并发请求"的场景都是它:
应用一:数据库连接池
class ConnectionPool {
private final Semaphore available; // ← N = 池容量(候座椅)
private final Queue<Connection> pool;
public ConnectionPool(int size) {
this.available = new Semaphore(size);
// ... 初始化 N 个连接 ...
}
public Connection acquire(long timeout) throws InterruptedException {
if (!available.tryAcquire(timeout, TimeUnit.MILLISECONDS)) {
throw new TimeoutException("连接池耗尽"); // 候座椅满,离开
}
return pool.poll();
}
public void release(Connection c) {
pool.offer(c);
available.release(); // 让出"候座椅"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
HikariCP、Druid 等连接池的核心算法就是这个——只是加了空闲连接清理、健康检查等增强。
应用二:限流器(RateLimiter)
class SimpleRateLimiter {
private final Semaphore permits;
public SimpleRateLimiter(int permitsPerSecond) {
this.permits = new Semaphore(permitsPerSecond);
// 每秒补充 N 个许可
scheduler.scheduleAtFixedRate(
() -> permits.release(permitsPerSecond),
1, 1, TimeUnit.SECONDS
);
}
public boolean tryAcquire() {
return permits.tryAcquire();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Sentinel、Guava RateLimiter 的基础就是信号量——只是补充策略更精巧(令牌桶、漏桶)。
应用三:并发度限制
// 同时最多 10 个文件并发上传
Semaphore uploadSlots = new Semaphore(10);
public void upload(File f) throws InterruptedException {
uploadSlots.acquire();
try {
doUpload(f);
} finally {
uploadSlots.release();
}
}
2
3
4
5
6
7
8
9
10
11
所以:理发师问题就是信号量这把瑞士军刀的入门示例——掌握了信号量,连接池、限流器、并发度控制都不再是工程难题。它和哲学家就餐合起来构成 Dijkstra 留给并发世界的两大基石。
# 7.跨语言并发对比
# 7.1 Java 共享内存派
Java 的并发哲学:线程共享堆,靠锁/原子保护一致性。
// 标志位
volatile boolean running = true;
// 计数
AtomicLong counter = new AtomicLong();
// 队列
BlockingQueue<Task> q = new LinkedBlockingQueue<>();
// 异步
CompletableFuture<String> f = CompletableFuture.supplyAsync(this::fetch);
2
3
4
5
6
7
8
9
10
11
优势:工具丰富、生态完整、文档详尽。代价:锁/可见性/有序性需要程序员心中有图,写错就是数据损坏。
# 7.2 Go 消息传递派
Go 的并发哲学:Don't communicate by sharing memory; share memory by communicating——用 channel 传消息,而不是共享变量。
// 生产者-消费者
ch := make(chan int, 10)
go func() {
for i := 0; i < 100; i++ {
ch <- i // 发送(满了自动阻塞)
}
close(ch)
}()
for v := range ch { // 接收(空了自动阻塞)
fmt.Println(v)
}
// select 多路选择
select {
case msg := <-ch1:
handle(msg)
case <-time.After(1*time.Second):
timeout()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
优势:心智负担低、不会死锁(绝大多数情况)、代码可读。代价:channel 内部依然有锁,只是封装在 runtime 里;高吞吐场景比 Java 略慢。
# 7.3 C++ 显式控制派
C++ 的并发哲学:性能至上,给程序员所有控制权。
// 互斥锁
std::mutex m;
std::lock_guard<std::mutex> lg(m); // RAII 自动 unlock
// 条件变量
std::condition_variable cv;
cv.wait(lock, []{ return !queue.empty(); });
// 原子变量(6 种内存序)
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed); // 最弱
counter.fetch_add(1, std::memory_order_seq_cst); // 最强(默认)
// 线程
std::thread t([]{ work(); });
t.join();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
特色:memory_order 让程序员显式选择内存屏障强度——relaxed/acquire/release/seq_cst——榨干硬件性能。这是 Java 永远做不到的细粒度控制。
# 7.4 Kotlin 协程派
Kotlin 用协程 + Channel + 结构化并发重新定义了 JVM 并发:
// 生产者-消费者
val ch = Channel<Int>(capacity = 10)
launch {
repeat(100) { ch.send(it) } // 满了挂起协程,不阻塞线程
ch.close()
}
launch {
for (v in ch) {
println(v)
}
}
// select 多路选择
select<Unit> {
ch1.onReceive { handle(it) }
onTimeout(1000) { timeout() }
}
// 结构化并发:父协程结束 → 自动取消所有子协程
coroutineScope {
launch { taskA() }
launch { taskB() }
} // 这里保证 A、B 都结束才返回
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
核心优势:
- 轻量:百万协程占用几十 MB;Java 线程同等数量 = 几十 GB
- 可挂起非阻塞:
channel.send满了挂起协程,不占线程 - 结构化:父子作用域绑定生命周期,消除野线程
- 异常自动汇集:子协程异常自动传给父——见 13 章
代价:Kotlin 专属,跨语言迁移困难;调试栈跟踪比线程复杂。
# 7.5 Erlang/Rust 视角补遗
Erlang——Actor 模型的祖师:
% 进程 = Actor,每个有独立邮箱
Pid = spawn(fun() -> loop(0) end),
% 发消息(异步)
Pid ! {add, 5},
% 接收
loop(State) ->
receive
{add, N} -> loop(State + N);
{get, From} -> From ! State, loop(State)
end.
2
3
4
5
6
7
8
9
10
11
12
Erlang 的设计哲学:不共享内存——一切皆消息。每个 Actor 完全隔离,crash 不传染("let it crash"),监督树自动重启。WhatsApp 用 Erlang 做到单服务器 200 万并发连接——这是 Java 永远做不到的。
Rust——零成本并发的极致:
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
let counter = Arc::new(Mutex::new(0));
let handles: Vec<_> = (0..10).map(|_| {
let c = Arc::clone(&counter);
thread::spawn(move || {
let mut num = c.lock().unwrap();
*num += 1;
})
}).collect();
for h in handles { h.join().unwrap(); }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Rust 的革命:编译期消除数据竞争——Send / Sync trait 让"线程安全"从运行时检查变成编译期保证。写不出错的并发代码,是 Rust 给后端工程的最大礼物。
# 7.6 横向对比总表
| 维度 | Java | C++ | Go | Python | Kotlin | Erlang | Rust |
|---|---|---|---|---|---|---|---|
| 并发模型 | 共享内存 + 锁 | 共享内存 + 锁 | CSP(channel) | GIL(伪并发) | 协程 + Channel | Actor(隔离) | 共享内存 + 编译保护 |
| 线程模型 | 1:1 / 虚拟线程 | 1:1 | M:N (GMP) | 1:1 | 协程在线程池上调度 | M:N(BEAM) | 1:1 |
| 同步原语 | synchronized/Lock/Atomic | mutex/atomic/memory_order | sync.Mutex/channel | threading.Lock | Mutex/Channel | 消息发送 | Mutex/Arc/Atomic |
| 生产消费 | BlockingQueue | std::queue + cv | chan | queue.Queue | Channel | 邮箱(自带) | mpsc::channel |
| 死锁风险 | 易(编程错误) | 易 | 罕见 | 易 | 罕见 | ❌ 不可能 | 编译期捕获大半 |
| 数据竞争 | 运行时炸 | UB(最危险) | -race 检测 | GIL 保护 | 同 Java | ❌ 无共享内存 | 编译期禁止 |
| 吞吐 QPS | 高 | 最高 | 高 | 极低(GIL) | 高 | 中(消息开销) | 最高(与 C++ 持平) |
| 学习曲线 | 中 | 高(最复杂) | 低(最易学) | 低 | 中 | 高(思维换) | 极高 |
| 适合场景 | 企业服务 | 高频交易、引擎 | 微服务、网关 | 数据科学 | Android 协程 | 电信、IM | 系统软件、嵌入式 |
所以:没有最好的并发模型,只有最匹配的范式。Java 共享内存适合复杂业务、Go channel 适合微服务编排、C++ atomic 适合极致性能、Erlang Actor 适合超高连接、Rust 适合零容忍数据竞争。理解差异,比死磕一种更重要——因为现代后端工程师早晚要在多种语言间穿梭。
# 8.死锁与活锁的工程级剖析
# 8.1 死锁的四种形态
生产环境中,死锁绝非教科书上"两线程互锁"那么简单——它有四种形态:
flowchart TB
A[死锁四种形态] --> B[形态1<br/>双向死锁]
A --> C[形态2<br/>多路环锁]
A --> D[形态3<br/>嵌套递归死锁]
A --> E[形态4<br/>资源耗尽死锁]
B --> B1[A 持锁1 等锁2<br/>B 持锁2 等锁1<br/>最常见]
C --> C1[3+ 线程形成大环<br/>jstack 难定位]
D --> D1[非可重入锁<br/>同线程二次进入]
E --> E1[线程池满了<br/>提交的子任务等池]
style B1 fill:#fff3cd
style C1 fill:#fff3cd
style D1 fill:#fff3cd
style E1 fill:#f8d7da
2
3
4
5
6
7
8
9
10
11
12
13
14
15
形态 4 最隐蔽——线程池"自吃"死锁:
ExecutorService pool = Executors.newFixedThreadPool(2); // 仅 2 个线程
Future<String> result = pool.submit(() -> {
Future<Integer> sub = pool.submit(() -> compute()); // 提交子任务
return "result=" + sub.get(); // 等子任务完成
});
// 当 2 个线程都被父任务占用,子任务永远等不到执行 → 死锁
2
3
4
5
6
7
这就是为什么 Spring 异步任务、ForkJoin 等框架严禁同池嵌套提交——必须用独立池或 RecursiveTask。
# 8.2 真实生产事故复盘
事故一:MySQL 行锁交叉死锁(2021 某电商)
-- 事务 A
BEGIN;
UPDATE orders SET status=1 WHERE id=100; -- 锁住 row 100
UPDATE inventory SET qty=qty-1 WHERE pid=200; -- 等 row 200
-- 事务 B(并发)
BEGIN;
UPDATE inventory SET qty=qty-1 WHERE pid=200; -- 锁住 row 200
UPDATE orders SET status=1 WHERE id=100; -- 等 row 100
2
3
4
5
6
7
8
9
MySQL 报错:Deadlock found when trying to get lock; try restarting transaction
修复:所有"订单+库存"事务强制按 orders → inventory 顺序更新——这就是 4.3 节"按序加锁"在数据库的应用。
事故二:分布式锁互等(2020 某金融)
微服务 A:申请 Redis 锁 X 成功 → 调用微服务 B(B 内部要锁 Y)
微服务 B:Y 被另一个流程持有,那个流程正在调 A 申请锁 X
→ X 等 Y,Y 等 X → 跨服务死锁
2
3
4
修复:分布式锁也要全局编号——所有服务约定锁顺序。但更根本的修复是取消跨服务持锁调用——业务重构成异步消息驱动。
事故三:ReentrantLock 异常未释放(2019 某 SaaS)
lock.lock();
processBusiness(); // ← 这里 NPE
lock.unlock(); // ← 永远到不了
// 第二个线程到 lock.lock() 永远等
2
3
4
修复:永远用 try-finally 包裹,IDE 静态扫描标记为 P0 错误。
# 8.3 活锁与饥饿
活锁(Livelock):线程没死,但永远在"做无用功"——
// 经典活锁:两人过窄桥,都"礼让"对方
class PoliteThread extends Thread {
public void run() {
while (true) {
if (otherThread.isWaiting()) {
yieldToOther(); // 让对方先走
continue; // 但对方也在让
}
cross();
}
}
}
// → 双方永远在 yield,谁也过不去
2
3
4
5
6
7
8
9
10
11
12
13
真实工程案例:某分布式系统两节点都用"退避重试"——发现冲突就退避一段时间——但两边退避时间相同,永远在同一时刻重试、同一时刻冲突。
解法:随机化退避时间(Exponential Backoff with Jitter)——CSMA/CD、TCP 重传、Kafka 重试都用这套机制。
饥饿(Starvation):低优先级线程永远拿不到资源——
非公平 ReentrantLock + 高频持锁线程 → 等待队列后端线程永远饿死
volatile 自旋锁 + 一直竞争失败的线程 → CPU 烧光但拿不到锁
2
死锁 vs 活锁 vs 饥饿对比表:
| 现象 | 线程状态 | CPU 占用 | jstack 表现 | 解法 |
|---|---|---|---|---|
| 死锁 | BLOCKED / WAITING | 0% | "Found Java-level deadlock" | 按序加锁 |
| 活锁 | RUNNABLE | 100% | 看似在干活 | 随机退避 |
| 饥饿 | RUNNABLE / WAITING | 不定 | 长期 WAITING | 公平锁 / 优先级 |
# 8.4 死锁检测与预防
主动检测——JDK 内建:
ThreadMXBean tmx = ManagementFactory.getThreadMXBean();
long[] ids = tmx.findDeadlockedThreads();
if (ids != null) {
ThreadInfo[] infos = tmx.getThreadInfo(ids);
for (ThreadInfo info : infos) {
System.err.println(info); // 打印死锁链
}
}
2
3
4
5
6
7
8
生产环境兜底——定时 watchdog:
ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor();
watchdog.scheduleAtFixedRate(() -> {
long[] deadIds = ManagementFactory.getThreadMXBean()
.findDeadlockedThreads();
if (deadIds != null) {
alertSystem.sendCritical("DEADLOCK detected!");
dumpAllThreads();
}
}, 30, 30, TimeUnit.SECONDS);
2
3
4
5
6
7
8
9
预防的工程纪律:
- 🔹 加锁顺序文档化:团队 wiki 写明"锁 A 永远在锁 B 前面"
- 🔹 静态扫描:用 SpotBugs / CheckThread 扫"未释放锁"
- 🔹 超时获取:所有锁尽量用
tryLock(timeout),不要无限等待 - 🔹 缩小临界区:临界区越小,撞锁概率越低
- 🔹 避免嵌套加锁:能用一把锁解决的不要用两把
- 🔹 不在锁内调外部代码:临界区不调用回调、远程接口、未知第三方代码
所以:死锁是架构问题,不是"加锁的小 bug"——它的真正修复几乎都在"调整加锁顺序"或"重构资源依赖",而非"换个锁实现"。Coffman 四条件就是体检报告,每个生产死锁都能用它精确诊断。
# 9.经典陷阱与调试方法论
# 9.1 ABA 问题真实事故
第 2.3 节提到 ABA 是 CAS 的著名陷阱——它不是理论问题,是真实事故源。
事故现场(某无锁队列实现):
public Node pop() {
while (true) {
Node head = top.get();
if (head == null) return null;
Node next = head.next;
if (top.compareAndSet(head, next)) { // ← ABA 陷阱
return head;
}
}
}
2
3
4
5
6
7
8
9
10
ABA 是怎么发生的:
时刻 T0:栈 = [A, B, C],线程 1 读 head=A, next=B
时刻 T1:线程 1 被切走
时刻 T2:线程 2 pop → 栈 = [B, C]
时刻 T3:线程 2 pop → 栈 = [C]
时刻 T4:线程 2 push A(!!)→ 栈 = [A, C]
时刻 T5:线程 1 恢复,CAS(head, A, B) 成功!
→ 但 B 已经不在栈中,栈被破坏成 [B, ???]
2
3
4
5
6
7
修复——AtomicStampedReference 加版本号:
AtomicStampedReference<Node> top = new AtomicStampedReference<>(null, 0);
public Node pop() {
while (true) {
int[] stampHolder = new int[1];
Node head = top.get(stampHolder);
if (head == null) return null;
if (top.compareAndSet(head, head.next,
stampHolder[0], stampHolder[0] + 1)) {
return head;
}
}
}
// 即使值回到 A,stamp 已经变了 → CAS 失败
2
3
4
5
6
7
8
9
10
11
12
13
14
# 9.2 伪共享:被忽视的隐形杀手
两个 volatile 字段在内存上挨得太近,落在同一缓存行(cache line,通常 64 字节)——它们之间会产生伪共享(False Sharing):
class Counter {
public volatile long a; // ← CPU 1 频繁修改
public volatile long b; // ← CPU 2 频繁修改
// a 和 b 在同一个 cache line 里
}
// 现象:CPU 1 改 a → 让 CPU 2 的 cache line 失效
// CPU 2 改 b → 让 CPU 1 的 cache line 失效
// → 两核来回 invalidate cache,性能暴跌 5-10×
2
3
4
5
6
7
8
实测数据(Apple M1 Pro,两线程各自自增 1 亿次):
| 实现 | 耗时 |
|---|---|
| 无填充(伪共享) | 4.2 s |
@Contended 填充 | 0.6 s |
| 加速比 | 7× |
修复 1——手动填充:
class Counter {
public volatile long a;
public long p1, p2, p3, p4, p5, p6, p7; // 56 字节填充
public volatile long b;
}
2
3
4
5
修复 2——@Contended 注解(JDK 8+):
class Counter {
@Contended public volatile long a;
@Contended public volatile long b;
}
// 启动加 -XX:-RestrictContended
2
3
4
5
JDK 内部用了多少 @Contended:Thread、ConcurrentHashMap.CounterCell、Striped64、LongAdder 全部带——这些类为高并发而生。LongAdder 比 AtomicLong 快几倍的核心机制就是 @Contended。
# 9.3 双重检查锁定的演化
DCL 单例是个**典型的"看似正确实则错误"**的代码模式——它的演化史就是 Java 内存模型演化史:
// V1:错误版本(90% 教程都这样写)
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // ← 重排序导致 NPE
}
}
}
return instance;
}
2
3
4
5
6
7
8
9
10
11
为什么错:new Singleton() 三步——分配内存 → 初始化对象 → 引用赋值。没有 volatile,编译器/CPU 可能重排为:分配内存 → 引用赋值 → 初始化对象。线程 B 看到 instance 不为 null 直接用,但对象还没初始化完。
// V2:正确版本(Java 5+ volatile 保证)
private static volatile Singleton instance;
2
// V3:终极优化——静态内部类 Holder(推荐)
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
2
3
4
5
6
7
8
9
10
11
12
Holder 模式为什么最优:JVM 类加载机制保证 Holder 类的静态字段初始化是线程安全 + 懒加载——比 DCL 简单 10 倍,零陷阱。
# 9.4 锁粒度的反直觉权衡
"锁越细越好" 是个错误的工程直觉——锁切得太细会带来隐藏成本:
// 方案 A:粗锁
synchronized (this) {
map.put(k, v);
list.add(v);
counter++;
}
// 总开销:1 次 lock + 1 次 unlock
// 方案 B:细锁
synchronized (mapLock) { map.put(k, v); }
synchronized (listLock) { list.add(v); }
synchronized (counterLock) { counter++; }
// 总开销:3 次 lock + 3 次 unlock + 中间可能被打断(一致性破坏)
2
3
4
5
6
7
8
9
10
11
12
13
何时用粗锁、何时用细锁:
| 场景 | 选择 | 理由 |
|---|---|---|
| 多操作必须原子 | 粗锁 | 细锁破坏一致性 |
| 操作完全独立 | 细锁 | 细锁提升并发度 |
| 临界区极短 | 粗锁 | 锁开销 > 临界区开销 |
| 临界区有 IO | 细锁 | 减少其他线程等待 |
| 不确定 | 先粗后细 | 性能不行再切,避免过度设计 |
ConcurrentHashMap 的演进就是粗锁→细锁的教科书:
JDK 1.7:分段锁(Segment)—— 16 把锁 —— 适度细化
JDK 1.8:CAS + synchronized 单桶 —— 桶级别细化
JDK 11+:增加红黑树 + LongAdder 计数 —— 极致细化
2
3
但普通业务代码 99% 用 synchronized 已经够了——别盲目模仿 JDK 的细粒度,那是为高并发场景定制的。
# 9.5 调试方法论
面对线上并发问题的诊断顺序:
flowchart TD
A[现象异常] --> B{CPU 100%?}
B -->|是| B1[活锁/死循环<br/>jstack 看 RUNNABLE 高的栈]
B -->|否| C{响应卡死?}
C -->|是| C1[死锁/长阻塞<br/>jstack 看 BLOCKED]
C -->|否| D{结果错误?}
D -->|是| D1[数据竞争<br/>看 volatile / 同步是否完整]
D -->|否| E{偶发问题?}
E -->|是| E1[内存可见性<br/>检查 happen-before 关系]
style B1 fill:#fff3cd
style C1 fill:#f8d7da
style D1 fill:#fff3cd
style E1 fill:#fff3cd
2
3
4
5
6
7
8
9
10
11
12
13
14
核心工具速查:
| 工具 | 用途 | 关键命令 |
|---|---|---|
| jstack | 线程栈快照 | jstack <pid> |
| jconsole | 实时监控 + 死锁检测 | GUI |
| JFR (Java Flight Recorder) | 生产环境性能录制 | jcmd <pid> JFR.start duration=60s |
| arthas | 在线诊断神器 | thread -b(找阻塞线程)/ monitor |
| JOL (Java Object Layout) | 看对象布局 / 伪共享 | ClassLayout.parseClass(...).toPrintable() |
| -XX:+PrintCompilation | JIT 编译信息 | 启动参数 |
| -Xlog:safepoint | safepoint 暂停 | 启动参数 |
| JMH | 微基准测试 | 必备工具 |
三个调试黄金法则:
- 先看现象,再看代码——CPU 100% 还是 0%,response 卡还是错误,决定了完全不同的诊断路径
- jstack 三连——间隔 1 秒抓 3 次栈,看哪些线程长期处于同一栈帧(必是阻塞或死循环点)
- 不要相信"偶发问题"——并发 bug 永远是必然的,只是触发概率低;用 JMH/Stress test 提高并发度即可重现
实战调试模板:
# 步骤 1:抓 3 次栈
for i in {1..3}; do jstack <pid> > stack_$i.txt; sleep 1; done
# 步骤 2:找阻塞链
grep -A 5 "BLOCKED\|deadlock" stack_*.txt
# 步骤 3:找等锁的对象
grep "waiting to lock\|parking to wait" stack_*.txt | sort | uniq -c | sort -rn
# 步骤 4:JFR 长期录制(看锁竞争热点)
jcmd <pid> JFR.start name=lock duration=60s filename=lock.jfr settings=profile
# 用 JMC (Java Mission Control) 打开 lock.jfr,看 Java Monitor Blocked 事件
2
3
4
5
6
7
8
9
10
11
12
所以:并发调试不是玄学——有方法论、有工具链、有套路。掌握 jstack + JFR + arthas 三件套,90% 的线上并发问题都能在 30 分钟内定位。
# 10.一句话总结
并发编程没有"银弹"——电影票超卖、生产消费阻塞、哲学家死锁、读者写者饥饿,每一个经典案例都在用不同的角度告诉你:共享 = 风险,协调 = 代价;要么减少共享,要么用对工具。
# 三个层次的认知升华
第一层(机制层):所有并发问题都是"原子性 / 可见性 / 有序性"三大问题的不同投影
- 超卖 → 原子性破坏(
num--不是一条指令) - 旧值读取 → 可见性问题(CPU 缓存未刷主存)
- DCL 单例 NPE → 有序性问题(JIT 重排了 new 的三步)
- synchronized 一刀切(同时解决三个),volatile 解决后两个,CAS 解决第一个,volatile + CAS = 锁的 95% 能力但快 4 倍
第二层(设计层):经典并发模型是"协调机制"的元模式
- 生产消费 = 解耦 + 缓冲(适用:90% 的异步场景)
- 哲学家就餐 = 资源死锁 + 破环算法(适用:分布式事务、数据库锁管理)
- 读者写者 = 对称放宽(适用:缓存、配置中心、共享集合)
- 理发师 = 信号量限流(适用:连接池、限流器)
- 掌握这四个原型,你就掌握了 90% 的并发架构
第三层(哲学层):好的并发设计 = 减少共享 + 选对工具
- "Don't communicate by sharing memory; share memory by communicating"(Go 哲学)—— 把数据流向显式化
- 不可变对象(String/Integer/record)天然线程安全——这是终极武器
- ThreadLocal 把"共享"变成"线程私有"——零同步代价
- 真正的高手写并发代码,一半时间在思考"如何让线程间不需要共享",而不是"如何加锁更快"
# 终极建议
| 场景 | 推荐方案 | 雷区 |
|---|---|---|
| 单变量计数 | LongAdder > AtomicLong >> synchronized | volatile(不保原子) |
| 复合业务逻辑 | synchronized 简单首选 | 多锁嵌套不规范导致死锁 |
| 生产消费 | BlockingQueue / Disruptor | 自己写 wait/notify |
| 读多写少 | ReadWriteLock / StampedLock | synchronized(吞吐损失 3×) |
| 限流 / 池化 | Semaphore | 自己 count + synchronized |
| 多锁场景 | 全局编号按序加锁 | 各自加锁导致循环等待 |
| 跨服务并发 | MQ / Kafka 消息传递 | 共享 DB 表 + 行锁 |
| 调试卡死 | 先 jstack 看死锁链 | 盲目重启 |
最后一句:新手用 synchronized 解决一切,进阶用 J.U.C 工具集,高手用不可变 + 消息传递让锁消失——这就是并发编程的修行三阶。
# 延伸阅读
- ← 11.线程前世今生探索:理解线程才能理解并发
- ← 12.线程通信设计思想:通信原语的全景
- ← 13.线程异常设计原理:并发场景的异常陷阱
- → 15.并发编程设计思想:从案例上升到方法论
- → 18.锁核心设计和思想:锁的内部实现
- → 25.线程池的设计思想:线程池如何榨干并发
# 9.工业级实战延伸
前面 8 章把经典案例讲透了。这一章换个角度——把每个案例放到真实生产环境,看看在工业代码里它们长什么样。
# 9.1 案例:阿里LongAdder在双11应用
场景:阿里大促期间,单台机器每秒上千万次商品浏览统计。最初代码:
// 第一版
volatile long pv;
void onView() { pv++; } // ❌ 不是原子
2
3
第二版:AtomicLong
AtomicLong pv = new AtomicLong();
void onView() { pv.incrementAndGet(); } // 原子,但高竞争下 CAS 风暴
2
性能数据:当 100 个线程同时打 1 亿次时——
synchronized: 78 秒
AtomicLong: 18 秒
LongAdder: 2 秒 ← 9 倍提升
2
3
LongAdder 的设计精髓:
flowchart LR
A[传统 AtomicLong] --> A1[单变量 base<br/>所有线程争抢]
A1 --> A2[CAS 失败重试<br/>高竞争退化]
B[LongAdder] --> B1[base + Cell数组]
B1 --> B2[每个线程哈希到不同 Cell]
B2 --> B3[读取时 sum 全部 Cell]
style B fill:#d4edda
2
3
4
5
6
7
8
9
核心思想:"把单点变多点"——本质是用空间换时间,用读取慢换写入快。这是高并发计数的最佳实践:写入散开到多 Cell,读取再聚合。
学到了什么:当你看到 AtomicLong 在生产中成为热点时,第一反应应该是"换 LongAdder",而不是"加锁"。
# 9.2 案例:Kafka通ISR实现最终一致
场景:Kafka 集群有 3 个副本,某次网络分区导致一个 follower 长时间不同步,怎么决定继续写还是停服?
ISR(In-Sync Replicas)机制:
flowchart LR
A[Producer 写入] --> B[Leader 副本]
B --> C{所有 ISR<br/>同步完成?}
C -->|是| D[ACK 成功]
C -->|超时| E[把落后副本踢出 ISR]
E --> D
style D fill:#d4edda
2
3
4
5
6
7
8
核心权衡:
acks=all + ISR 全员同步:CP(一致性 + 分区容错)
↓ 副本落后 → 踢出 ISR
acks=all + 缩水后的 ISR:AP(可用性 + 分区容错)
2
3
这就是 CAP 理论的工程化——不是"二选一",而是"动态切换"。当所有副本健康时拥有强一致性;当部分副本故障时降级为最终一致性。
学到了什么:生产级并发系统都不是死板的 CP 或 AP,而是根据健康状态动态调整。Kafka 的 ISR、Redis 的 Sentinel、ZooKeeper 的 ZAB 都是这个思路。
# 9.3 案例:Netflix Hystrix舱壁模式
场景:Netflix 微服务调用链——A 调 B 调 C,C 慢了导致 B 全部线程被占用,B 慢了又拖死 A,最终雪崩。
舱壁模式(Bulkhead):
flowchart TB
A[调用 A] --> B1[B 线程池<br/>20 线程]
A --> B2[C 线程池<br/>10 线程]
A --> B3[D 线程池<br/>5 线程]
B1 --> S1[B 服务]
B2 --> S2[C 服务]
B3 --> S3[D 服务]
style B1 fill:#d4edda
style B2 fill:#fff3cd
style B3 fill:#f8d7da
2
3
4
5
6
7
8
9
10
11
12
核心:为每个下游服务独立线程池——某个服务慢/挂掉只影响它专属的线程池,不会拖垮整个系统。
配套机制:
HystrixCommand<Order> cmd = new HystrixCommand<Order>(
HystrixCommandGroupKey.Factory.asKey("OrderService")) {
@Override
protected Order run() throws Exception {
return orderService.fetch(id);
}
@Override
protected Order getFallback() {
return Order.UNKNOWN; // ← 降级返回
}
};
cmd.execute(); // 1秒超时 + 熔断 + 降级 + 隔离
2
3
4
5
6
7
8
9
10
11
12
13
14
学到了什么:大型分布式系统的并发设计,不是"如何让每个调用更快",而是"如何让某个调用慢的时候不拖累其他人"。隔离、超时、熔断、降级——这是分布式时代的并发哲学。
# 9.4 案例:Redis单线程为什么快
反直觉事实:Redis 是单线程模型,但能轻松撑住 10 万 QPS。
原因分解:
flowchart LR
A[Redis 单线程的快] --> B1[内存操作<br/>无 IO 等待]
A --> B2[非阻塞 IO<br/>epoll 多路复用]
A --> B3[协议简单<br/>RESP 极轻]
A --> B4[避免锁开销<br/>零线程切换]
style B4 fill:#d4edda
2
3
4
5
6
7
核心洞察:多线程的好处来自"等 IO 时切换"。如果你的工作全是内存操作(无 IO 等待),多线程反而是负担——锁、缓存一致性、上下文切换全是开销。
Redis 6.0 的妥协:在网络 IO 部分引入多线程(解析协议),但命令执行依然是单线程的——精准地把多线程用在该用的地方。
学到了什么:"多线程一定比单线程快"是一个普遍误解。能否并行的本质是"任务之间是否独立"——Redis 的命令必须串行(保证原子性),那单线程就是最优。先理解工作负载,再选择并发模型。
# 10.并发架构演进路线图
# 10.1 单机→集群→异步→流式
flowchart LR
A[单机多线程<br/>2000s] --> B[集群分布式<br/>2010s]
B --> C[微服务异步<br/>2015s]
C --> D[流式响应式<br/>2020s]
D --> E[结构化并发<br/>2024+]
A --> A1[synchronized + JUC]
B --> B1[ZK + 分布式锁]
C --> C1[MQ + 服务发现]
D --> D1[Reactor + Flux]
E --> E1[Loom + Kotlin Flow]
style E fill:#d4edda
2
3
4
5
6
7
8
9
10
11
12
13
每代解决的核心问题:
| 时代 | 核心矛盾 | 解决方案 | 典型代表 |
|---|---|---|---|
| 单机多线程 | CPU 利用率 vs 数据一致 | 锁 + JUC | Java 5 |
| 分布式 | 单机容量 vs 跨机一致 | Paxos/Raft + ZK | Hadoop/HBase |
| 微服务 | 服务耦合 vs 业务解耦 | MQ + RPC | Spring Cloud |
| 响应式 | 资源利用 vs 编程复杂 | 背压 + 异步流 | Reactor/RxJava |
| 结构化并发 | 异步代码 vs 顺序心智 | Loom + 协程 | JDK 21+ |
# 10.2 三个永恒的反模式
不论时代怎么变,下面三个反模式始终是事故根源:
反模式 1:共享可变状态默认开放
public List<Order> orders = new ArrayList<>(); // ❌
反模式 2:不限制并发量
new Thread(() -> ...).start(); // ❌
new Thread(() -> ...).start(); // ❌
new Thread(() -> ...).start(); // ❌ ← 无上限
2
3
反模式 3:跨服务调用不设超时
Response r = httpClient.get(url); // ❌ 默认无超时
# 10.3 一句话总结
多线程并发的真正本质,是"在不可靠的硬件 + 不确定的调度 + 易变的业务"三重不确定性下,构建可预测的执行模型。 11 个经典案例只是表象,背后映射的是同一个问题——程序员的"顺序心智"和"硬件的并发现实"之间的契约如何写。从 i++ 到 Disruptor,从 synchronized 到 Loom,所有方案都是这条契约的不同版本。 真正的并发高手,不是会用更多原语的人,而是能在画完 happens-before 图之后说"这个临界区其实可以删掉"的人——让锁消失、让共享消失、让并发问题在设计阶段就不存在,这才是终极境界。