5.2 死锁与锁升级事故


文档摘要

5.2 死锁与锁升级事故 本节摘要:两段代码以相反顺序获取两把锁,并发一碰撞就互相等到天荒地老。本节从一次发布后的全站 hang 讲起,覆盖死锁四要素、锁顺序的工程约束、tryLock 超时破环、synchronized 的锁升级过程,以及 AQS 与显式锁的适用边界。 事故现场:发布 40 分钟后全站 hang 转账服务新版本发布后一切正常,40 分钟后 RT 突然拉满,所有请求超时。jstack 一把抓出真凶,日志里 JVM 已经自动打印: 两段业务代码的锁顺序相反: 当一笔"A 转 B"的请求和一次 audit(B, A) 的调用同时发生,前者持 A 等 B,后者持 B 等 A,谁也不放手。

5.2 死锁与锁升级事故

本节摘要:两段代码以相反顺序获取两把锁,并发一碰撞就互相等到天荒地老。本节从一次发布后的全站 hang 讲起,覆盖死锁四要素、锁顺序的工程约束、tryLock 超时破环、synchronized 的锁升级过程,以及 AQS 与显式锁的适用边界。

事故现场:发布 40 分钟后全站 hang

转账服务新版本发布后一切正常,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); } } }

所有获取多把锁的路径遵守同一全序,死锁在拓扑上不可能。难点不在写法而在纪律:锁序是全局属性,任何一处违反(尤其老代码和新人代码对同一组锁排序不同)就前功尽弃。

死锁形成时序

第二道防线:tryLock 破坏"不可剥夺"

顺序约束失效或无法统一时,用显式锁的尝试获取 + 超时让路

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() 可以做成运行时探针,巡检到死锁自动报警甚至自杀重启——长事务系统里"检测到死锁就自动摘流量"是常见的自愈手段。

synchronized 的锁升级:为什么"简单锁"也不便宜

JDK 6 之后 synchronized 做了一整套优化,锁状态沿 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 单向升级(JDK 15 起偏向锁被废弃,路径简化为轻量级起步):

  • 偏向锁:只有一个线程反复获取时,对象头记下线程 id,后续进入近乎零成本(因维护成本高于收益被移除)
  • 轻量级锁:短时竞争,线程栈上的 Lock Record + CAS 自旋
  • 重量级锁:竞争激烈,膨胀为操作系统互斥量,线程挂起/唤醒涉及内核态切换——上下文切换的微秒级成本就在这

要点是锁会膨胀且默认不回退:一场突发流量让某把锁升到重量级,高峰过后它仍以重量级语义运行。长锁 + 高并发下,jstack 会看到大量 BLOCKED 在 monitor 上的线程。显式 ReentrantLock 的适用场景:需要 tryLock 超时、需要公平锁、需要多个 Condition 队列(生产者消费者的精准唤醒)。其余场景 synchronized 足够——JIT 对它的锁消除(逃逸分析证明对象不共享,锁直接删掉)与锁粗化(相邻同步块合并)是自动的。

读写锁与 CAS 的位置

读多写少的共享结构用 ReentrantReadWriteLock,读锁共享写锁独占;更进一步的 StampedLock 支持乐观读(读时不加锁,读完校验版本,失败再升级悲观读),吞吐更高但不可重入、用法苛刻。无锁路线的 CAS(compare-and-swap)是 Atomic 类与 LongAdder 的地基,适合简单状态的原子更新;复杂状态用 CAS 手搓循环容易写错(ABA 问题要 AtomicStampedReference 兜),不是默认选项。它们共同的爹是 AQS(AbstractQueuedSynchronizer):一个 volatile 状态位 + CLH 等待队列,把"获取-释放"抽象成模板,ReentrantLock、Semaphore、CountDownLatch 全是它的子类——读 JUC 源码从 AQS 入手是捷径。

⚠️ 常见坑:锁内调用外部回调/RPC。持锁时间从纳秒级拉到毫秒级,吞吐塌方且死锁概率放大一个数量级——锁内只做内存操作,外部调用挪到锁外。

💡 关键直觉:死锁不是"锁太多",是"顺序太乱"。把系统里所有锁的获取顺序画成一张有向图,不允许出现环——评审时能画出这张图的服务,死锁概率约等于零。

防坑清单

  • 多把锁的获取定义全局全序(按 id、按固定枚举序),评审检查有向图无环
  • 不可控场景用 tryLock + 超时 + 回退,破坏不可剥夺条件
  • 锁内禁止 IO/RPC/回调,持锁时长进监控
  • 长流程系统部署死锁探针(ThreadMXBean),自动报警
  • 显式锁仅用于 tryLock/公平性/Condition 需求,默认 synchronized

本节要点回顾

  • 四要素:互斥、持有并等待、不可剥夺、循环等待;工程抓手是循环等待
  • 全局锁序:按对象 id 等全序加锁,环闭不上死锁不成立
  • tryLock:超时让路是第二道防线,配退避重试
  • 锁升级:偏向(已废)→ 轻量级 CAS → 重量级 monitor,膨胀不轻易回退
  • AQS:JUC 锁与同步器的公共骨架,volatile 状态 + 等待队列

下一节看并发事故的容量版本:线程池打满。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U