跳到主要内容

死锁、活锁与饥饿

并发程序不仅要保证状态正确,还要保证任务能够继续完成。死锁、活锁和饥饿都会破坏进度,但它们的线程状态、CPU 表现和修复方式不同。

1. 死锁形成等待环

void transfer(Account from, Account to, Money amount) {
synchronized (from) {
synchronized (to) {
move(from, to, amount);
}
}
}

线程 A 从账户 1 向账户 2 转账,线程 B 同时按相反方向转账时,可能各自持有一个账户 monitor 并等待另一个,形成环。

经典的四个条件用于检查风险:资源互斥、持有并等待、资源不可被强制剥夺、循环等待。破坏其中一项即可避免这类死锁,工程中最常见的是统一锁顺序:

Account first = smallerId(from, to);
Account second = first == from ? to : from;

如果两个 key 相同,还要处理同对象或 tie lock,不能只比较哈希后假定顺序唯一。

2. 超时让等待退出,但不自动保证业务正确

ReentrantLock.tryLock(timeout) 可以在无法取得第二把锁时释放第一把并重试,打破无限等待。重试必须有总截止时间、退避和失败结果,否则多个线程可能同步地反复让步,转成活锁。

数据库锁、HTTP 调用和线程池 Future 也能构成跨组件等待环。JVM 只能自动识别部分 monitor 和 ownable synchronizer 死锁,不能理解整个分布式依赖图。

3. 活锁持续执行却没有完成

两个礼貌线程检测到冲突后同时撤销、同时重试,每次都再次冲突。线程通常处于 RUNNABLE,CPU 和重试计数可能很高,但成功吞吐接近零。

常见处理方式包括随机或指数退避、给一方确定优先级、减少冲突窗口,以及对重试次数和总时间设上限。只观察线程是否在运行,无法判断系统是否有业务进展。

4. 饥饿只影响部分参与者

系统整体仍在完成任务,但某个线程长期拿不到锁、许可、CPU 或执行器位置,就是饥饿。来源可能包括:

  • 非公平竞争中的持续插队。
  • 高优先级任务不断占满同一队列。
  • 读锁长期不断到达,写者难以进入。
  • 大任务被短任务反复推迟。

公平锁可以缓解特定获取顺序问题,却通常牺牲吞吐,也不能保证操作系统调度公平。更直接的方案可能是拆分队列、设定配额或限制单租户占用。

5. 用“是否完成工作”建立进度指标

诊断至少同时观察:

  • 成功完成率和最老未完成任务年龄。
  • 重试、超时和取消次数。
  • 锁等待、队列长度和线程状态。
  • CPU、上下文切换与下游响应。

死锁通常表现为稳定等待环;活锁表现为活动与重试很多却没有完成;饥饿表现为整体有吞吐,但某些任务等待时间持续增长。

6. 常见问题

6.1 使用 ConcurrentHashMap 就不会死锁吗

单个容器操作由实现保证线程安全,但业务可能在回调里取得其他锁、等待 Future,或以不同顺序组合多个组件,仍能形成等待环。

6.2 随机退避能证明没有活锁吗

它可以降低同步重试的概率,不是严格的进度证明。仍需截止时间、重试上限和失败路径。

7. 面试题

7.1 死锁、活锁和饥饿怎样区分

出现公司:拼多多、美团

考察重点

  • 等待环、无效重试和局部长期等待。
  • 线程状态不能单独判断进度。
  • 锁顺序、退避和资源配额。

相关内容:第 1 节“死锁形成等待环”至第 5 节“用‘是否完成工作’建立进度指标”。

参考回答

死锁是参与者形成循环等待,彼此都不能继续;活锁是线程持续运行和改变状态,却反复冲突而没有任务完成;饥饿是系统整体有进展,但某个线程或任务长期得不到资源。

死锁通过统一锁顺序或可失败获取破坏等待环;活锁需要退避、优先级和重试上限;饥饿要检查公平性、队列配额与任务隔离。诊断都要把线程栈与完成率、重试和等待时间线结合起来。