5.2 死锁与锁升级事故 本节摘要:两段代码以相反顺序获取两把锁,并发一碰撞就互相等到天荒地老。本节从一次发布后的全站 hang 讲起,覆盖死锁四要素、锁顺序的工程约束、tryLock 超时破环、synchronized 的锁升级过程,以及 AQS 与显式锁的适用边界。 事故现场:发布 40 分钟后全站 hang 转账服务新版本发布后一切正常,40 分钟后 RT 突然拉满,所有请求超时。jstack 一把抓出真凶,日志里 JVM 已经自动打印: 两段业务代码的锁顺序相反: 当一笔"A 转 B"的请求和一次 audit(B, A) 的调用同时发生,前者持 A 等 B,后者持 B 等 A,谁也不放手。
本节摘要:两段代码以相反顺序获取两把锁,并发一碰撞就互相等到天荒地老。本节从一次发布后的全站 hang 讲起,覆盖死锁四要素、锁顺序的工程约束、tryLock 超时破环、synchronized 的锁升级过程,以及 AQS 与显式锁的适用边界。
转账服务新版本发布后一切正常,40 分钟后 RT 突然拉满,所有请求超时。jstack 一把抓出真凶,日志里 JVM 已经自动打印:
Found one Java-level deadlock: "thread-1" holds lock A, waiting for lock B "thread-2" holds lock B, waiting for lock A
两段业务代码的锁顺序相反:
// 转账路径:先锁转出账户 再锁转入账户 void transfer(Account from, Account to, long amt) { synchronized (from) { synchronized (to) { doTransfer(from, to, amt); } } } // 对账路径:按参数自然顺序 加锁顺序取决于谁先谁后 void audit(Account a, Account b) { synchronized (b) { // 与 transfer 顺序相反 synchronized (a) { doAudit(a, b); } } }
当一笔"A 转 B"的请求和一次 audit(B, A) 的调用同时发生,前者持 A 等 B,后者持 B 等 A,谁也不放手。更糟的是持有这两把锁的代码还会阻塞其他线程(等 A 的、等 B 的请求排起队),故障从两个线程扩散到整个服务——死锁是"低概率事件 + 全局爆炸半径"的组合,测试环境跑一年也未必撞上一次。
死锁成立的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。工程上真正可控的是循环等待——只要全局规定锁的获取顺序,环就闭不上。转账的经典解法是按账户 id 排序:
void transfer(Account from, Account to, long amt) { Account first = from.id < to.id ? from : to; Account second = from.id < to.id ? to : from; synchronized (first) { // 全系统统一:永远先锁 id 小的 synchronized (second) { doTransfer(from, to, amt); } } }
所有获取多把锁的路径遵守同一全序,死锁在拓扑上不可能。难点不在写法而在纪律:锁序是全局属性,任何一处违反(尤其老代码和新人代码对同一组锁排序不同)就前功尽弃。
顺序约束失效或无法统一时,用显式锁的尝试获取 + 超时让路:
private final ReentrantLock lockA = new ReentrantLock(); if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) { try { if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) { try { doWork(); } finally { lockB.unlock(); } } else { backoffAndRetry(); // 拿不到 B 放弃已持有的 A 回退重试 } } finally { lockA.unlock(); } } else { rejectOrFallback(); // 快速失败 交给上游重试 }
拿不到就放手,"不可剥夺"条件被破坏,环自然断开。代价是重试逻辑与退避策略的复杂度,所以定位是第二道防线:锁序防患于未然,tryLock 兜底不可控场景(第三方锁、动态锁集合)。
排查工具要熟:jstack 会自动检测并打印 "Found one Java-level deadlock";ThreadMXBean.findDeadlockedThreads() 可以做成运行时探针,巡检到死锁自动报警甚至自杀重启——长事务系统里"检测到死锁就自动摘流量"是常见的自愈手段。
JDK 6 之后 synchronized 做了一整套优化,锁状态沿 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 单向升级(JDK 15 起偏向锁被废弃,路径简化为轻量级起步):
要点是锁会膨胀且默认不回退:一场突发流量让某把锁升到重量级,高峰过后它仍以重量级语义运行。长锁 + 高并发下,jstack 会看到大量 BLOCKED 在 monitor 上的线程。显式 ReentrantLock 的适用场景:需要 tryLock 超时、需要公平锁、需要多个 Condition 队列(生产者消费者的精准唤醒)。其余场景 synchronized 足够——JIT 对它的锁消除(逃逸分析证明对象不共享,锁直接删掉)与锁粗化(相邻同步块合并)是自动的。
读多写少的共享结构用 ReentrantReadWriteLock,读锁共享写锁独占;更进一步的 StampedLock 支持乐观读(读时不加锁,读完校验版本,失败再升级悲观读),吞吐更高但不可重入、用法苛刻。无锁路线的 CAS(compare-and-swap)是 Atomic 类与 LongAdder 的地基,适合简单状态的原子更新;复杂状态用 CAS 手搓循环容易写错(ABA 问题要 AtomicStampedReference 兜),不是默认选项。它们共同的爹是 AQS(AbstractQueuedSynchronizer):一个 volatile 状态位 + CLH 等待队列,把"获取-释放"抽象成模板,ReentrantLock、Semaphore、CountDownLatch 全是它的子类——读 JUC 源码从 AQS 入手是捷径。
⚠️ 常见坑:锁内调用外部回调/RPC。持锁时间从纳秒级拉到毫秒级,吞吐塌方且死锁概率放大一个数量级——锁内只做内存操作,外部调用挪到锁外。
💡 关键直觉:死锁不是"锁太多",是"顺序太乱"。把系统里所有锁的获取顺序画成一张有向图,不允许出现环——评审时能画出这张图的服务,死锁概率约等于零。
下一节看并发事故的容量版本:线程池打满。