死锁排查与预防(jstack 实战)
问题的提出
死锁(Deadlock)是并发编程中最让人头疼的问题之一——程序没有崩溃、没有异常、CPU 占用也不高,但业务就是卡住了。更麻烦的是,死锁往往不在开发环境复现,只在生产环境偶发,常规日志看不到任何线索。
本文从死锁的 4 个必要条件出发,讲清楚 jstack 怎么查、查到了怎么看、看完了怎么修,最后给出一个可复用的生产排查流程。面试问到「高并发下死锁怎么排查」,照着这套思路答,比背八股文强一个档次。
死锁的 4 个必要条件
死锁不是玄学,它有明确的数学条件。4 个条件必须同时满足:
- 互斥(Mutual Exclusion):资源一次只能被一个线程占用
- 持有并等待(Hold and Wait):线程持有至少一个资源,同时等待其他线程持有的资源
- 不可剥夺(No Preemption):已分配的资源不能被强制剥夺,只能由持有者主动释放
- 循环等待(Circular Wait):线程之间形成「A 等 B、B 等 C、C 等 A」的环
只要破坏任意一个条件,死锁就不成立。大部分预防手段就是对着这 4 条逐个击破。
数学形式化
面试进阶:循环等待可以抽象为资源分配图(Resource Allocation Graph, RAG)。死锁等价于 RAG 中存在环,但这个环必须被「不可剥夺」条件加持才会形成死锁。换句话说:RAG 有环 ≠ 死锁。如果环中的资源是可剥夺的(比如 CPU 时间片),系统可以通过剥夺打破环,不会死锁。
RAG 判断死锁的充要条件:当且仅当 RAG 中存在一个环,且环中所有资源都是不可剥夺类型(比如 synchronized monitor 锁、数据库行锁),且环中每个线程都处于「持有并等待」状态,才构成死锁。
死锁的必要条件在生产中的实际含义
| 条件 | 业务场景 | 常见触发方式 |
|---|---|---|
| 互斥 | 共享资源加锁(库存、账户余额、配置项) | 用 synchronized 或 ReentrantLock 保护临界区 |
| 持有并等待 | 一个方法内获取了锁 A,再试图获取锁 B | 嵌套 synchronized 块,或 @Transactional 内调用加锁方法 |
| 不可剥夺 | 使用了普通锁(JDK 内置锁默认不可剥夺) | synchronized 或 ReentrantLock 非公平模式下 |
| 循环等待 | 线程 1 锁 A 等 B,线程 2 锁 B 等 A | 两个方法按相反顺序获取同一组锁 |
时序图:死锁形成过程
线程 A 线程 B
| |
|-- synchronized(lockX) |
| 持有 lockX |
| |-- synchronized(lockY)
| | 持有 lockY
| |
|-- synchronized(lockY) |
| ⏳ 等待 lockY... |
| |-- synchronized(lockX)
| | ⏳ 等待 lockX...
| |
| ⚠ 循环等待形成 |
| A 等 B 的 lockY |
| B 等 A 的 lockX |
| → 死锁成立,程序冻结 |先写一个死锁,感知它
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("t1 持有 lockA,等待 lockB...");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lockB) {
System.out.println("t1 获取了 lockB");
}
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("t2 持有 lockB,等待 lockA...");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lockA) {
System.out.println("t2 获取了 lockA");
}
}
}, "Thread-2");
t1.start();
t2.start();
t1.join();
t2.join();
}
}运行后程序卡住,输出类似:
t1 持有 lockA,等待 lockB...
t2 持有 lockB,等待 lockA...卡住不动了,但 jstack 一看就明白。
jstack 排查死锁(最常用)
步骤
# 1. 找到 Java 进程
jps -l
# 输出:12345 DeadlockDemo
# 2. 获取线程堆栈
jstack -l 12345
# 3. 也可以输出到文件慢慢看
jstack -l 12345 > deadlock.dump生产环境替代方案:如果线上容器没有 jstack 命令(比如 Alpine 基础镜像),可以用 jattach 替代:
# 下载 jattach(单文件,无需 JDK)
curl -L -o /usr/local/bin/jattach \
https://github.com/apangin/jattach/releases/download/v2.2/jattach
chmod +x /usr/local/bin/jattach
# 等价于 jstack -l <pid>
jattach <pid> threaddump
# 等价于 jmap -heap <pid>
jattach <pid> dumpheap怎么看输出
jstack 的输出里,死锁信息在最前面,直接搜 "Found one Java-level deadlock":
Found one Java-level deadlock:
=============================
"Thread-2":
waiting to lock monitor 0x00007f8b9c000000 (object 0x000000076b5a6c10, a java.lang.Object),
which is held by "Thread-1"
"Thread-1":
waiting to lock monitor 0x00007f8b9c000000 (object 0x000000076b5a6c20, a java.lang.Object),
which is held by "Thread-2"
Java stack information for the threads listed above:
===================================================
"Thread-2":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:24)
- waiting to lock <0x000000076b5a6c10> (a java.lang.Object)
- locked <0x000000076b5a6c20> (a java.lang.Object)
"Thread-1":
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:13)
- waiting to lock <0x000000076b5a6c20> (a java.lang.Object)
- locked <0x000000076b5a6c10> (a java.lang.Object)信息非常清晰:
- Thread-1 持有
0x...6c10,等待0x...6c20 - Thread-2 持有
0x...6c20,等待0x...6c10 - 循环等待,死锁成立
生产环境快速定位技巧
当有几十个线程都在 BLOCKED 状态时,靠眼扫效率太低。用命令行管道过滤:
# 只提取死锁相关的线程信息
jstack -l <pid> | grep -A 30 "Found one Java-level deadlock"
# 统计各状态线程数,快速判断问题类型
jstack -l <pid> | grep "java.lang.Thread.State" | sort | uniq -c
# 输出示例:
# 2 java.lang.Thread.State: BLOCKED ← 死锁嫌疑
# 12 java.lang.Thread.State: RUNNABLE
# 4 java.lang.Thread.State: TIMED_WAITING
# 2 java.lang.Thread.State: WAITING ← 线程池饥饿嫌疑
# 如果 BLOCKED 线程数 > 3,基本可以确认死锁或严重锁竞争jstack 各字段解析(面试重点)
jstack 输出里三种常见的线程状态,面试经常追问区别:
| 状态 | 含义 | 死锁相关 | 典型场景 |
|---|---|---|---|
BLOCKED | 线程被阻塞,等待 monitor 锁 | 死锁核心标志 | synchronized 竞争 |
WAITING (on object monitor) | 调用了 Object.wait() / LockSupport.park() | 常伴随死锁 | Condition.await()、Future.get() |
TIMED_WAITING | 带超时的等待 | 可能涉及活锁 | Thread.sleep()、LockSupport.parkNanos() |
死锁排查时,重点关注 BLOCKED 和 WAITING 状态的线程。如果大量 BLOCKED 线程集中在两三个 monitor 地址上,且这些 monitor 被不同线程持有,基本可以确认死锁。
两种更快的排查方式
jconsole 图形化
JDK 自带的 jconsole 连接进程后,点击「线程」→「检测死锁」,直接展示死锁线程和锁的持有关系图,适合快速排查。
VisualVM + VisualGC 插件
生产环境推荐用 VisualVM attach 到进程,死锁检测会以红色高亮显示,还能看到锁的持有链和锁等待耗时。用 jvisualvm 命令启动,插件市场里有各类诊断工具。
async-profiler 火焰图
如果死锁伴随 CPU 高(比如活锁场景),async-profiler 的火焰图能直接看出热点代码:profiler.sh -d 60 -f flamegraph.html <pid>。如果火焰图里某个锁定代码段占用大量 CPU 时间,但 jstack 看不出死锁,大概率是活锁或忙等。
ThreadMXBean 程序化检测
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class DeadlockDetector {
private static final ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
public static void detectDeadlock() {
long[] deadlockedIds = mbean.findDeadlockedThreads();
if (deadlockedIds != null) {
ThreadInfo[] infos = mbean.getThreadInfo(deadlockedIds, true, true);
System.err.println("检测到死锁!");
for (ThreadInfo info : infos) {
System.err.printf("线程: %s (ID=%d) 状态: %s%n",
info.getThreadName(), info.getThreadId(), info.getThreadState());
for (MonitorInfo monitor : info.getLockedMonitors()) {
System.err.printf(" 持有锁: %s%n", monitor);
}
System.err.printf(" 等待锁: %s%n", info.getLockInfo());
}
}
}
}把这个 detectDeadlock() 方法放到定时任务里(比如每 10 秒执行一次),配合监控告警,可以做到死锁发生后自动 dump 堆栈 + 告警,不用等用户反馈。
ThreadMXBean 检测原理(面试加分)
ThreadMXBean.findDeadlockedThreads() 底层实现依赖 JVM 的线程死锁检测器,这不是简单的线程状态扫描——它会在 JVM 层面构建一个 wait-for graph(资源等待图),然后对这个图执行拓扑排序或环检测算法(类似 Tarjan 的强连通分量检测)。如果图中存在强连通分量(SCC),且 SCC 中的每个线程都在等待 SCC 中另一个线程持有的锁,则判定为死锁。
注意 findDeadlockedThreads() 和 findMonitorDeadlockedThreads() 的区别:
| 方法 | 检测范围 | 适用场景 |
|---|---|---|
findMonitorDeadlockedThreads() | 只检测 synchronized 的 monitor 锁死锁 | 老项目,纯 synchronized 代码 |
findDeadlockedThreads() | 检测 synchronized + java.util.concurrent.locks 的死锁 | 新项目,用了 ReentrantLock、StampedLock 等 |
面试被问到「程序化检测死锁的原理」,答出这个区别就是加分项。
死锁预防方案对比
| 方案 | 破坏的条件 | 难度 | 适用场景 | 踩坑点 |
|---|---|---|---|---|
| 固定锁顺序 | 循环等待 | ⭐ 低 | 多锁场景,锁的类型已知 | hashCode 可能冲突(极小概率),需用 TieBreak 兜底 |
| tryLock 超时 | 持有并等待 | ⭐⭐ 中 | 锁类型为 ReentrantLock | 超时时间设太短容易活锁,太长又失去意义 |
| 减少锁粒度 | 互斥(间接) | ⭐⭐⭐ 高 | 高并发、热点数据 | 分段数设计不好反而降低性能 |
| 死锁检测线程 | 不破坏条件,但发现快 | ⭐⭐ 中 | 任何场景 | 检测到死锁后的恢复策略要设计好 |
方案一:固定锁顺序(最有效,首推)
如果两个线程总是以相同的顺序获取锁,循环等待就不可能发生。
// 坏:锁顺序取决于方法参数
public void transfer(Account from, Account to, int amount) {
synchronized (from) {
synchronized (to) {
from.debit(amount);
to.credit(amount);
}
}
}
// 两个线程同时调用 transfer(a, b, 100) 和 transfer(b, a, 200) 时死锁
// 真实案例:某支付系统转账接口线上偶发超时,排查发现就是因为这个// 好:用 System.identityHashCode 固定锁顺序
public void transfer(Account from, Account to, int amount) {
Object firstLock, secondLock;
int fromHash = System.identityHashCode(from);
int toHash = System.identityHashCode(to);
if (fromHash < toHash) {
firstLock = from; secondLock = to;
} else if (fromHash > toHash) {
firstLock = to; secondLock = from;
} else {
// hashCode 冲突(极低概率,约 1/2^32),用第三个锁兜底
synchronized (TIE_LOCK) {
synchronized (from) {
synchronized (to) {
from.debit(amount);
to.credit(amount);
}
}
}
return;
}
synchronized (firstLock) {
synchronized (secondLock) {
from.debit(amount);
to.credit(amount);
}
}
}核心思想:不管传参顺序如何,内部总是先锁 hashCode 小的,再锁大的。循环等待条件被破坏。
踩坑实录:某电商库存服务的扣减接口,transfer(warehouseA, warehouseB, qty) 和 transfer(warehouseB, warehouseA, qty) 同时调用,高峰期每 10 分钟触发一次死锁,导致库存扣减失败、订单超卖。加固定锁顺序后,线上零死锁。
固定锁顺序的局限性:当锁数量超过 2 个时,锁顺序的排序逻辑会变得复杂。比如一个方法需要锁 A、B、C,另一个方法需要锁 C、D、E,第三个方法需要锁 E、F、A,这时候排序不是简单的比较大小就能解决,需要按全局唯一编号排序,否则仍然可能形成环。
方案二:超时放弃(tryLock)
用 ReentrantLock.tryLock(timeout) 代替 synchronized,获取不到就释放已有锁,避免无限等待。
private final Lock lockA = new ReentrantLock();
private final Lock lockB = new ReentrantLock();
public void doWork() throws InterruptedException {
while (true) {
if (lockA.tryLock(1, TimeUnit.SECONDS)) {
try {
// 持有 lockA,尝试获取 lockB
if (lockB.tryLock(1, TimeUnit.SECONDS)) {
try {
System.out.println("获取了两个锁,干活");
return;
} finally {
lockB.unlock();
}
}
} finally {
// 获取 lockB 失败,释放 lockA,等会重试
lockA.unlock();
}
}
// 等一会再重试,避免活锁
Thread.sleep(50);
}
}关键参数:tryLock 的超时时间不是越长越好。设 100ms 在高峰期可能几乎每次都超时退避,导致活锁;设 10s 又失去了超时的意义。经验值:锁竞争激烈时 1~3s,低竞争时 500ms。
tryLock 的活锁风险:两个线程都 tryLock 成功获取了锁 A,然后都尝试获取锁 B 失败,同时释放锁 A,然后同时重试——又同时获取锁 A,又同时失败。这就是活锁。解决方案:Thread.sleep() 里加随机值,比如 Thread.sleep(50 + (int)(Math.random() * 50)),打散时间窗口。
synchronized 与 ReentrantLock 的死锁预防差异:synchronized 不提供超时机制,一旦获取锁就无限等待。ReentrantLock 的 tryLock(timeout, unit) 可以在超时后放弃,破坏「持有并等待」条件。这是面试常问的「synchronized vs ReentrantLock 你选哪个」的底层论据之一。
方案三:减少锁粒度
锁越少,死锁概率越低。能用 CAS 不用锁,能用分段锁不用全局锁。
// 全局锁 → 分段锁
private final StripedLock locks = StripedLock.lazyStriped(16);
public void process(int id) {
Lock lock = locks.get(id % 16);
lock.lock();
try { /* 业务逻辑 */ } finally { lock.unlock(); }
}StripedLock 本质是 Striped<Lock>,内部维护一个固定大小的锁数组,通过 key 的 hashCode 取模定位到同一个 Lock 实例。这样 16 个分段意味着任一线程最多和 1/16 的线程竞争,死锁概率大幅降低。
分段锁在真实项目中的典型应用:
| 业务场景 | 分段策略 | 分段数 | 实际效果 |
|---|---|---|---|
| 库存扣减 | 按 warehouse_id 分段 | 64 | 从全局锁到并发度 64 倍 |
| 用户余额更新 | 按 user_id 分段 | 256 | 消除热点账户锁竞争 |
| 优惠券核销 | 按 coupon_batch_id 分段 | 32 | 大促期间死锁告警降为零 |
但注意:分段锁不消灭死锁,只降低概率。如果两个线程最终进入同一个分段,锁顺序还是可能出问题。所以分段锁 + 固定锁顺序一起用效果最好。
隐式死锁(更隐蔽)
真正让生产环境头疼的是那些不直接表现为 synchronized 的死锁:
数据库锁 + 应用锁混合死锁
// 事务 A
@Transactional
public void methodA() {
synchronized (lockX) {
jdbcTemplate.update("UPDATE accounts SET balance=? WHERE id=1", amount);
jdbcTemplate.update("UPDATE accounts SET balance=? WHERE id=2", -amount);
}
}
// 事务 B
@Transactional
public void methodB() {
synchronized (lockX) {
jdbcTemplate.update("UPDATE accounts SET balance=? WHERE id=2", amount);
jdbcTemplate.update("UPDATE accounts SET balance=? WHERE id=1", -amount);
}
}这里有两层锁:应用层的 synchronized(lockX) 和数据库层的行锁。如果多个线程的执行顺序不同,可能在应用层和数据库层形成交叉等待。jstack 只能看到应用层的锁,数据库层的死锁只能从数据库的 SHOW ENGINE INNODB STATUS 里看到。
典型排查流程:
- 接口超时告警,TPS 骤降
- 马上
jstack -l <pid>,结果没有 "Found one Java-level deadlock" - 再查数据库:
mysql> SHOW ENGINE INNODB STATUS\G - 看到
LATEST DETECTED DEADLOCK部分,显示两个事务分别更新了 id=1 和 id=2 的行锁 - 修复:SQL 内的更新顺序统一按主键排序
真实案例:某订单系统在 618 大促期间,数据库死锁日志显示 LATEST DETECTED DEADLOCK,但 jstack 一切正常。排查发现是应用层先锁了对象,再执行 SQL,而 SQL 内部的更新顺序不一致导致数据库行锁死锁。修复方案:统一 SQL 内的更新顺序,并移除应用层不必要的锁。
活锁(Livelock)
活锁比死锁更难排查——线程没有阻塞,但一直在做无用功。jstack 显示的状态是 RUNNABLE,不会出现 BLOCKED。排查方法:top -H 看哪些线程 CPU 占用高,然后 jstack 定位它们的执行位置。
活锁 vs 死锁对比:
| 对比项 | 死锁 | 活锁 |
|---|---|---|
| 线程状态 | BLOCKED 或 WAITING | RUNNABLE |
| CPU 占用 | 低(几乎 0%) | 高(忙等循环) |
| jstack 特征 | "Found one Java-level deadlock" | 看不到异常,但大量线程在同一个循环代码 |
| 恢复方式 | 重启进程 | 加随机退避(Thread.sleep 加随机值) |
活锁的典型代码表现:
// 活锁示例:两个线程互相谦让
public void tryTransfer() {
while (true) {
if (lockA.tryLock()) {
try {
if (lockB.tryLock()) {
try {
// 干活
return;
} finally {
lockB.unlock();
}
}
} finally {
lockA.unlock();
}
}
// 没有退避,直接重试 → 活锁
}
}注意这里没有 Thread.sleep(),没有随机退避,两个线程可能以完全相同的节奏失败和重试,形成活锁。修复:Thread.sleep(50 + (int)(Math.random() * 50))。
资源死锁(线程池饥饿)
线程池 + 锁的组合也可能死锁:
ExecutorService pool = Executors.newFixedThreadPool(2);
public void submit() {
pool.submit(() -> {
// 任务 A 需要等任务 B 的结果
Future<?> future = pool.submit(() -> {
System.out.println("task B");
});
future.get(); // 线程池只有 2 个线程,都被占用了
});
}两个线程都卡在 future.get() 上等待线程池里的任务完成,但线程池已满,永远不会有空闲线程来执行被提交的任务。这就是线程池饥饿死锁。修复:用无界线程池(不推荐)、或提前评估线程池大小、或用 CompletableFuture 异步编排。
线程池饥饿死锁的判定标准:jstack 中看到大量线程处于 WAITING (parking) 状态,堆栈指向 java.util.concurrent.FutureTask.awaitDone(),并且这些线程都属于同一个线程池,那么 100% 是线程池饥饿死锁。
生产实战:某数据同步服务,用 newFixedThreadPool(4) 并行处理 4 个分片的数据,每个分片处理到一半又往线程池提交了子任务,子任务靠 Future.get() 等待结果。上线后每 10 分钟全挂一次,jstack 显示所有线程都在 WAITING 状态且堆栈都是 Future.get()。修复:子任务改成同步执行,或者线程池用 newCachedThreadPool 临时扩线程。
生产环境排查流程
1. 业务告警(接口超时、请求堆积、TPS 骤降)
↓
2. jstack 快速检查
jstack -l <pid> | grep -A 20 "Found one Java-level deadlock"
↓
3. 有死锁 → 修复代码(固定锁顺序 或 tryLock 超时)
没死锁 → 检查是否线程池饥饿
↓
4. 线程池饥饿检查
jstack -l <pid> | grep -E "pool-.*-thread" | head -20
看是否有大量线程处于 WAITING 状态且在调用 Future.get()
↓
5. 还不是线程池饥饿 → 检查数据库死锁
mysql> SHOW ENGINE INNODB STATUS\G
搜索 "LATEST DETECTED DEADLOCK"
↓
6. 还是没发现 → 检查活锁(top -H 看 CPU 占用高的线程,jstack 定位热点代码)
↓
7. 修复后,加入 ThreadMXBean 自动检测,
配合监控告警,下次死锁秒级感知生产事故复盘:一次完整的死锁排查
背景:某电商平台订单服务,采用 Spring Boot + MySQL + Redis,部署 4 节点。某日大促期间,订单接口 P99 延迟从 30ms 飙升到 12s,部分节点完全不可用。
排查过程:
- 告警触发:监控告警
order.create接口 P99 > 5s,持续 3 分钟 - jstack 初检:
jstack -l <pid> | grep -A 30 "Found one"→ 输出 2 组死锁 - 死锁分析:
- Thread-A(HTTP-nio-8080-exec-12)持有
InventoryService.lock,等待OrderService.lock - Thread-B(HTTP-nio-8080-exec-14)持有
OrderService.lock,等待InventoryService.lock - 同时还有 8 个线程在
BLOCKED状态排队等这两个锁
- Thread-A(HTTP-nio-8080-exec-12)持有
- 代码定位:两个方法:
createOrder():先锁InventoryService.lock,再锁OrderService.lockcancelOrder():先锁OrderService.lock,再锁InventoryService.lock
- 修复:统一锁顺序,
createOrder()和cancelOrder()都先锁hashCode较小的锁 - 加 ThreadMXBean 自检:定时任务每 30 秒检测死锁,检测到后自动发企业微信告警并 dump 堆栈
教训:该代码上线 3 个月从未触发死锁,因为大促前流量只有平时的 1/10,两个方法同时调用的概率极低。大促流量放大 10 倍后,概率问题变成必然问题。低并发环境看不到的死锁,不代表不存在。
面试高频追问与回答思路
Q1: 死锁肯定会导致程序挂掉吗?
不一定。死锁只导致涉及到的线程卡住,不影响其他线程。如果卡住的是非核心线程(比如后台统计线程),业务可能不受影响,但资源池会持续被占。如果卡住的是 Tomcat 请求处理线程,会导致请求堆积、服务不可用。
补充:Tomcat 默认线程池 200 个线程,如果 10 个线程卡死在死锁上,剩下 190 个还能正常处理。但如果 200 个里 150 个都在 BLOCKED 状态(排队等死锁的锁),那这 150 个线程也等于废了,实际可用线程只剩 50 个,接口能力降为 1/4。
Q2: jstack 一定能检测到死锁吗?
jstack 只能检测 Java 级别的死锁(synchronized 和 ReentrantLock 导致的)。它检测不到:
- 数据库行锁死锁(需要看
SHOW ENGINE INNODB STATUS) - 操作系统级别的死锁(比如文件锁、网络 socket 阻塞)
- 活锁(线程状态是 RUNNABLE,没死锁但卡住了)
- 分布式锁死锁(Redis Redlock、ZooKeeper 锁,需要看中间件日志)
Q3: 分布式系统里也存在死锁吗?
是的。分布式锁(Redis Redlock、ZooKeeper 锁等)如果获取顺序不一致,也会形成分布式死锁。排查手段没有 jstack 这么方便,需要靠分布式链路追踪 + 超时日志。预防手段和单机一样:固定锁顺序 + 超时放弃。
分布式死锁的特殊性:分布式锁的超时机制可以被用来破坏「不可剥夺」条件——如果锁设置了租约时间(lease time),超时后锁自动释放,相当于被「剥夺」了。但这也带来了新的问题:锁超时后业务还没执行完,会导致数据不一致。所以用 Redis 锁做分布式死锁预防时,租约时间要设置为业务执行时间的 3~5 倍余量。
Q4: 你怎么预防死锁?
先回答破坏循环等待(固定锁顺序,最推荐),再说破坏持有并等待(tryLock 超时),最后提降低锁粒度。如果面试官追问「线上已经出现了,怎么快速止血」,回答:kill -3 或 jstack 抓堆栈,确定死锁线程后重启或 kill 掉其中一个线程(如果是手动设计的可中断方式)。
Q5: Agent 系统里有哪些特殊的死锁场景?(转型 Agent 工程师必看)
Agent 系统的死锁不是线程级锁,而是资源级和编排级的,排查手段也不同:
场景一:LLM 调用 + 共享资源锁定
// Agent 处理消息时,先锁用户会话,再调 LLM
public class AgentSession {
private final Lock sessionLock = new ReentrantLock();
public String processMessage(String userId, String msg) {
sessionLock.lock(); // 锁住用户会话
try {
// 调 LLM → 可能 2-10 秒
String reply = llmService.chat(userId, msg);
// 更新上下文
updateContext(userId, msg, reply);
return reply;
} finally {
sessionLock.unlock();
}
}
}如果另一个 Agent 线程持有 sessionLock 也在调 LLM,两者都卡在 LLM 调用上,锁释放不了。这不是典型的死锁,但现象一样——线程等锁,锁等 LLM 返回。
修复方案:用 tryLock + 超时,或者把锁粒度降到 LLM 调用之外:
public String processMessage(String userId, String msg) {
// 只有在读写上下文时才加锁,LLM 调用在外面
String context;
sessionLock.lock();
try {
context = getContext(userId);
appendMessage(userId, msg);
} finally {
sessionLock.unlock();
}
String reply = llmService.chat(userId, context, msg);
sessionLock.lock();
try {
appendMessage(userId, reply);
return reply;
} finally {
sessionLock.unlock();
}
}场景二:Agent 工具调用中的资源死锁
LangChain / 自定义 Agent 框架里,多个工具共享同一个外部资源池:
// Agent 同时调用两个工具,共享同一个 HTTP 连接池
public class AgentExecutor {
private final ExecutorService toolPool = Executors.newFixedThreadPool(4);
public String executePlan(List<ToolCall> calls) {
// 顺序执行,每个工具调用等上一个结果
for (ToolCall call : calls) {
Future<String> f = toolPool.submit(() -> callTool(call));
// 工具调用的结果需要作为参数传给下一个工具
String result = f.get(); // 阻塞等待
}
}
}如果 toolPool 只有 4 个线程,但 Agent 的一个推理循环需要嵌套 5 层工具调用,第 5 层永远等不到线程——和线程池饥饿死锁一模一样。
修复方案:
- 不要让 Agent 单步推理占用线程池线程,改成异步编排(
CompletableFuture链式调用) - 或者为 Agent 推理和工具调用分别设置独立的线程池,避免互相阻塞
- 或者用响应式框架(WebFlux、Vert.x)处理 Agent 调用链
场景三:分布式 Agent 协调中的死锁
多 Agent 协作场景(AutoGen、CrewAI 风格),Agent A 持有某工具的执行权,同时等待 Agent B 的数据;Agent B 在等待 Agent C 的数据;Agent C 需要 Agent A 的工具结果。形成跨进程的循环等待。
这种死锁 jstack 看不到,需要靠分布式链路追踪 + 超时兜底。排查手段:
- 在 Agent 调用链中注入 TraceID,记录每个 Agent 的输入输出和等待关系
- 设置每个 Agent 调用的超时时间(LLM 调用本身一般在 2-30s,超时设 60s 比较合理)
- 超时后强制释放资源,记录失败链路
场景四:Redis 分布式锁 + Agent 会话管理
Agent 系统常用 Redis 分布式锁来管理用户会话的独占访问(避免同一个用户的消息被并发处理)。但 Redis 锁超时时间是个双刃剑:
| 锁超时 | 问题 |
|---|---|
| 设太短(< 5s) | Agent 处理慢(LLM 响应慢或工具调用慢)时,锁超时自动释放,另一个请求进来,两个 Agent 实例同时处理同一个用户的会话,上下文混乱 |
| 设太长(> 30s) | 如果 Agent 实例挂了,锁一直不释放,该用户的所有请求都被阻塞,直到锁过期 |
修复方案:用 Redlock 的 Watchdog 机制(类似 Redisson 的 watchdog),每 1/3 租约时间自动续期,保证业务不结束锁不过期。同时设置最大租约时间(比如 60s),超过这个时间强制释放锁,避免死锁无限持续。
面试官可能追问的深层问题:
「为什么固定锁顺序是最推荐的?」因为它代码改动最小、逻辑最清晰、不需要引入新的锁类型,只需要调整获取锁的顺序。相比 tryLock 需要把
synchronized改成ReentrantLock,侵入性更低。「tryLock 和 synchronized 能混用吗?」不能。
synchronized的 monitor 锁无法被tryLock检测到,ThreadMXBean.findDeadlockedThreads()虽然能检测,但synchronized没有超时机制,无法自动退避。所以一旦选了tryLock方案,所有相关锁都要换成ReentrantLock。「死锁检测线程发现死锁后怎么恢复?」最安全的做法是记录告警 + dump 堆栈后重启进程。不推荐在检测线程中强行中断线程(
Thread.interrupt()),因为无法保证中断后数据一致。如果业务可以接受回滚,可以设计补偿机制:检测到死锁后,记录未完成的操作 ID,重启后执行补偿。「Agent 面试中死锁相关会被问到什么?」面试官可能会问:Agent 系统的 LLM 调用是 IO 密集型,锁竞争场景和传统后端有什么区别?Agent 编排中的死锁怎么监控?答案要点:① Agent 的锁竞争以 IO 等待为主(LLM 调用、工具 API),不是 CPU 计算;② 传统 jstack 在 Agent 场景只能看到「线程在等外部服务」,需要结合链路追踪和超时告警;③ Agent 的锁范围要尽量小,不要把 LLM 调用放在锁集型,锁竞争场景和传统后端有什么区别?Agent 编排中的死锁怎么监控?答案要点:① Agent 的锁竞争以 IO 等待为主(LLM 调用、工具 API),不是 CPU 计算;② 传统 jstack 在 Agent 场景只能看到「线程在等外部服务」,需要结合链路追踪和超时告警;③ Agent 的锁范围要尽量小,不要把 LLM 调用放在锁内。
总结
- 死锁的 4 个必要条件:互斥、持有并等待、不可剥夺、循环等待,破坏任意一个即可防止死锁
- jstack 排查:
jstack -l <pid>顶部直接输出死锁链路,搜 "Found one Java-level deadlock"。排查时关注BLOCKED和WAITING状态的线程;生产环境用grep -c统计各状态线程数快速判断问题类型 - 程序化检测:
ThreadMXBean.findDeadlockedThreads()底层是 wait-for graph 环检测,可检测ReentrantLock死锁;findMonitorDeadlockedThreads()只检测synchronized死锁。两者区别是面试加分点 - 最有效的预防:固定锁顺序(按
System.identityHashCode排序,加 TieBreak 兜底),次选tryLock(timeout)超时放弃(注意活锁风险,加随机退避) - 隐式死锁更隐蔽:数据库锁 + 应用锁混合死锁、活锁需要用
top -H+jstack配合排查、线程池饥饿死锁需要看线程状态分布 - 排查优先级:jstack → 线程池饥饿 → 数据库死锁 → 活锁
- 线上服务监控中集成死锁检测,做到死锁发生后自动 dump 堆栈并告警,不等用户反馈
- 低并发环境看不到的死锁不代表不存在,流量放大后概率问题变成必然问题