主题
界面
主题
《操作系统:进程与线程》讲了"怎么开线程";本节讲**"多个线程同时干活时要防什么"**:
竞态条件(Race Condition):结果取决于"谁先谁后" → 结果随机 ✗
死锁(Deadlock):互相等对方放手 → 永远卡住 ✗✗★ 这两个是并发编程里最贵的两类 bug ✓ ——因为都不报错,只是"偶尔错"和"突然不动" ✓
count++ 为什么不是原子操作,并用实测数字说明后果 ★★★;wait 必须放在 while 里的原因 ★★★;synchronized 的基本用法吗?(《线程与同步基础》)✓join 吗?(《操作系统:进程与线程》)✓先看一个真实事故 ✗:
某接口统计"当日下单数",用的是内存里的计数器:
orderCount++;
线上现象:**统计值总是比真实值少**,而且每天少多少都不一样 ✗✗
排查结论:8 个线程同时自增,丢了一部分更新。★ 本节的实测把这个"少"量化出来 ✓:
期望值:4000000
无同步 long++: 1768558(少了 2231442)✗✗
synchronized: 4000000 ✓
AtomicInteger: 4000000 ✓★ 少了 56% ✗✗ ——而且"每次运行都不同" ✓ ——这正是并发 bug 最难查的原因 ✓
count++ 在字节码层面是三条指令:
① 读 count 到寄存器 GETFIELD
② 寄存器加 1 IADD
③ 写回 count PUTFIELD★ 两个线程交错执行,就会丢更新 ✓:
线程A:读(100) .............. 加1(101) 写回(101)
线程B:....... 读(100) 加1(101) 写回(101) ...... ← B 读到的是旧值 ✗
结果:两次自增,值只涨了 1 ✗✗★ 三种解法与它们的取舍 ✓:
| 方案 | 原理 | 适用 |
|---|---|---|
synchronized ★ | 互斥锁,同一时刻只有一个线程能进 | 临界区有多步操作时 ✓ |
AtomicInteger ★★ | CAS(比较并交换),无锁自旋 | 单个变量的原子操作 ✓✓ |
LongAdder ★★ | 分段累加,最后汇总 | 高并发计数(比 Atomic 更快)✓ |
★ 选择口诀 ✓:
"一段代码要互斥" → 锁;"一个数字要安全自增" → 原子类 ✓✓
计数场景优先
LongAdder(《JUC 工具类与并发容器》 会展开它为什么更快)✓
★ 一个容易忽略的边界 ✗:
synchronized保护的是"对象",不是"代码" ✓所以必须保证"所有访问共享状态的地方,用的是同一把锁" ✓✓
用了两把不同的锁 = 没加锁 ✗✗
本节的实测(真实复现 + 检测):
★ 检测到死锁线程数: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只是"不死锁",但它会带来频繁重试(活锁) ⚠️
★ 真实线上的死锁长什么样 ✓:
现象:接口全部超时、CPU 不高、线程数持续增长
原因:线程池的线程全卡在死锁上,新请求排队
排查:jstack <pid> 第一行就会打印 "Found one Java-level deadlock" ✓✓★ 一句话:"CPU 不高但接口全挂" → 第一个想到的就是死锁或线程池耗尽 ✓✓
实测(传输 20 万条):
手写 synchronized + wait/notify: 193 ms
ArrayBlockingQueue: 55 ms ← 快 3.5 倍 ✓★ 手写版必须遵守两条铁律 ✓✓:
| # | 铁律 | 违反了会怎样 |
|---|---|---|
| ① | wait 必须放在 while 里(不是 if)★★★ | 虚假唤醒、或条件被别人改掉 → 用错数据 ✗✗ |
| ② | 改完条件必须 notify/notifyAll ★★★ | 对方永久等待(错过唤醒) ✗✗ |
★ 为什么必须用 while 而不是 if ✓✓:
① 虚假唤醒:JVM 允许 wait 在没有通知的情况下返回(罕见但合法)✗
② 被抢走:被唤醒后要先抢锁,抢到锁时条件可能又被别人改回去了 ✗✗★ 标准写法(背下来) ✓:
synchronized (lock) {
while (!condition) { // ★ while,不是 if
lock.wait();
}
// 这里条件一定成立
}
synchronized (lock) {
// 改条件
lock.notifyAll(); // ★ 改完必须通知
}★ 为什么是 notifyAll 而不是 notify ✓:
notify只唤醒一个线程,如果唤醒的那个"条件还不满足",它就又回去了 —— 而其他该被唤醒的线程被漏掉了 ✗✗除非你能严格证明"唤醒任意一个都正确",否则一律用
notifyAll✓✓
★ 但最好的做法是用 JDK 现成的 ✓✓:
| 需求 | 用什么 |
|---|---|
| 生产者消费者 | ArrayBlockingQueue / LinkedBlockingQueue ✓✓ |
| 共享计数 | AtomicInteger / LongAdder ✓ |
| 并发读多写少的 Map | ConcurrentHashMap ✓ |
| 限流 / 一次性的屏障 | Semaphore / CountDownLatch / CyclicBarrier ✓ |
★ 一句实践结论 ✓:
优先用 JDK 的并发容器 ——它们已经处理好了这些边界,而且通常更快 ✓✓
手写
wait/notify只在"实现自定义同步组件"时才需要 ✓
现代操作系统调度的三个要点 ✓:
| 要点 | 说明 |
|---|---|
| 时间片轮转 ★ | 每个线程跑一小段(毫秒级)就换人 ——所以"看起来"是同时的 ✓ |
| 优先级与抢占 ★ | 高优先级可抢占低优先级;但不要指望它精确 ⚠️ |
| IO 阻塞时让出 CPU ★★★ | 等 IO 的线程不占 CPU ——这正是"IO 密集可以多配线程"的原因 ✓✓ |
★ 你不能控制调度,但你可以影响它 ✓:
| 手段 | 作用 |
|---|---|
Thread.yield() ⚠️ | "我愿意让出" ——只是建议,不保证 ✗ |
Thread.sleep(0) | 触发一次重新调度 ⚠️ |
| 降低线程优先级 | 影响很小,不要依赖 ✗ |
| 拆分任务 + 线程池隔离 ★★ | 真正有效的手段:让重要任务有自己的池子 ✓✓ |
★ 结论:别去"调优调度",去"设计好线程池" ✓✓
// ✗ 非原子
counter++;
// ✓ 原子
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
// ✓✓ 高并发计数(分段累加,吞吐更高)
LongAdder counter = new LongAdder();
counter.increment();// 生产者
queue.put(item); // 满时自动阻塞
// 消费者
Item item = queue.take(); // 空时自动阻塞★ 一行代码替代几十行 wait/notify ✓ ——而且更不容易错 ✓✓
private final Object lockA = new Object();
private final Object lockB = new Object();
void updateA() { synchronized (lockA) { shared++; } } // ✗
void updateB() { synchronized (lockB) { shared++; } } // ✗ 等于没锁代码:labs/cs-basics/src/main/java/cn/kb/learn/cs/os/SchedulingDeadlockLesson.java
运行:
cd labs
.\mvnw.cmd -pl cs-basics compile
java "-Dstdout.encoding=UTF-8" -cp cs-basics/target/classes cn.kb.learn.cs.os.SchedulingDeadlockLesson实测输出(完整):
===== 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 永远无法退出 ✓✓这也从侧面说明:死锁一旦发生,程序就真的"卡死"了,没有任何自动恢复机制 ✗
synchronized 换成两把不同的锁:观察结果又变少 ✗✗;AtomicInteger 换成 LongAdder:观察高并发下的吞吐差异 ★;jstack <pid> 看死锁:观察 "Found one Java-level deadlock" 那段 ★★★;while (!condition) 改成 if (!condition):观察偶发的"数据被取空" ✗(难复现,正是它的可怕之处)✓;notifyAll 改成 notify:观察生产者消费者偶发卡住 ⚠️。| # | 错误 | 说明 |
|---|---|---|
| 1 | 以为 count++ 是原子的 ✗✗ | 三步操作(5.1)✗ |
| 2 | 两把不同的锁保护同一个变量 ✗✗ | 等于没锁 ✓ |
| 3 | 加锁顺序不统一 ✗✗ | 死锁的典型起因 ✓ |
| 4 | wait 放在 if 里 ✗✗ | 虚假唤醒 + 被抢走(5.3)✗ |
| 5 | 忘了 notify ✗✗ | 对方永久等待 ✓ |
| 6 | 用 notify 而不是 notifyAll ⚠️ | 可能唤醒错人 ✓ |
| 7 | 手写同步而不看 JDK 有没有现成容器 ✗ | 慢且易错(5.3)✓ |
目标:亲手制造一次"少算"与一次"死锁",并学会检测。
步骤:
SchedulingDeadlockLesson,记录少算了多少(每次不同)★;jps + jstack 在程序运行期间抓一次死锁快照(需要把死锁那段改成长期挂起)★★;tryLock 的等待时间改成 0(tryLock()),观察失败率上升 ✓;AtomicInteger 与 LongAdder 在 32 线程下的吞吐 ★。验收方式:能说出"你这台机器上,8 线程无同步自增 400 万次大概少算多少"。
扩展任务:实现一个**"避免死锁的转账"**:
accountId 大小排序后再加锁(统一顺序)✓;AtomicInteger.incrementAndGet() 是线程安全的。(对 / 错)wait 必须放在 while 循环里?public void transfer(Account from, Account to, long amount) {
synchronized (from) {
synchronized (to) {
// 转账
}
}
}这段代码可能死锁吗?为什么?怎么改? 5. (编程)用 BlockingQueue 实现一个"有界缓冲区"的生产者消费者,分别用 1、4、8 个生产者和消费者,观察吞吐变化。
count++ 的三步,并知道竞态的后果 ★★★;findDeadlockedThreads / jstack 检测 ★★★;wait 必须放 while、改条件必须 notifyAll ★★★;setDaemon(true) 能让死锁的线程不阻塞 JVM 退出 ✓。《操作系统:内存管理与局部性原理》:为什么"访问顺序"能让同一段代码快 30 倍。
暂无