AI 排查 Java 线程问题——死锁与线程池耗尽的定位流程
线上 Java 服务最让人头疼的两类线程问题,一是死锁:几个线程互相等锁,谁也走不下去;二是线程池耗尽:任务越积越多,接口越来越慢,最后请求被拒绝。两者的表象都是“服务卡住了”,但定位思路完全不同。本文按“先定性、再取证、后修复”的顺序,梳理一套可复用的定位流程。
一、先定性:卡住的是锁,还是队列
遇到服务响应变慢或超时,先不要急着重启,先确认三件事。第一,CPU 是高还是低。死锁时线程大多在等待锁,CPU 往往并不高;线程池耗尽伴随大量任务排队时,CPU 可能正常,也可能因重试被打满。第二,接口是全部超时,还是只有个别接口超时。全部超时更像线程池或连接池被占满;个别接口卡住,更像某个锁出了问题。第三,线程数是否持续增长。线程数不断上涨而业务量没有明显变化,往往是线程泄漏或任务堆积的信号。
可以用 jps 找到进程,再用 jstack 加进程号导出线程快照。同一时刻连取两到三次快照做对比,比只看一次更有说服力:如果同一批线程反复出现在同一个锁上,问题就基本锁定了。
二、死锁定位:从 jstack 里找循环等待
死锁的四个必要条件是互斥、占有并等待、不可剥夺和循环等待,定位的关键就是找到“循环等待”这条链。拿到线程快照后,按以下顺序检查:
- 搜索 Found one Java-level deadlock。JDK 的线程转储会在末尾直接给出死锁报告,列出互相等待的线程和它们持有的锁。
- 找到 BLOCKED 状态的线程,看它在等哪把锁(waiting to lock),以及它自己持有了哪把锁(locked)。
- 沿着“等锁—持锁”的链条追下去,直到链条回到起点,循环就确认了。
修复死锁没有银弹,常见做法是:统一多个锁的加锁顺序,避免嵌套加锁时顺序不一致;缩短持锁范围,把不需要锁的操作移出同步块;对必须跨锁的操作,改用带超时的 tryLock,超时后释放已持有的锁并重试;涉及跨服务资源时,尽量把加锁收敛到一处。
三、线程池耗尽定位:看三组数字
线程池耗尽时,线程本身未必死锁,而是任务来得比处理得快。需要同时看三组数字:活跃线程数、队列里排队的任务数、已被拒绝的任务数。定期采样核心线程数、最大线程数、当前活跃线程数、队列长度和已完成任务数,再结合接口耗时一起看。
如果活跃线程长期等于最大线程数,同时队列持续增长,说明消费能力跟不上生产速度。这时要区分是下游变慢拖住了线程,还是任务量本身超过了设计容量:先看线程快照里大多数线程在等什么——等数据库、等远程调用,还是等锁。等下游是常态,就优先优化下游或给调用加超时;任务量超过容量,才考虑扩容或限流。直接把线程池调大往往只能把问题往后推,还可能把数据库连接池一起拖垮。
四、示例代码:线程池监控与死锁检测
下面的示例用 JDK 自带的管理接口实现两件事:定期打印线程池状态,以及检测死锁线程并打印堆栈。它不依赖任何第三方库,可以直接放到演示工程里运行:
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.*;
public class ThreadHealthCheck {
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 8, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy());
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
System.out.printf("活跃线程=%d, 队列任务=%d, 已完成=%d, 池大小=%d%n",
pool.getActiveCount(), pool.getQueue().size(),
pool.getCompletedTaskCount(), pool.getPoolSize());
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] deadlocked = bean.findDeadlockedThreads();
if (deadlocked != null) {
System.out.println("检测到死锁线程:");
for (ThreadInfo info : bean.getThreadInfo(deadlocked)) {
System.out.println(info);
}
}
}, 0, 5, TimeUnit.SECONDS);
for (int i = 0; i < 20; i++) {
final int taskId = i;
pool.submit(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("任务 " + taskId + " 完成,线程:" + Thread.currentThread().getName());
});
}
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
monitor.shutdown();
}
}
运行后重点看三行输出:活跃线程是否长期顶满、队列任务是否持续增长、是否打印出死锁线程。把这份采样日志和接口耗时放在同一时间轴上对比,结论会清晰很多。
五、让 AI 参与排查,而不是代替判断
排查线程问题时,AI 可以帮我们做三件费时但机械的事:解析动辄上千行的 jstack 输出,标出 BLOCKED 线程和锁的等待链;把多次快照、线程池采样和接口耗时对齐到同一时间轴,指出异常开始的时间点;根据锁的持有关系,画出等待关系图,辅助确认是否存在循环等待。
但 AI 的输出仍需要人工核对:死锁报告以 JDK 线程转储原文为准,修复方案要结合业务上锁的真实顺序来定。把 AI 当作“先读日志、再给线索”的助手,而不是直接下结论的裁判,排查会快很多,误判也会少很多。
六、修复后的检查清单
修复完成后,建议按这份清单逐项复核:加锁顺序是否已经统一,嵌套加锁处有没有遗漏;锁的持有时间是否已缩短,日志、远程调用是否已移出同步块;线程池的核心线程数、最大线程数、队列容量和拒绝策略是否与下游容量匹配;超时时间是否覆盖数据库、远程调用和锁等待;监控是否已经覆盖活跃线程数、队列长度、拒绝次数和死锁检测;上线后是否观察了至少一个业务高峰时段,确认指标回落。
结语
死锁和线程池耗尽的定位流程可以总结为一句话:先用快照定性,再用采样定量,最后按锁或按容量修复。jstack 负责看清锁的等待链,线程池监控负责看清任务的堆积情况,AI 负责把海量日志整理成可读的线索。把这三者配合起来,多数线程问题都能在重启之前找到真正的病因。
- 点赞
- 收藏
- 关注作者
评论(0)