线上死锁不要只靠 jstack:把死锁检测做进程序,上报与自动打断恢复
摘要:线上死锁传统处理往往是服务卡死、人工登录机器执行 jstack,事后人肉分析死锁环,响应慢,现场还容易丢失。JDK 通过 ThreadMXBean 提供原生死锁检测能力,可以直接在业务程序内部做周期性自检。本文实现一套可直接落地的死锁检测器:低频检测、完整锁信息上报告警,并尝试通过 interrupt 自动恢复;同时讲清 interrupt 的能力边界,哪些死锁可以恢复,哪些只能走重启止血。
一、为什么把检测做进程序
游戏服里死锁的典型长相:背包模块持有自己的锁去调邮件模块,邮件模块持有自己的锁反过来调背包,两个 tick 线程各自拿着一把锁等对方——这一批玩家就卡死了。等运维登上机器跑 jstack,几十分钟过去了,现场还未必留着。
事后工具的问题不在能力(jstack 找环又准又全),在时机。而 JVM 其实一直知道哪些线程死锁了——等待图里的环它扫得出来,只是默认不告诉你。我们要做的就是把这件事变成程序的一个周期性自检:发现、上报、尝试恢复。
先交代检测器要满足的三个需求,也是这篇的结构:
- 能在项目里低频自检(挂在 tick 线程,不额外起线程);
- 发现死锁后上报,信息完整到能直接看出环;
- 尝试自动打断恢复,并且清楚打断的边界在哪。
二、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,你就有了分钟级的死锁自愈能力;而救不了的那些环,正好提醒你该消灭锁而不是管理锁。