生产环境死锁排查:从线程堆栈到锁等待图的完整分析链路

发布时间:2026/7/19 17:47:53
生产环境死锁排查:从线程堆栈到锁等待图的完整分析链路 生产环境死锁排查从线程堆栈到锁等待图的完整分析链路一、所有请求线程突然卡住不动日志一天没打一行生产环境最诡异的故障之一服务还活着端口还开着健康检查还能过但所有请求都超时。查 CPU 使用率——几乎为零。查内存——正常范围。查数据库连接池——有很多空闲。但就是不响应任何请求。这种症状在 Java 应用中十有八九是发生了死锁。死锁的根源很简单两个或多个线程相互等待对方释放锁资源形成闭环等待谁也走不出去。但排查死锁的过程不是谁都能靠直觉完成的——必须从现象出发沿着数据收集、堆栈分析、锁等待图构建的链路一步步走最终定位到是哪两个线程在哪两把锁上互相卡住。排查死锁的完整链路如下发现异常请求超时、CPU空闲后先收集第一手数据使用jstack导出线程堆栈同时查看GC日志排除GC停顿检查数据库连接排除DB层死锁。拿到线程堆栈后定位所有处于BLOCKED状态的线程提取每个线程等待的锁地址构建锁等待有向图。检查图中是否存在环若存在环即可确认死锁定位环中的线程和锁若不存在环则属于非死锁问题需排查其他原因。确认死锁后分析死锁的业务逻辑通过排序锁获取顺序等方式修复最后验证修复并上线。二、从 jstack 中读取死锁信息在 Java 中排查死锁的第一步是jstack pid。这条命令会打印所有线程的当前堆栈并自动检测死锁。如果能直接看到 JVM 打印的Found one Java-level deadlock信息恭喜你死锁已经被 JVM 自动检测出来了。但有些不那么经典的死锁JVM 可能检测不到——比如通过CountDownLatch、Semaphore或线程池造成的逻辑死锁这些不涉及 JVM 锁监视器jstack 不会主动报告。这种情况下需要手动分析线程堆栈。以下是两个线程互相等待的典型堆栈片段Thread-A #23 prio5 os_prio0 tid0x00007f8a0c001000 nid0x5a3b waiting for monitor entry [0x00007f89e8bf9000] ---java.lang.Thread.State: BLOCKED (on object monitor)at com.example.OrderService.createOrder(OrderService.java:45)- waiting to lock 0x00000006c2e8d3f0 (a com.example.LockA)- locked 0x00000006c2e8d400 (a com.example.LockB)Thread-B #24 prio5 os_prio0 tid0x00007f8a0c001800 nid0x5a3c waiting for monitor entry [0x00007f89e8af8000]java.lang.Thread.State: BLOCKED (on object monitor)at com.example.OrderService.refundOrder(OrderService.java:78)- waiting to lock 0x00000006c2e8d400 (a com.example.LockB)- locked 0x00000006c2e8d3f0 (a com.example.LockA)从这两段堆栈可以清晰地看到Thread-A 持有 LockB等待 LockAThread-B 持有 LockA等待 LockB。典型的环状等待——死锁。 读取 jstack 时需要关注的关键信息 - **线程状态**BLOCKED 表示在等待 JVM 锁WAITING 表示在等待信号。 - **waiting to lock**线程正在等待的锁对象地址。 - **locked**线程已经持有的锁对象地址。 - **对象地址的唯一性**同一个地址 0x00000006c2e8d3f0 出现在两个线程的 waiting to lock 和 locked 中就构成了等待环。 ## 三、构建锁等待图 当线程数量超过 10 个时肉眼分析堆栈信息效率太低。一个更系统的方法是构建锁等待图Lock Wait Graph。 java /** * 锁等待图分析工具 * * 通过 jstack 输出解析线程和锁的等待关系 * 构建有向图并检测环死锁 * * 图的节点是锁对象用地址标识 * 图的边是A → B 表示持有锁 A 的线程在等待锁 B */ public class LockWaitGraphAnalyzer { /** * 从 jstack 输出中检测死锁 * * param jstackOutput jstack 命令的完整文本输出 * return 检测到的死锁环列表 */ public ListListString detectDeadlocks(String jstackOutput) { // 构建锁等待图 MapString, SetString waitGraph buildWaitGraph(jstackOutput); // 在有向图中使用 DFS 检测环 return findCycles(waitGraph); } /** * 从 jstack 文本中构建锁等待关系 */ private MapString, SetString buildWaitGraph(String jstackOutput) { MapString, SetString graph new HashMap(); // 按线程分段 String[] threadBlocks jstackOutput.split(\n\n); for (String block : threadBlocks) { if (!block.contains(BLOCKED)) { continue; // 只关注等待锁的线程 } // 解析 locked该线程持有的锁 SetString heldLocks new HashSet(); Pattern lockedPattern Pattern.compile( - locked (0x[0-9a-f])); Matcher lockedMatcher lockedPattern.matcher(block); while (lockedMatcher.find()) { heldLocks.add(lockedMatcher.group(1)); } // 解析 waiting to lock该线程等待的锁 Pattern waitingPattern Pattern.compile( - waiting to lock (0x[0-9a-f])); Matcher waitingMatcher waitingPattern.matcher(block); while (waitingMatcher.find()) { String waitingLock waitingMatcher.group(1); // 对每个持有的锁添加一条持有 → 等待的边 for (String heldLock : heldLocks) { graph.computeIfAbsent(heldLock, k - new HashSet()) .add(waitingLock); } } } return graph; } /** * DFS 检测有向图中的环 * * 维护三种状态 * - 白色未访问 * - 灰色正在访问在当前递归路径上 * - 黑色已访问完毕 * * 如果在 DFS 中遇到灰色节点说明存在环死锁 */ private ListListString findCycles(MapString, SetString graph) { ListListString cycles new ArrayList(); SetString whiteSet new HashSet(graph.keySet()); SetString graySet new HashSet(); SetString blackSet new HashSet(); while (!whiteSet.isEmpty()) { String start whiteSet.iterator().next(); ListString currentPath new ArrayList(); dfs(start, graph, whiteSet, graySet, blackSet, currentPath, cycles); } return cycles; } private boolean dfs(String current, MapString, SetString graph, SetString white, SetString gray, SetString black, ListString path, ListListString cycles) { white.remove(current); gray.add(current); path.add(current); for (String neighbor : graph.getOrDefault(current, Collections.emptySet())) { if (black.contains(neighbor)) { continue; // 已探索完成无需再走 } if (gray.contains(neighbor)) { // 遇到灰色节点发现环 // 从当前路径中提取环的部分 int cycleStart path.indexOf(neighbor); ListString cycle new ArrayList( path.subList(cycleStart, path.size())); cycles.add(cycle); return true; } if (dfs(neighbor, graph, white, gray, black, path, cycles)) { return true; } } path.remove(path.size() - 1); gray.remove(current); black.add(current); return false; } }四、死锁修复的三种方案定位到死锁后修复的方向有三个选择哪一种取决于业务场景。方案一统一加锁顺序。这是死锁问题的根本解决方案。如果多个业务方法都需要获取 LockA 和 LockB确保在所有方法中加锁的顺序一致总是先 LockA 再 LockB。这样不会形成环状等待。缺点是需要对代码有一定的控制力如果锁的获取路径分散在多个微服务中统一顺序就很难保证。方案二使用 tryLock 加超时。不用synchronized改用ReentrantLock.tryLock(timeout, TimeUnit)。获取不到锁时主动放弃释放已持有的锁过一段时间再重试整个业务逻辑。这种方式避免了永久死锁但引入了新的问题业务可能在多次重试后才成功增加了整体延迟。/** * 使用 tryLock 避免死锁 * * 核心思路 * - 获取第一把锁后用 tryLock 获取第二把锁 * - 获取失败时释放第一把锁随机等待后重试 * - 避免了永久等待把死锁转化为偶尔的重试 */ public boolean safeTransfer(Lock from, Lock to, int amount) { while (true) { from.lock(); try { // 用 tryLock 而不是 lock避免永久等待 if (to.tryLock(100, TimeUnit.MILLISECONDS)) { try { // 两把锁都拿到了执行业务逻辑 doTransfer(from, to, amount); return true; } finally { to.unlock(); } } } finally { from.unlock(); } // 没拿到第二把锁短暂随机休眠后重试 // 加随机休眠是为了避免多个线程同步重试 Thread.sleep(ThreadLocalRandom.current().nextLong(50, 200)); } }方案三缩小锁的粒度。不是对整个方法加锁而是只对需要互斥访问的数据加锁。比如扣减库存时锁定的是特定的商品 ID 范围而不是整个库存服务。锁的粒度越细不同线程锁冲突的概率越低死锁的可能性也越小。五、总结死锁排查的核心是数据驱动——从 jstack 导出堆栈提取线程等待关系构建锁等待图检测环结构定位到具体的代码行。这个过程有固定的模式不需要太多直觉。修复方案中统一加锁顺序是根本解tryLock 加超时是工程解缩小锁粒度是架构解。三者可以根据场景组合使用。最重要的是不要在代码评审时觉得这种加锁方式一般不会出问题——只要存在环状等待的可能性死锁就一定会发生只是时间早晚的问题。