手写Java死锁检测组件:基于ThreadMXBean的主动监控与告警实现
1. 为什么放着现成工具不用非要手写一套1.1 第一次遇到线上死锁jstack 只能当“事后诸葛亮”先讲一个我印象深刻的场景。某天下午某个内部系统突然大面积请求超时CPU 占用率不高但服务就像被按了暂停键。我让运维拉了一份线程 dump一眼扫过去就看到了典型的死锁队形线程 A 持有锁 X等待锁 Y线程 B 持有锁 Y等待锁 X这就是教科书里写的“循环等待”。当时我用 jstack 手工确认了死锁发生然后重启了服务暂时恢复业务。但问题来了重启之后锁的竞争状态被清空了我只知道“刚才死了锁”却不知道“哪段代码引发的”。dump 文件里虽然有栈信息但那是已经出问题之后的现场触发死锁的那条路径早就被后续的线程状态覆盖了。从那时候起我就想能不能写一个“死锁检测组件”让它周期性地主动去检查 JVM 里的线程锁状态一旦发现死锁就立刻把现场信息打包存下来甚至直接触发告警。这样我们就能在死锁刚发生时就拿到相对干净的现场而不是等客户反馈“系统卡死了”之后再去事后翻 dump。1.2 现成能力的边界ThreadMXBean 能检测但没人帮你“主动盯”说实话JVM 本身就提供了一个很低层的检测入口java.lang.management.ThreadMXBean的findDeadlockedThreads()方法。这个方法会返回当前处于死锁状态的线程 ID 数组如果返回 null 就代表没有死锁。它甚至能帮你区分“monitor 死锁”和“ownable synchronizer 死锁”说白了就是既认synchronized关键字也认ReentrantLock这类基于 AQS 的锁。既然如此为什么不直接用现成的现成工具如 jstack、VisualVM、JConsole都是被动触发的得有人去点一下、跑一下命令生产环境很多时候没有条件随时随地连上 JConsole尤其容器化部署之后端口暴露、权限控制都很麻烦最重要的是这些工具不会主动留痕。它们能把死锁信息打在终端上但不会把“当时每个线程在等什么锁、已经等了多久”结构化地记录下来如果你要在一个大型服务里持续监控几十个线程池靠人工定期去 dump 根本不可行。所以“手写死锁检测组件”的核心不是去实现 JVM 内部的死锁算法而是把 JVM 提供的检测能力包装成一个可复用、可定时、可通知、可留痕的组件。这个组件不需要底层实现得很晦涩它更像是一个自动化巡检机器人。1.3 手写检测组件的核心目标拆解动手之前我先把目标拆成几条硬性要求避免写着写着变成一堆无效代码主动扫描能按固定周期对所有线程做死锁检测而不是等服务卡死才人工介入。现场留痕检测到死锁后能输出一份结构化的“死锁现场报告”包含线程名、线程状态、等待的锁、持有的锁、对应代码栈。可回调告警支持注入回调可对接钉钉、邮件、日志系统等告警通道。低开销本身不能变成新的性能瓶颈扫描频率可配置。可复用封装成独立组件后续任何 Java 服务都可以引入并快速接入。这五条就是整个组件的主线。接下来我会从原理开始讲为什么 JVM 能用这个方法检测死锁然后再给出完整实现。2. 死锁检测的底层原理从线程状态反推等待环2.1 四个必要条件的通俗理解死锁的产生条件说起来很抽象互斥、持有并等待、不可剥夺、循环等待。我用一个生活化一点的类比来拆解。想象你和一个同事在茶水间只有一台咖啡机和一台微波炉。甲拿着咖啡杯咖啡机被他占了还需要微波炉热早餐乙端着早餐盘微波炉被他占了正要接咖啡。现在两个人都握着自己手里的资源不肯放又都在等对方让出另一个资源。这就是循环等待。放到代码里四个条件分别是互斥锁同一时刻只能被一个线程持有持有并等待线程拿着锁 A 不放又去申请锁 B不可剥夺外人不能强行把线程手上的锁拿走循环等待A 等 B 的锁B 等 A 的锁。检测死锁的关键就是判断第四点系统里是否存在一个等待环。只要能找出这个环就能锁定参与死锁的所有线程。2.2 检测突破口JVM 的“锁依赖图”ThreadMXBean.findDeadlockedThreads()的原理可以理解为JVM 内部维护了一张“线程等待锁”的依赖图每个线程等待的锁对象会被记录在对应ThreadInfo的 monitor 信息里。假如线程 A 的 monitor 信息表明它在等待一个被线程 B 持有的锁同时线程 B 又在等待一个被线程 A 持有的锁这就形成了一条双向依赖也就是死锁环。Java 5 之前只有findMonitorDeadlockedThreads()它只能检测synchronized块的 monitor 死锁。Java 6 之后推出了findDeadlockedThreads()它把检测范围扩展到了java.util.concurrent.locks比如ReentrantLock、ReentrantReadWriteLock内部使用的同步器。后者是通过 JVM 内置的同步器信息来识别的所以哪怕你全程只用 Lock API也不会漏检。这里有个容易忽略的点JVM 只会检测“当前已形成环”的线程而不是所有可能死锁的线程。也就是说如果只有两个线程都同时等同一个锁但其中一方还没拿住自己的锁形成闭环JVM 不会报死锁。这没问题因为那种情况下系统不会永久卡死顶多是短暂阻塞。2.3 为什么一直循环 wait 的线程不算死锁顺便说一个很多人混淆的场景线程一直处于WAITING状态比如调用了wait()之后没有notify()。这是死锁吗答案是不是。WAITING状态本质上是线程主动让出了 CPU它在等待一个条件成立。如果没有其他线程唤醒它它可能永远卡住但这不构成“锁等待环”。JVM 的死锁检测只关心“正在等待获取锁”的线程所以检测结果不会把这类纯 wait 线程统计进去。理解了这一层你就明白为什么死锁检测不能简单通过“统计 WAITING 线程数量”来实现——那是典型的误报思路。我们要的是精确判断“谁在等谁的锁”而不是按状态一刀切。3. 核心实现一个可直接落地的死锁检测组件3.1 基础版单次检测的完整实现我直接给出一个完整可运行的基础版本然后再逐步解释关键设计。import java.lang.management.ManagementFactory; import java.lang.management.ThreadInfo; import java.lang.management.ThreadMXBean; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Optional; public class DeadlockDetector { private static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); private final ThreadMXBean threadMXBean; public DeadlockDetector() { this.threadMXBean ManagementFactory.getThreadMXBean(); } /** * 执行一次死锁检测。 * return 返回死锁信息如果没有死锁返回 Optional.empty() */ public OptionalDeadlockReport detect() { long[] deadlockedThreadIds threadMXBean.findDeadlockedThreads(); if (deadlockedThreadIds null || deadlockedThreadIds.length 0) { return Optional.empty(); } // 第二个参数 true获取线程的 monitor 锁信息 // 第三个参数 true获取线程的同步器锁信息 ThreadInfo[] threadInfos threadMXBean.getThreadInfo(deadlockedThreadIds, true, true); ListThreadInfo deadlockedThreads new ArrayList(); for (ThreadInfo info : threadInfos) { if (info ! null) { deadlockedThreads.add(info); } } return Optional.of(new DeadlockReport(LocalDateTime.now(), deadlockedThreads)); } public static class DeadlockReport { private final LocalDateTime detectTime; private final ListThreadInfo deadlockedThreads; public DeadlockReport(LocalDateTime detectTime, ListThreadInfo deadlockedThreads) { this.detectTime detectTime; this.deadlockedThreads deadlockedThreads; } public String format() { StringBuilder sb new StringBuilder(); sb.append(检测到死锁时间: ).append(FORMATTER.format(detectTime)).append(\n); sb.append(参与死锁的线程数: ).append(deadlockedThreads.size()).append(\n); for (ThreadInfo info : deadlockedThreads) { sb.append(\n); sb.append(线程ID: ).append(info.getThreadId()).append(\n); sb.append(线程名: ).append(info.getThreadName()).append(\n); sb.append(线程状态: ).append(info.getThreadState()).append(\n); sb.append(正在等待的锁: ).append(formatLock(info.getLockInfo())).append(\n); sb.append(当前持有的锁:\n); if (info.getLockedMonitors() ! null) { for (var monitor : info.getLockedMonitors()) { sb.append( - monitor: ).append(monitor.getClassName()) .append().append(monitor.getIdentityHashCode()) .append( 获取于 ).append(monitor.getLockedStackFrame()) .append(\n); } } if (info.getLockedSynchronizers() ! null) { for (var syn : info.getLockedSynchronizers()) { sb.append( - synchronizer: ).append(syn.getClassName()) .append().append(syn.getIdentityHashCode()) .append(\n); } } sb.append(堆栈信息:\n); for (StackTraceElement ste : info.getStackTrace()) { sb.append( at ).append(ste).append(\n); } } return sb.toString(); } private String formatLock(java.lang.management.LockInfo lockInfo) { if (lockInfo null) { return 无; } return lockInfo.getClassName() lockInfo.getIdentityHashCode(); } } }这段代码的逻辑非常直白每次调用detect()底层会去 JVM 里拿一次死锁线程列表然后把每个线程的详细信息包装成报告。3.2 关键 API 的参数选择为什么第三个参数要传 true这里有一个值得展开的细节getThreadInfo(long[] ids, boolean lockedMonitors, boolean lockedSynchronizers)的后两个参数。lockedMonitorstrue会填充ThreadInfo.getLockedMonitors()也就是这个线程当前持有的 monitor 锁synchronized相关的锁lockedSynchronizerstrue会填充ThreadInfo.getLockedSynchronizers()也就是这个线程当前持有的并发同步器锁ReentrantLock等。这两个参数开启后getThreadInfo的开销会比默认版本高一些因为 JVM 需要额外收集锁信息。所以不建议在高频路径里调用但死锁检测本身是低频操作可以放心开启。如果不开启你只能看到线程当前在等哪个锁看不到它手上已经握着哪把锁现场信息的完整度会大打折扣。3.3 周期扫描与回调告警从“查一次”到“持续盯防”单次检测只是组件的一半。要有实用价值还得加上定时扫描和告警回调。下面的代码实现了一个简单的调度封装import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; public class DeadlockMonitor { private final DeadlockDetector detector; private final ScheduledExecutorService scheduler; private final ConsumerDeadlockDetector.DeadlockReport alertHandler; private volatile boolean running false; public DeadlockMonitor( DeadlockDetector detector, ConsumerDeadlockDetector.DeadlockReport alertHandler) { this.detector detector; this.alertHandler alertHandler; this.scheduler Executors.newSingleThreadScheduledExecutor(r - { Thread t new Thread(r, deadlock-monitor); t.setDaemon(true); return t; }); } public void start(long initialDelay, long period, TimeUnit unit) { if (running) { return; } running true; scheduler.scheduleAtFixedRate(() - { try { detector.detect().ifPresent(report - { // 先落本地日志再触发告警 System.err.println(report.format()); alertHandler.accept(report); }); } catch (Throwable t) { // 检测任务异常不能影响业务线程至少打个错误日志 System.err.println(死锁检测任务异常: t.getMessage()); } }, initialDelay, period, unit); } public void stop() { running false; scheduler.shutdownNow(); } }使用方式很直接public class Main { public static void main(String[] args) { DeadlockDetector detector new DeadlockDetector(); DeadlockMonitor monitor new DeadlockMonitor(detector, report - { // 这里对接你的告警通道比如 Webhook、邮件、日志平台 System.out.println(触发死锁告警线程数: report.getDeadlockedThreads().size()); }); // 启动后每 30 秒扫描一次 monitor.start(0, 30, TimeUnit.SECONDS); } }调度线程要设置为 daemon 线程这是很多人的疏忽。如果设置为非 daemon 线程你的应用明明已经结束了JVM 却因为死锁监控线程还活着而无法退出这在测试和容器环境中很容易造成“进程退不掉”的诡异问题。3.4 线程池场景下的名字噪音与去重在实际业务里死锁经常发生在线程池中。如果你用Executors.newFixedThreadPool()或 Spring 的ThreadPoolTaskExecutor工作线程的名字通常是一堆 “pool-1-thread-1”、“pool-2-thread-3”。表面上看死锁报告里全是这类名字很难一眼看出“是哪段业务代码导致的”。这时候不要只盯线程名要看getStackTrace()和getLockedMonitors().getLockedStackFrame()。后者会明确告诉你“这把锁是在哪一行代码加上的”比如- monitor: com.example.OrderService12345 获取于 com.example.OrderService.lockOrder(OrderService.java:108)通过堆栈和锁获取位置的组合基本可以定位到具体业务方法。也就是说线程池给线程起的名字只是“代号”真正关键的是栈信息。有同学问检测结果里会不会出现同一个线程被重复统计一般不会findDeadlockedThreads()返回的数组里一个死锁环上的线程只会出现一次。不过如果你的系统里同时存在多个独立死锁环这个数组会包含所有环上的线程需要按环聚类处理。我目前没做环内聚类的细分因为告警场景下只要把全部线程信息打出来就够了人工排查完全来得及。4. 让组件真正用起来监控接入与信息增强4.1 信息增强让告警不再是一堆看不懂的十六进制默认的LockInfo打印出来是xxx123456这种“类名哈希码”的形式。哈希码在生产环境没什么参考价值因为同一个类可能有多个实例哈希码只能让你区分不同实例不能告诉你实例代表什么。我建议在报告里额外补充一层“业务上下文”。比如在创建锁对象时给它起个名字public class NamedLock { private final String name; private final Object delegate new Object(); public NamedLock(String name) { this.name name; } public void lock() { synchronized (delegate) { // 业务逻辑 } } public String getName() { return name; } }然后检测时再结合堆栈去把锁实例和业务名关联起来。虽然这样多了一层封装但在排查问题时体验完全不同。你看到的从“OrderService12345”变成“订单操作锁-订单ID10086”一眼就能定位是哪条业务路径。4.2 与监控体系对接的三种方式写死打印日志只是最基础的做法。真实项目里一个组件要和现有监控体系配合一般有三种选择我用表格做一个对比对接方式实现思路适用场景注意事项日志告警检测到死锁后输出 error 级别日志由日志平台如 ELK配置关键字告警已有统一日志平台是最快见效的方案日志要包含足够上下文不能只打个“deadlock detected”指标上报记录死锁次数、当前死锁线程数等指标定期上报到监控系统如 Prometheus想追踪死锁发生频率、判断是否频发建议额外打标签如死锁类型、业务模块回调通知在监控组件里注入告警处理器直接推送钉钉/企业微信/邮件死锁属于严重故障需要立即人工介入注意告警风暴建议同一时间窗口内只发一次我在实际项目里是用“日志 指标”双通道。日志用来保存完整现场指标用来报警阈值比如“最近 5 分钟内死锁次数大于 0”就触发页面告警。4.3 控制告警风暴死锁发生之后不能每 30 秒轰炸一次有一个场景需要特别处理系统已经死锁了但你设的扫描周期是 30 秒。于是检测组件每隔 30 秒就会回调一次告警一个小时的故障可能产生上百条告警通知把人的注意力全冲散了。我见过有人直接用AtomicLong做冷却计数的。更简单一点的做法是在监控组件内部加一个“告警去重窗口”private volatile long lastAlertTime 0L; private static final long ALERT_COOLDOWN_MS 5 * 60 * 1000L; // 5分钟冷却 private boolean shouldAlert() { long now System.currentTimeMillis(); if (now - lastAlertTime ALERT_COOLDOWN_MS) { lastAlertTime now; return true; } return false; }第一次检测到死锁立即告警之后无论检测到多少次在 5 分钟内都不会重复告警。这样既能保证第一时间知道故障发生又不会刷屏。等人工介入处理完之后告警冷却自动恢复。5. 实测过程中的坑与经验总结5.1 性能开销不是零但可以接受findDeadlockedThreads()本身不是特别重但getThreadInfo(ids, true, true)的开销会比默认版本高不少。我在一台 8核16G 的机器上做过粗略对比默认参数下获取所有线程信息大约耗时 20ms 到 40ms开启 monitor 和 synchronizer 信息后大约会翻倍到 60ms 到 80ms。这个开销如果你每分钟扫描一次影响可以忽略不计。但如果你把扫描周期压缩到 1 秒就需要谨慎评估了。实际使用建议扫描周期不要低于 10 秒一般 30 秒到 60 秒是比较合理的区间。死锁一旦形成线程会一直卡在等待状态晚几十秒发现并不会改变死锁的严重程度所以没必要追求秒级检测。还有一个细节findDeadlockedThreads()会扫描所有线程包括系统内部线程和垃圾回收线程。如果某些系统线程正好处于等待状态可能被误判进列表。我在实践中遇到过一次 GC 线程相关的误报后来排查发现是 JVM 自身在极端内存压力下的暂时状态并非业务死锁。这种情况比较罕见但如果你的告警逻辑太激进确实会造成虚惊。5.2 分布式的“跨服务死锁”检测不到手写组件能解决的是单 JVM 进程内的死锁。但在微服务架构里死锁还有一种更隐蔽的形态服务 A 拿到了 Redis 分布式锁 X去调服务 B服务 B 拿到了分布式锁 Y反过来调服务 A。两个服务互相等对方释放锁形成跨进程的循环等待。这种死锁靠ThreadMXBean是无解的因为它只关心 JVM 内部的锁关系。要检测跨进程死锁需要把分布式锁的申请信息上报到中心节点再构建全链路的锁等待图。这个复杂度远高于手写单机组件一般需要借助分布式链路追踪系统来辅助定位。我的建议是**先解决单机 JVM 内的死锁把检测组件做扎实再考虑跨进程场景。**大部分线上事故还是发生在单进程内的锁竞争上这个组件已经能覆盖大多数场景。5.3 用检测组件反向验证修复效果除了故障告警这个组件还有一个很实用的功能回归验证。比如定位到死锁是因为“加锁顺序不一致”导致的修复之后你可以在压测环境里用脚本疯狂模拟并发冲突同时开着检测组件。如果修复前每次压测都能检测到死锁修复后压测半小时都没有检测到就能比较有底气地说“问题大概率解决了”。这种验证方式的优势在于死锁本身是随机的偶发问题光靠人眼观察不一定能发现。检测组件把“是否发生死锁”变成了一个可量化的指标压测结束后直接看检测报告就够了。我在一个模拟项目里实际验证过这个流程先写一个故意制造死锁的 Demo用两个线程对两把锁以相反顺序加锁然后启动检测组件扫描。第一次运行几秒内就检测到了死锁并输出了报告修改代码统一加锁顺序后跑了十分钟都没有再触发。这种“故意构造死锁 自动检测验证修复”的组合非常适合在团队内部做代码评审和故障复盘。6. 手写过程中我认为最值得注意的三个设计决策最后总结三个我这次实现下来觉得最值得注意的设计决策或者说是我个人踩过坑之后得到的体会。第一不要把检测逻辑和业务告警逻辑耦合在一起。检测组件只负责“发现死锁并输出报告”至于报告是发邮件、推消息还是写日志应该由使用方通过回调决定。如果耦合在一起换一个告警通道就要改组件源码非常被动。第二一定要给检测线程一个独立的命名和 daemon 属性。我在实际部署中遇到过因为监控线程未设置 daemon导致测试环境应用关闭时 JVM 进程迟迟不退的情况。这个问题看起来小但真遇到了很耽误时间。第三报告格式要做到“可被人直接阅读”。组件的输出不是给机器看的 JSON而是给人看的格式化文本。死锁这件事发生频率低但危害大一旦发生值班同学需要立刻能在报告里看出是哪几个线程、在哪些代码位置互相等待。宁可报告长一点也不要精简到只剩一个线程名列表。这个组件本质上只是把 JVM 已有的检测能力包装成了“自动化盯防工具”但它带来的价值是实实在在的不用等人反馈系统卡死不用临时拉 dump 文件死锁发生后有结构化的现场记录修复后还能用来做回归验证。如果你也在维护多线程并发比较密集的 Java 服务我建议抽半天时间把这套东西搭起来。哪怕公司已经有了 APM 工具多一个主动检测的抓手排查问题时总不会吃亏。