Skip to content

死锁排查与预防(jstack 实战)

问题的提出

死锁(Deadlock)是并发编程中最让人头疼的问题之一——程序没有崩溃、没有异常、CPU 占用也不高,但业务就是卡住了。更麻烦的是,死锁往往不在开发环境复现,只在生产环境偶发,常规日志看不到任何线索。

本文从死锁的 4 个必要条件出发,讲清楚 jstack 怎么查、查到了怎么看、看完了怎么修,最后给出一个可复用的生产排查流程。面试问到「高并发下死锁怎么排查」,照着这套思路答,比背八股文强一个档次。

死锁的 4 个必要条件

死锁不是玄学,它有明确的数学条件。4 个条件必须同时满足:

  1. 互斥(Mutual Exclusion):资源一次只能被一个线程占用
  2. 持有并等待(Hold and Wait):线程持有至少一个资源,同时等待其他线程持有的资源
  3. 不可剥夺(No Preemption):已分配的资源不能被强制剥夺,只能由持有者主动释放
  4. 循环等待(Circular Wait):线程之间形成「A 等 B、B 等 C、C 等 A」的环

只要破坏任意一个条件,死锁就不成立。大部分预防手段就是对着这 4 条逐个击破。

数学形式化

面试进阶:循环等待可以抽象为资源分配图(Resource Allocation Graph, RAG)。死锁等价于 RAG 中存在,但这个环必须被「不可剥夺」条件加持才会形成死锁。换句话说:RAG 有环 ≠ 死锁。如果环中的资源是可剥夺的(比如 CPU 时间片),系统可以通过剥夺打破环,不会死锁。

RAG 判断死锁的充要条件:当且仅当 RAG 中存在一个环,且环中所有资源都是不可剥夺类型(比如 synchronized monitor 锁、数据库行锁),且环中每个线程都处于「持有并等待」状态,才构成死锁。

死锁的必要条件在生产中的实际含义

条件业务场景常见触发方式
互斥共享资源加锁(库存、账户余额、配置项)synchronizedReentrantLock 保护临界区
持有并等待一个方法内获取了锁 A,再试图获取锁 B嵌套 synchronized 块,或 @Transactional 内调用加锁方法
不可剥夺使用了普通锁(JDK 内置锁默认不可剥夺)synchronizedReentrantLock 非公平模式下
循环等待线程 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       |
  |   → 死锁成立,程序冻结  |

先写一个死锁,感知它

java
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 排查死锁(最常用)

步骤

bash
# 1. 找到 Java 进程
jps -l
# 输出:12345 DeadlockDemo

# 2. 获取线程堆栈
jstack -l 12345

# 3. 也可以输出到文件慢慢看
jstack -l 12345 > deadlock.dump

生产环境替代方案:如果线上容器没有 jstack 命令(比如 Alpine 基础镜像),可以用 jattach 替代:

bash
# 下载 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 状态时,靠眼扫效率太低。用命令行管道过滤:

bash
# 只提取死锁相关的线程信息
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()

死锁排查时,重点关注 BLOCKEDWAITING 状态的线程。如果大量 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 程序化检测

java
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 的死锁新项目,用了 ReentrantLockStampedLock

面试被问到「程序化检测死锁的原理」,答出这个区别就是加分项。

死锁预防方案对比

方案破坏的条件难度适用场景踩坑点
固定锁顺序循环等待⭐ 低多锁场景,锁的类型已知hashCode 可能冲突(极小概率),需用 TieBreak 兜底
tryLock 超时持有并等待⭐⭐ 中锁类型为 ReentrantLock超时时间设太短容易活锁,太长又失去意义
减少锁粒度互斥(间接)⭐⭐⭐ 高高并发、热点数据分段数设计不好反而降低性能
死锁检测线程不破坏条件,但发现快⭐⭐ 中任何场景检测到死锁后的恢复策略要设计好

方案一:固定锁顺序(最有效,首推)

如果两个线程总是以相同的顺序获取锁,循环等待就不可能发生。

java
// 坏:锁顺序取决于方法参数
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) 时死锁
// 真实案例:某支付系统转账接口线上偶发超时,排查发现就是因为这个
java
// 好:用 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,获取不到就释放已有锁,避免无限等待。

java
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 不提供超时机制,一旦获取锁就无限等待。ReentrantLocktryLock(timeout, unit) 可以在超时后放弃,破坏「持有并等待」条件。这是面试常问的「synchronized vs ReentrantLock 你选哪个」的底层论据之一。

方案三:减少锁粒度

锁越少,死锁概率越低。能用 CAS 不用锁,能用分段锁不用全局锁。

java
// 全局锁 → 分段锁
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 的死锁:

数据库锁 + 应用锁混合死锁

java
// 事务 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 里看到。

典型排查流程

  1. 接口超时告警,TPS 骤降
  2. 马上 jstack -l <pid>,结果没有 "Found one Java-level deadlock"
  3. 再查数据库:mysql> SHOW ENGINE INNODB STATUS\G
  4. 看到 LATEST DETECTED DEADLOCK 部分,显示两个事务分别更新了 id=1 和 id=2 的行锁
  5. 修复:SQL 内的更新顺序统一按主键排序

真实案例:某订单系统在 618 大促期间,数据库死锁日志显示 LATEST DETECTED DEADLOCK,但 jstack 一切正常。排查发现是应用层先锁了对象,再执行 SQL,而 SQL 内部的更新顺序不一致导致数据库行锁死锁。修复方案:统一 SQL 内的更新顺序,并移除应用层不必要的锁。

活锁(Livelock)

活锁比死锁更难排查——线程没有阻塞,但一直在做无用功。jstack 显示的状态是 RUNNABLE,不会出现 BLOCKED。排查方法:top -H 看哪些线程 CPU 占用高,然后 jstack 定位它们的执行位置。

活锁 vs 死锁对比

对比项死锁活锁
线程状态BLOCKED 或 WAITINGRUNNABLE
CPU 占用低(几乎 0%)高(忙等循环)
jstack 特征"Found one Java-level deadlock"看不到异常,但大量线程在同一个循环代码
恢复方式重启进程加随机退避(Thread.sleep 加随机值)

活锁的典型代码表现

java
// 活锁示例:两个线程互相谦让
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))

资源死锁(线程池饥饿)

线程池 + 锁的组合也可能死锁:

java
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,部分节点完全不可用。

排查过程

  1. 告警触发:监控告警 order.create 接口 P99 > 5s,持续 3 分钟
  2. jstack 初检jstack -l <pid> | grep -A 30 "Found one" → 输出 2 组死锁
  3. 死锁分析
    • Thread-A(HTTP-nio-8080-exec-12)持有 InventoryService.lock,等待 OrderService.lock
    • Thread-B(HTTP-nio-8080-exec-14)持有 OrderService.lock,等待 InventoryService.lock
    • 同时还有 8 个线程在 BLOCKED 状态排队等这两个锁
  4. 代码定位:两个方法:
    • createOrder():先锁 InventoryService.lock,再锁 OrderService.lock
    • cancelOrder():先锁 OrderService.lock,再锁 InventoryService.lock
  5. 修复:统一锁顺序,createOrder()cancelOrder() 都先锁 hashCode 较小的锁
  6. 加 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 调用 + 共享资源锁定

java
// 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 调用之外:

java
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 框架里,多个工具共享同一个外部资源池:

java
// 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 看不到,需要靠分布式链路追踪 + 超时兜底。排查手段:

  1. 在 Agent 调用链中注入 TraceID,记录每个 Agent 的输入输出和等待关系
  2. 设置每个 Agent 调用的超时时间(LLM 调用本身一般在 2-30s,超时设 60s 比较合理)
  3. 超时后强制释放资源,记录失败链路

场景四: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"。排查时关注 BLOCKEDWAITING 状态的线程;生产环境用 grep -c 统计各状态线程数快速判断问题类型
  • 程序化检测:ThreadMXBean.findDeadlockedThreads() 底层是 wait-for graph 环检测,可检测 ReentrantLock 死锁;findMonitorDeadlockedThreads() 只检测 synchronized 死锁。两者区别是面试加分点
  • 最有效的预防:固定锁顺序(按 System.identityHashCode 排序,加 TieBreak 兜底),次选 tryLock(timeout) 超时放弃(注意活锁风险,加随机退避)
  • 隐式死锁更隐蔽:数据库锁 + 应用锁混合死锁、活锁需要用 top -H + jstack 配合排查、线程池饥饿死锁需要看线程状态分布
  • 排查优先级:jstack → 线程池饥饿 → 数据库死锁 → 活锁
  • 线上服务监控中集成死锁检测,做到死锁发生后自动 dump 堆栈并告警,不等用户反馈
  • 低并发环境看不到的死锁不代表不存在,流量放大后概率问题变成必然问题

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。