☰
线上死锁不要只靠 jstack:把死锁检测做进程序,上报与自动打断恢复
2026/10/13 3:25:35 网站建设 项目流程

线上死锁不要只靠 jstack:把死锁检测做进程序,上报与自动打断恢复

摘要:线上死锁传统处理往往是服务卡死、人工登录机器执行 jstack,事后人肉分析死锁环,响应慢,现场还容易丢失。JDK 通过 ThreadMXBean 提供原生死锁检测能力,可以直接在业务程序内部做周期性自检。本文实现一套可直接落地的死锁检测器:低频检测、完整锁信息上报告警,并尝试通过 interrupt 自动恢复;同时讲清 interrupt 的能力边界,哪些死锁可以恢复,哪些只能走重启止血。

一、为什么把检测做进程序

游戏服里死锁的典型长相:背包模块持有自己的锁去调邮件模块,邮件模块持有自己的锁反过来调背包,两个 tick 线程各自拿着一把锁等对方——这一批玩家就卡死了。等运维登上机器跑 jstack,几十分钟过去了,现场还未必留着。

事后工具的问题不在能力(jstack 找环又准又全),在时机。而 JVM 其实一直知道哪些线程死锁了——等待图里的环它扫得出来,只是默认不告诉你。我们要做的就是把这件事变成程序的一个周期性自检:发现、上报、尝试恢复。

先交代检测器要满足的三个需求,也是这篇的结构:

  1. 能在项目里低频自检(挂在 tick 线程,不额外起线程);
  2. 发现死锁后上报,信息完整到能直接看出环;
  3. 尝试自动打断恢复,并且清楚打断的边界在哪。

二、JDK 给了什么:ThreadMXBean

核心 API 就两个,都在java.lang.management.ThreadMXBean里:

ThreadMXBeantmx=ManagementFactory.getThreadMXBean();long[]ids=tmx.findDeadlockedThreads();// 扫描全线程的锁等待图,找环ThreadInfo[]infos=tmx.getThreadInfo(ids,true,true,Integer.MAX_VALUE);

两个容易踩的版本问题:

  • findDeadlockedThreads()和findMonitorDeadlockedThreads()是一对,后者只查 synchronized 的 monitor 死锁,前者连 ReentrantLock 这类 ownable synchronizer 一起查(JDK 6 加入)。没理由用旧的;
  • getThreadInfo一定要用带lockedMonitors参数的重载。简单重载拿到的堆栈看不出"谁持有哪把锁",而死锁排查恰恰最需要这个——两个参数一开,ThreadInfo.toString()会直接打出waiting to lock <0x...> (held by thread X)这样的行,环长什么样一眼可见。

还有一个成本认知要有:findDeadlockedThreads是全线程的等待图扫描,官方定位是"troubleshooting 用途,不是同步控制"。它不贵到不能用,但也不能每帧都跑——这就是检测要挂在 tick 里降频执行的原因。

三、检测器:可以整段搬走的实现

publicclassDeadlockDetector{privatestaticfinalThreadMXBeanTMX=ManagementFactory.getThreadMXBean();/** 死锁回调:上报、告警、打断恢复都在这里做 */publicinterfaceDeadlockListener{voidonDeadlock(ThreadInfo[]threads);}privatefinalDeadlockListenerlistener;privatefinallongreportIntervalMs;privatelonglastReportAt;publicDeadlockDetector(DeadlockListenerlistener,longreportIntervalMs){this.listener=listener;this.reportIntervalMs=reportIntervalMs;}/** 低频调用,比如 tick 里每 30 秒进来一次 */publicvoidcheck(){long[]ids=TMX.findDeadlockedThreads();if(ids==null||ids.length==0){return;}// true, true:带上持有的 monitor 和 ownable synchronizer,堆栈取全ThreadInfo[]infos=TMX.getThreadInfo(ids,true,true,Integer.MAX_VALUE);longnow=System.currentTimeMillis();if(now-lastReportAt<reportIntervalMs){return;// 死锁不会自愈,持续存在时避免每个周期刷屏}lastReportAt=now;listener.onDeadlock(infos);}}

两个设计点。回调而不是直接打日志,是因为"发现死锁之后干什么"每个项目不一样:有的只要日志,有的要推监控告警,有的要触发自动恢复——检测器只负责发现。上报做了节流,因为死锁一旦形成会一直存在,不节流的话每个检测周期都会全量报一次,日志和告警都会被刷穿。

四、上报:让日志自己说明环

最简的监听器长这样:

DeadlockDetectordetector=newDeadlockDetector(threads->{StringBuildersb=newStringBuilder("检测到 JVM 死锁,线程数: "+threads.length);for(ThreadInfoinfo:threads){sb.append("\n").append(info);}log.error(sb.toString());monitor.report("jvm.deadlock",threads.length);// 有监控体系就顺手报上去},60_000);

ThreadInfo的 toString 是这个方案里被低估的部分:线程名、状态、完整堆栈、每个栈帧上持有的锁、正在等待的锁和它的持有者,全在里面。一份 error 日志贴出来,环的拓扑基本不用二次分析。

我们最初的版本(几十行)和这个的差异只有两处,也顺带分享:一是用了不带锁信息的getThreadInfo重载,日志里看不出环,要靠人对着线程名和业务猜;二是用抛异常的方式把死锁信息丢给全局异常处理器记录——功能上能跑,但"发现死锁"是个事件不是异常,用回调语义更干净。

五、自动打断恢复:interrupt 的能与不能

上报解决"知道",恢复要解决"解锁"。手段是对死锁线程发interrupt(),但必须先把它的能力边界说透,不然就是虚假的安全感:

interrupt 救得了的:线程阻塞在Object.wait()、Condition.await()、lockInterruptibly()、tryLock(timeout)、sleep/join这些可中断点上时,interrupt 会让它以InterruptedException的形式醒来。醒来后业务放弃本次拿锁、回滚、释放自己已持有的锁——环就破了。

interrupt 救不了的:线程阻塞在synchronized的 monitor 竞争上时,interrupt 完全无效(monitor 等待不可中断);阻塞在ReentrantLock.lock()(不可中断版本)上时,interrupt 会把它戳醒一次,它看一眼中断标志然后继续 park 睡回去。

这意味着"自动打断恢复"的适用面是:你的锁都走可中断的获取方式,才谈得上自动恢复。想清楚这一点,恢复代码本身反而很简单:

privatevoidtryRecover(ThreadInfo[]threads){for(ThreadInfoinfo:threads){Threadt=findByThreadId(info.getThreadId());if(t!=null){t.interrupt();// 理论上打断环上一个线程即可破环,全部打断也无害}}}privatestaticThreadfindByThreadId(longid){for(Threadt:Thread.getAllStackTraces().keySet()){if(t.getId()==id){returnt;}}returnnull;}

两个工程细节别省:

  • 被打断的业务要接得住。InterruptedException冒出来时,业务侧要放弃当前操作、回滚已做的修改,否则死锁是解了,数据留了个半成品——比死锁更难查。游戏服单逻辑线程模型下相对好办:这个线程这轮 tick 的操作整体作废,下轮重来;
  • 恢复要有开关和日志。自动 interrupt 是在改线程的行为,出问题时你得知道"检测器干过什么"。建议打断前后都打日志,并允许配置成"只上报不恢复"。

如果死锁线程全都救不了(比如就是两把 synchronized 锁的环),interrupt 无用武之地,能做的升级是运维层面的:把该实例从网关摘掉、走优雅停机流程重启——止血保住节点,根因留给复盘。

六、挂进 tick:调度与频率

游戏服有天然的调度点,不用为检测单独起线程:

privatefinalDeadlockDetectordeadlockDetector=newDeadlockDetector(this::onDeadlock,60_000);privatelongnextDeadlockCheckAt;// 主循环 tick,比如 200ms 一次publicvoidonTick(){longnow=TimeUtil.now();if(now>=nextDeadlockCheckAt){nextDeadlockCheckAt=now+30_000;// 检测周期 30 秒deadlockDetector.check();}}

30 秒是个稳妥的起点:死锁从发生到上报最多延迟半分钟,扫描成本在这个频率下可以忽略。没有 tick 线程的项目,用ScheduledExecutorService每 30 秒跑一次check()等价,注意别让调度线程持有任何业务锁,免得检测器自己进了环。

七、最后

检测和恢复都是止血,死锁的正解永远是消除——统一加锁顺序、缩小锁粒度、能用单线程模型串行化的就别共享。检测器真正的价值是把"死锁发生了"从玩家投诉提前到分钟级告警,让根因修复有时间发生。


一句话总结:JDK 一直知道谁死锁了,ThreadMXBean 一个调用就能问出来——把它挂进 tick 低频检查、带锁信息上报、对可中断的等待尝试 interrupt,你就有了分钟级的死锁自愈能力;而救不了的那些环,正好提醒你该消灭锁而不是管理锁。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询