Skip to content

操作系统:调度、同步与死锁 ​

S2-010建议学高级约 90 分钟JDK 21计算机基础 · 操作系统

1. 本节定位 ​

《操作系统:进程与线程》讲了"怎么开线程";本节讲**"多个线程同时干活时要防什么"**:

text
竞态条件(Race Condition):结果取决于"谁先谁后" → 结果随机 ✗
死锁(Deadlock):互相等对方放手 → 永远卡住 ✗✗

★ 这两个是并发编程里最贵的两类 bug ✓ ——因为都不报错,只是"偶尔错"和"突然不动" ✓

2. 学习目标 ​

  • 能说清 count++ 为什么不是原子操作,并用实测数字说明后果 ★★★;
  • 能复现死锁,并用 JDK 自带工具检测出来 ★★★;
  • 说出死锁的四个必要条件与三种破解手段 ★★★;
  • 知道 wait 必须放在 while 里的原因 ★★★;
  • 知道"优先用 JDK 并发容器而不是手写" ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一个真实事故 ✗:

text
某接口统计"当日下单数",用的是内存里的计数器:
    orderCount++;
线上现象:**统计值总是比真实值少**,而且每天少多少都不一样 ✗✗
排查结论:8 个线程同时自增,丢了一部分更新。

★ 本节的实测把这个"少"量化出来 ✓:

text
期望值:4000000
无同步 long++:      1768558(少了 2231442)✗✗
synchronized:       4000000 ✓
AtomicInteger:      4000000 ✓

★ 少了 56% ✗✗ ——而且"每次运行都不同" ✓ ——这正是并发 bug 最难查的原因 ✓

5. 核心概念 ​

5.1 竞态条件:一步变三步 ★★★ ​

count++ 在字节码层面是三条指令:

text
① 读 count 到寄存器       GETFIELD
② 寄存器加 1              IADD
③ 写回 count              PUTFIELD

★ 两个线程交错执行,就会丢更新 ✓:

text
线程A:读(100) .............. 加1(101) 写回(101)
线程B:....... 读(100) 加1(101) 写回(101) ......    ← B 读到的是旧值 ✗
结果:两次自增,值只涨了 1 ✗✗

★ 三种解法与它们的取舍 ✓:

方案原理适用
synchronized ★互斥锁,同一时刻只有一个线程能进临界区有多步操作时 ✓
AtomicInteger ★★CAS(比较并交换),无锁自旋单个变量的原子操作 ✓✓
LongAdder ★★分段累加,最后汇总高并发计数(比 Atomic 更快)✓

★ 选择口诀 ✓:

"一段代码要互斥" → 锁;"一个数字要安全自增" → 原子类 ✓✓

计数场景优先 LongAdder(《JUC 工具类与并发容器》 会展开它为什么更快)✓

★ 一个容易忽略的边界 ✗:

synchronized 保护的是"对象",不是"代码" ✓

所以必须保证"所有访问共享状态的地方,用的是同一把锁" ✓✓

用了两把不同的锁 = 没加锁 ✗✗

5.2 死锁:四个条件同时成立 ★★★ ​

本节的实测(真实复现 + 检测):

text
★ 检测到死锁线程数:2
   线程 worker-deadlock-1 阻塞在 java.lang.Object@65b54208,它等的是 worker-deadlock-2
   线程 worker-deadlock-2 阻塞在 java.lang.Object@85ede7b,它等的是 worker-deadlock-1

★ 死锁的四个必要条件(缺一不可) ✓✓:

#条件说明
①互斥资源一次只能被一个线程持有 ✓
②持有并等待 ★★拿着 A,还要去要 B ← 最常见的起因
③不可抢占不能把锁从别人手里抢走 ✓
④循环等待 ★★A 等 B,B 等 A ← 最容易破的一环

★ 破掉任意一个就不会死锁 ✓ ——工程上最有效的是破 ④ ✓✓:

手段破的是说明
统一加锁顺序 ★★★④所有线程都按"先 A 后 B"的顺序 ✓✓ 首选
带超时的 tryLock ★★②抢不到就放弃,稍后重试 ✓
减少锁粒度 / 用无锁结构 ★★①Atomic、ConcurrentHashMap ✓

★ 三种手段的取舍 ✓:

首选"统一顺序" ——它最简单、最彻底、零运行时成本 ✓✓

tryLock 只是"不死锁",但它会带来频繁重试(活锁) ⚠️

★ 真实线上的死锁长什么样 ✓:

text
现象:接口全部超时、CPU 不高、线程数持续增长
原因:线程池的线程全卡在死锁上,新请求排队
排查:jstack <pid> 第一行就会打印 "Found one Java-level deadlock" ✓✓

★ 一句话:"CPU 不高但接口全挂" → 第一个想到的就是死锁或线程池耗尽 ✓✓

5.3 线程协作:wait/notify 的两条铁律 ★★★ ​

实测(传输 20 万条):

text
手写 synchronized + wait/notify: 193 ms
ArrayBlockingQueue:              55 ms       ← 快 3.5 倍 ✓

★ 手写版必须遵守两条铁律 ✓✓:

#铁律违反了会怎样
①wait 必须放在 while 里(不是 if)★★★虚假唤醒、或条件被别人改掉 → 用错数据 ✗✗
②改完条件必须 notify/notifyAll ★★★对方永久等待(错过唤醒) ✗✗

★ 为什么必须用 while 而不是 if ✓✓:

text
① 虚假唤醒:JVM 允许 wait 在没有通知的情况下返回(罕见但合法)✗
② 被抢走:被唤醒后要先抢锁,抢到锁时条件可能又被别人改回去了 ✗✗

★ 标准写法(背下来) ✓:

java
synchronized (lock) {
    while (!condition) {        // ★ while,不是 if
        lock.wait();
    }
    // 这里条件一定成立
}

synchronized (lock) {
    // 改条件
    lock.notifyAll();           // ★ 改完必须通知
}

★ 为什么是 notifyAll 而不是 notify ✓:

notify 只唤醒一个线程,如果唤醒的那个"条件还不满足",它就又回去了 —— 而其他该被唤醒的线程被漏掉了 ✗✗

除非你能严格证明"唤醒任意一个都正确",否则一律用 notifyAll ✓✓

★ 但最好的做法是用 JDK 现成的 ✓✓:

需求用什么
生产者消费者ArrayBlockingQueue / LinkedBlockingQueue ✓✓
共享计数AtomicInteger / LongAdder ✓
并发读多写少的 MapConcurrentHashMap ✓
限流 / 一次性的屏障Semaphore / CountDownLatch / CyclicBarrier ✓

★ 一句实践结论 ✓:

优先用 JDK 的并发容器 ——它们已经处理好了这些边界,而且通常更快 ✓✓

手写 wait/notify 只在"实现自定义同步组件"时才需要 ✓

5.4 调度:操作系统怎么分配 CPU ★ ​

现代操作系统调度的三个要点 ✓:

要点说明
时间片轮转 ★每个线程跑一小段(毫秒级)就换人 ——所以"看起来"是同时的 ✓
优先级与抢占 ★高优先级可抢占低优先级;但不要指望它精确 ⚠️
IO 阻塞时让出 CPU ★★★等 IO 的线程不占 CPU ——这正是"IO 密集可以多配线程"的原因 ✓✓

★ 你不能控制调度,但你可以影响它 ✓:

手段作用
Thread.yield() ⚠️"我愿意让出" ——只是建议,不保证 ✗
Thread.sleep(0)触发一次重新调度 ⚠️
降低线程优先级影响很小,不要依赖 ✗
拆分任务 + 线程池隔离 ★★真正有效的手段:让重要任务有自己的池子 ✓✓

★ 结论:别去"调优调度",去"设计好线程池" ✓✓

6. 递进示例 ​

6.1 最小示例:把非原子操作变原子 ​

java
// ✗ 非原子
counter++;

// ✓ 原子
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();

// ✓✓ 高并发计数(分段累加,吞吐更高)
LongAdder counter = new LongAdder();
counter.increment();

6.2 业务示例:用队列替代手写同步 ​

java
// 生产者
queue.put(item);          // 满时自动阻塞

// 消费者
Item item = queue.take(); // 空时自动阻塞

★ 一行代码替代几十行 wait/notify ✓ ——而且更不容易错 ✓✓

6.3 反例:两把不同的锁保护同一份数据 ​

java
private final Object lockA = new Object();
private final Object lockB = new Object();

void updateA() { synchronized (lockA) { shared++; } }    // ✗
void updateB() { synchronized (lockB) { shared++; } }    // ✗ 等于没锁

7. 完整可运行代码与实测 ​

代码:labs/cs-basics/src/main/java/cn/kb/learn/cs/os/SchedulingDeadlockLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl cs-basics compile
java "-Dstdout.encoding=UTF-8" -cp cs-basics/target/classes cn.kb.learn.cs.os.SchedulingDeadlockLesson

实测输出(完整):

text
===== 1. 竞态条件:为什么 count++ 会少算 =====
  期望值:4000000
  无同步 long++:      1768558(少了 2231442)✗✗
  synchronized:       4000000 ✓
  AtomicInteger:      4000000 ✓

===== 2. 复现死锁:两个线程交叉抢两把锁 =====
  ★ 检测到死锁线程数:2
     线程 worker-deadlock-1 阻塞在 java.lang.Object@65b54208,它等的是 worker-deadlock-2
     线程 worker-deadlock-2 阻塞在 java.lang.Object@85ede7b,它等的是 worker-deadlock-1

===== 3. 避免死锁:tryLock 超时 vs 统一加锁顺序 =====
  两个线程都在 3 秒内结束:是 ✓(没有死锁)

===== 4. 线程协作:手写 wait/notify vs BlockingQueue =====
  手写 synchronized + wait/notify: 193 ms(传输 200000 条)
  ArrayBlockingQueue:              55 ms(传输 200000 条)

★ 三条可落地的结论 ✓:

#结论依据
①任何 x++、x += n、check-then-act 都要防竞态实测少算 56% ✗
②加锁要统一顺序;JDK 的 findDeadlockedThreads 能直接检测死锁可复现可检测 ✓
③线程协作用 BlockingQueue,不手写 wait/notify快 3.5 倍且更安全 ✓

★ 一条"实战型"的提醒 ✓:

本节的死锁实验跑完后,那两个线程是"真的死锁"的 —— 代码里必须把它们设为守护线程(setDaemon(true)),否则 JVM 永远无法退出 ✓✓

这也从侧面说明:死锁一旦发生,程序就真的"卡死"了,没有任何自动恢复机制 ✗

8. Debug 观察点 ​

  1. 把 synchronized 换成两把不同的锁:观察结果又变少 ✗✗;
  2. 把 AtomicInteger 换成 LongAdder:观察高并发下的吞吐差异 ★;
  3. 用 jstack <pid> 看死锁:观察 "Found one Java-level deadlock" 那段 ★★★;
  4. 把 while (!condition) 改成 if (!condition):观察偶发的"数据被取空" ✗(难复现,正是它的可怕之处)✓;
  5. 把 notifyAll 改成 notify:观察生产者消费者偶发卡住 ⚠️。

9. 常见错误 ​

#错误说明
1以为 count++ 是原子的 ✗✗三步操作(5.1)✗
2两把不同的锁保护同一个变量 ✗✗等于没锁 ✓
3加锁顺序不统一 ✗✗死锁的典型起因 ✓
4wait 放在 if 里 ✗✗虚假唤醒 + 被抢走(5.3)✗
5忘了 notify ✗✗对方永久等待 ✓
6用 notify 而不是 notifyAll ⚠️可能唤醒错人 ✓
7手写同步而不看 JDK 有没有现成容器 ✗慢且易错(5.3)✓

10. 动手实验 ​

目标:亲手制造一次"少算"与一次"死锁",并学会检测。

步骤:

  1. 跑 SchedulingDeadlockLesson,记录少算了多少(每次不同)★;
  2. 把线程数从 8 改成 32,观察少算的比例是否变大 ★;
  3. 用 jps + jstack 在程序运行期间抓一次死锁快照(需要把死锁那段改成长期挂起)★★;
  4. 把 tryLock 的等待时间改成 0(tryLock()),观察失败率上升 ✓;
  5. 对比 AtomicInteger 与 LongAdder 在 32 线程下的吞吐 ★。

验收方式:能说出"你这台机器上,8 线程无同步自增 400 万次大概少算多少"。

扩展任务:实现一个**"避免死锁的转账"**:

  • 两个账户之间转账要同时锁两个账户 ✓;
  • 按 accountId 大小排序后再加锁(统一顺序)✓;
  • 对比"不排序"的版本,用死锁检测验证后者会死锁 ★★

11. 练习题 ​

  1. 判断:AtomicInteger.incrementAndGet() 是线程安全的。(对 / 错)
  2. 填空:死锁的四个必要条件是 、、、;工程上最常破的是 ______。
  3. 简答:为什么 wait 必须放在 while 循环里?
  4. (代码阅读)
java
public void transfer(Account from, Account to, long amount) {
    synchronized (from) {
        synchronized (to) {
            // 转账
        }
    }
}

这段代码可能死锁吗?为什么?怎么改? 5. (编程)用 BlockingQueue 实现一个"有界缓冲区"的生产者消费者,分别用 1、4、8 个生产者和消费者,观察吞吐变化。

参考答案(要点)
  1. 对。它基于 CAS,是原子的 ✓
  2. 互斥、持有并等待、不可抢占、循环等待;循环等待(统一加锁顺序)✓★
  3. ① 防虚假唤醒;② 被唤醒到真正拿到锁之间,条件可能又被别人改回去 ✓★
  4. 会死锁 ✗ ——A 转账给 B(锁 A→B)与 B 转账给 A(锁 B→A)同时发生时,形成循环等待 ✓ 改法:按账户 ID 排序后加锁(统一顺序)✓✓
  5. 要点:吞吐会先升后平;生产者/消费者数量不匹配时队列会成为瓶颈 ✓

12. 掌握标准 ​

  • [ ] 能说出 count++ 的三步,并知道竞态的后果 ★★★;
  • [ ] 能在代码里复现死锁,并用 findDeadlockedThreads / jstack 检测 ★★★;
  • [ ] 能说出四个必要条件与三种破解手段,且知道首选"统一顺序" ★★★;
  • [ ] 知道 wait 必须放 while、改条件必须 notifyAll ★★★;
  • [ ] 知道手写同步不如用 JDK 并发容器 ★★★;
  • [ ] 知道"CPU 不高但接口全挂"要优先怀疑死锁 ★★;
  • [ ] 知道 setDaemon(true) 能让死锁的线程不阻塞 JVM 退出 ✓。

13. 扩展资源 ​

14. 下一步学习 ​

《操作系统:内存管理与局部性原理》:为什么"访问顺序"能让同一段代码快 30 倍。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引