编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 性能优化实践

  • 程序编程原理

    • README
    • 序卷方法论

    • 数据的本质

    • 运行时模型

    • 并发的设计

      • README
      • 1.线程前世今生探索
      • 2.并发上下文切换原理
      • 3.线程通信设计思想
      • 4.线程异常设计原理
      • 5.多线程并发经典案例
        • 1.电影票超卖案例
          • 1.1 看似无害的代码
          • 1.2 Bug 是如何浮现的
          • 1.3 字节码层面的真相
          • 1.4 三个独立的伤口
        • 2.解决方案的四把刀
          • 2.1 synchronized 互斥锁
          • 2.2 ReentrantLock 显式锁
          • 2.3 AtomicInteger 无锁 CAS
          • 2.4 volatile 可见性
          • 2.5 四种方案的取舍
        • 3.生产者-消费者模型
          • 3.1 问题原型
          • 3.2 wait/notify 原始版
          • 3.3 BlockingQueue 工业版
          • 3.4 Disruptor 极致版
        • 4.哲学家就餐——死锁原型
          • 4.1 问题描述
          • 4.2 朴素实现的死锁
          • 4.3 三种破解方案
          • 4.4 Chandy-Misra 算法
          • 4.5 真实工程映射
        • 5.读者-写者——读多写少原型
          • 5.1 问题描述
          • 5.2 ReadWriteLock 实现
          • 5.3 写者饥饿深度剖析
          • 5.4 StampedLock 乐观读
          • 5.5 CopyOnWrite 终极读优化
        • 6.睡眠的理发师——信号量原型
          • 6.1 问题描述
          • 6.2 信号量解法
          • 6.3 工程映射:连接池/限流
        • 7.跨语言并发对比
          • 7.1 Java 共享内存派
          • 7.2 Go 消息传递派
          • 7.3 C++ 显式控制派
          • 7.4 Kotlin 协程派
          • 7.5 Erlang/Rust 视角补遗
          • 7.6 横向对比总表
        • 8.死锁与活锁的工程级剖析
          • 8.1 死锁的四种形态
          • 8.2 真实生产事故复盘
          • 8.3 活锁与饥饿
          • 8.4 死锁检测与预防
        • 9.经典陷阱与调试方法论
          • 9.1 ABA 问题真实事故
          • 9.2 伪共享:被忽视的隐形杀手
          • 9.3 双重检查锁定的演化
          • 9.4 锁粒度的反直觉权衡
          • 9.5 调试方法论
        • 10.一句话总结
          • 三个层次的认知升华
          • 终极建议
          • 延伸阅读
        • 9.工业级实战延伸
          • 9.1 案例:阿里LongAdder在双11应用
          • 9.2 案例:Kafka通ISR实现最终一致
          • 9.3 案例:Netflix Hystrix舱壁模式
          • 9.4 案例:Redis单线程为什么快
        • 10.并发架构演进路线图
          • 10.1 单机→集群→异步→流式
          • 10.2 三个永恒的反模式
          • 10.3 一句话总结
      • 6.并发Bug源头由来
      • 7.并发编程设计思想
      • 8.并发编程安全设计
      • 9.锁核心设计和思想
      • 10.理解CAS设计由来
      • 11.异步和同步的设计
      • 12.单线程模型的思想
      • 13.协程核心设计思想
      • 14.Actor与CSP并发模型
      • 15.线程池的设计思想
      • 16.线程池设计核心原理
      • 17.线程池使用技巧
      • 18.结构化并发设计思想
    • 内存的真相

    • 交互和系统

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 专栏
  • 程序编程原理
  • 并发的设计
杨充
2025-06-04
目录

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
1
2
3
4
5
6
7
8

# 目录介绍

  • 1.电影票超卖案例
    • 1.1 看似无害的代码
    • 1.2 Bug 是如何浮现的
    • 1.3 字节码层面的真相
    • 1.4 三个独立的伤口
  • 2.解决方案的四把刀
    • 2.1 synchronized 互斥锁
    • 2.2 ReentrantLock 显式锁
    • 2.3 AtomicInteger 无锁 CAS
    • 2.4 volatile 可见性
    • 2.5 四种方案的取舍
  • 3.生产者-消费者模型
    • 3.1 问题原型
    • 3.2 wait/notify 原始版
    • 3.3 BlockingQueue 工业版
    • 3.4 Disruptor 极致版
  • 4.哲学家就餐——死锁原型
    • 4.1 问题描述
    • 4.2 朴素实现的死锁
    • 4.3 三种破解方案
    • 4.4 Chandy-Misra 算法
    • 4.5 真实工程映射
  • 5.读者-写者——读多写少原型
    • 5.1 问题描述
    • 5.2 ReadWriteLock 实现
    • 5.3 写者饥饿深度剖析
    • 5.4 StampedLock 乐观读
    • 5.5 CopyOnWrite 终极读优化
  • 6.睡眠的理发师——信号量原型
    • 6.1 问题描述
    • 6.2 信号量解法
    • 6.3 工程映射:连接池/限流
  • 7.跨语言并发对比
    • 7.1 Java 共享内存派
    • 7.2 Go 消息传递派
    • 7.3 C++ 显式控制派
    • 7.4 Kotlin 协程派
    • 7.5 Erlang/Rust 视角补遗
    • 7.6 横向对比总表
  • 8.死锁与活锁的工程级剖析
    • 8.1 死锁的四种形态
    • 8.2 真实生产事故复盘
    • 8.3 活锁与饥饿
    • 8.4 死锁检测与预防
  • 9.经典陷阱与调试方法论
    • 9.1 ABA 问题真实事故
    • 9.2 伪共享:被忽视的隐形杀手
    • 9.3 双重检查锁定的演化
    • 9.4 锁粒度的反直觉权衡
    • 9.5 调试方法论
  • 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
    }
}
1
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 张",超卖!
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 写回主存
1
2
3
4

num-- 是 4 条字节码 / 数十条 CPU 指令。两个线程并发执行时,可能这样穿插:

时间线 →
线程 A:  getstatic(读到100) → ...被切走...                    → isub(99) → putstatic(99)
线程 B:                    → getstatic(读到100) → isub(99) → putstatic(99)
                                                         ↑
                                              两个线程都把 99 写回,
                                              库存只减了 1 但卖了 2 张
1
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
1
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--;
        }
    }
}
1
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. 释放锁,唤醒等待队列中的下一个线程
1
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
        }
    }
}
1
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)
1
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 失败 → 别人改过了 → 自动重试
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

CAS 的硬件支持——它对应 x86 的一条指令:

lock cmpxchg [num], ecx
  ↑
  lock 前缀 = 锁住缓存行(不是锁内核),其他核同时改这个地址会被排队
  cmpxchg = 比较并交换,是 CPU 层面的"如果还是旧值就替换为新值"
1
2
3
4

关键洞察:CAS 没有锁——没有内核态切换、没有等待队列、没有阻塞。它的代价仅仅是"如果失败就重试"。冲突低时 CAS 飞快,冲突高时 CAS 退化为忙循环。

ABA 问题——CAS 的著名陷阱:

线程 A: 读到值 100
线程 B: 100 → 99 → 100(短时间内来回变了一次)
线程 A: CAS(100, 99) 成功,但其实状态已经"变过又变回来"
1
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-- 变原子
    }
}
1
2
3
4
5
6
7
8

致命误区:很多新人以为 volatile = "线程安全"。错。volatile 解决的是"看见",不是"独占"。

volatile 真正干的事:

普通字段:     线程 A 改了 → 留在 A 的 L1 cache → 线程 B 可能读到旧值
volatile:    线程 A 改了 → 立刻刷主存(StoreStore 屏障)
                          → 线程 B 读时强制走主存(LoadLoad 屏障)
1
2
3

volatile 适用的场景——单读单写、读多写少的标志位:

class Server {
    private volatile boolean running = true;     // ✓ 标志位

    public void serve() {
        while (running) {                        // 读多
            processRequest();
        }
    }

    public void shutdown() {
        running = false;                          // 写少
    }
}
1
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;
}
1
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
1
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) ──→
       缓冲区满 → 生产者等                  缓冲区空 → 消费者等
1
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;
    }
}
1
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();    // ← 空了自动阻塞
1
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();
        }
    }
}
1
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          
每次锁竞争内核切换
1
2
3
4
5
6
7
8

三大设计:

  1. 环形缓冲(RingBuffer):预分配槽位,不 new 不 GC——避免了对象分配 + GC 暂停
  2. 无锁 CAS:生产者用 cursor.compareAndSet 推进游标——避免内核态切换
  3. 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
1
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();
                }
            }
        }
    }
}
1
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∞:每个人都在等右边的人放筷子,永远不会放
1
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();
        }
    }
}
1
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));   // 失败就退避
        }
    }
}
1
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();
    }
}
1
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 是干净的 → 留着自己用,等吃完再给

吃完一顿:
  所有筷子都变"脏"
1
2
3
4
5
6
7
8
9
10
11
12
13
14

为什么不死锁?

脏/干净标志破坏了"持有并等待"中"持续持有"的部分:
  - 持有"干净"筷子 → 我正要用 → 不让
  - 持有"脏"筷子   → 别人请求时必须给

可证明:脏筷子不会形成等待环(数学上是无环图)
1
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
1
2
3
4
5
6
7
8
9

修复方案:业务规定所有多行更新必须按 uid 升序——这就是 4.3 节"按序加锁"在数据库的应用。这一行规约能消除 99% 的事务死锁。

所以:哲学家就餐不只是面试题——它是所有"多资源协调"问题的元模型。每次你看到死锁报错,都可以问自己一句话:"这是哪些'哲学家'在抢哪些'筷子'?" 答案出来,破解方案就出来了。

# 5.读者-写者——读多写少原型

# 5.1 问题描述

数据库、配置中心、缓存——典型的读多写少:99% 的请求是读,1% 是写。如果每次读都加互斥锁,性能极低。理想方案:

读读不互斥(多个读者可同时读)
读写互斥  (写时不能读)
写写互斥  (只能一个写者)
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();
    }
}
1
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(); }
}
1
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 个读线程持续来)
1
2
3
4
5
6

问题根源——非公平 ReadWriteLock 的优先策略:

读锁请求到达:
  当前是读锁状态? → 直接共享(即使有写者在等)  ← 写者饥饿源头
  当前是写锁状态? → 进入等待队列

写锁请求到达:
  当前无锁? → 立即获取
  当前有读? → 进入等待队列
  当前有写? → 进入等待队列
1
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);
}
1
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);
1
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();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

适用场景速查:

场景 是否用 CopyOnWrite
监听器列表(注册一次,触发多次) ✅ 完美匹配
配置项白名单(启动加载,运行期不变) ✅ 完美匹配
高频写入的集合 ❌ 内存爆炸
大数据量集合(10 万+) ❌ 复制成本太高

所以:读者-写者问题揭示了一条铁律——读多写少时,不要用对称的互斥锁。读读共享、写写互斥、读乐观、写悲观,是这类场景的最优解;如果读到了"99% 读 + 极少写 + 集合不大",CopyOnWrite 是终极方案。

# 6.睡眠的理发师——信号量原型

# 6.1 问题描述

Dijkstra 1965 年提出(同年还提出哲学家就餐——并发界的双子星):理发店有 1 把椅子(理发位)+ N 把候座椅。

顾客来 → 看候座椅满没满
         ├── 满  → 离开
         └── 没满 → 坐下等
                   ├── 理发师在睡 → 叫醒
                   └── 理发师在剪 → 等

理发师 → 看候座椅有没有人
         ├── 没人 → 睡觉
         └── 有人 → 叫一个来理发
1
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();
        }
    }
}
1
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):纯通知机制(无资源)
1
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();      // 让出"候座椅"
    }
}
1
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();
    }
}
1
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();
    }
}
1
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);
1
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()
}
1
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();
1
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 都结束才返回
1
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.
1
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(); }
1
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
1
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 个线程都被父任务占用,子任务永远等不到执行 → 死锁
1
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
1
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 → 跨服务死锁
1
2
3
4

修复:分布式锁也要全局编号——所有服务约定锁顺序。但更根本的修复是取消跨服务持锁调用——业务重构成异步消息驱动。

事故三:ReentrantLock 异常未释放(2019 某 SaaS)

lock.lock();
processBusiness();   // ← 这里 NPE
lock.unlock();        // ← 永远到不了
// 第二个线程到 lock.lock() 永远等
1
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,谁也过不去
1
2
3
4
5
6
7
8
9
10
11
12
13

真实工程案例:某分布式系统两节点都用"退避重试"——发现冲突就退避一段时间——但两边退避时间相同,永远在同一时刻重试、同一时刻冲突。

解法:随机化退避时间(Exponential Backoff with Jitter)——CSMA/CD、TCP 重传、Kafka 重试都用这套机制。

饥饿(Starvation):低优先级线程永远拿不到资源——

非公平 ReentrantLock + 高频持锁线程 → 等待队列后端线程永远饿死
volatile 自旋锁 + 一直竞争失败的线程 → CPU 烧光但拿不到锁
1
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);   // 打印死锁链
    }
}
1
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);
1
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;
        }
    }
}
1
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, ???]
1
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 失败
1
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×
1
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;
}
1
2
3
4
5

修复 2——@Contended 注解(JDK 8+):

class Counter {
    @Contended public volatile long a;
    @Contended public volatile long b;
}
// 启动加 -XX:-RestrictContended
1
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;
}
1
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;
1
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;
    }
}
1
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 + 中间可能被打断(一致性破坏)
1
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 计数 —— 极致细化
1
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
1
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 微基准测试 必备工具

三个调试黄金法则:

  1. 先看现象,再看代码——CPU 100% 还是 0%,response 卡还是错误,决定了完全不同的诊断路径
  2. jstack 三连——间隔 1 秒抓 3 次栈,看哪些线程长期处于同一栈帧(必是阻塞或死循环点)
  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 事件
1
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++; }      // ❌ 不是原子
1
2
3

第二版:AtomicLong

AtomicLong pv = new AtomicLong();
void onView() { pv.incrementAndGet(); }   // 原子,但高竞争下 CAS 风暴
1
2

性能数据:当 100 个线程同时打 1 亿次时——

synchronized:    78 秒
AtomicLong:      18 秒
LongAdder:        2 秒    ← 9 倍提升
1
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
1
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
1
2
3
4
5
6
7
8

核心权衡:

acks=all + ISR 全员同步:CP(一致性 + 分区容错)
  ↓ 副本落后 → 踢出 ISR
acks=all + 缩水后的 ISR:AP(可用性 + 分区容错)
1
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
1
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秒超时 + 熔断 + 降级 + 隔离
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
1
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
1
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<>();   // ❌
1

反模式 2:不限制并发量

new Thread(() -> ...).start();    // ❌
new Thread(() -> ...).start();    // ❌
new Thread(() -> ...).start();    // ❌  ← 无上限
1
2
3

反模式 3:跨服务调用不设超时

Response r = httpClient.get(url);    // ❌ 默认无超时
1

# 10.3 一句话总结

多线程并发的真正本质,是"在不可靠的硬件 + 不确定的调度 + 易变的业务"三重不确定性下,构建可预测的执行模型。 11 个经典案例只是表象,背后映射的是同一个问题——程序员的"顺序心智"和"硬件的并发现实"之间的契约如何写。从 i++ 到 Disruptor,从 synchronized 到 Loom,所有方案都是这条契约的不同版本。 真正的并发高手,不是会用更多原语的人,而是能在画完 happens-before 图之后说"这个临界区其实可以删掉"的人——让锁消失、让共享消失、让并发问题在设计阶段就不存在,这才是终极境界。

上次更新: 2026/06/07, 10:26:12
4.线程异常设计原理
6.并发Bug源头由来

← 4.线程异常设计原理 6.并发Bug源头由来→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式