线上死锁不要只靠 jstack把死锁检测做进程序上报与自动打断恢复摘要线上死锁传统处理往往是服务卡死、人工登录机器执行 jstack事后人肉分析死锁环响应慢现场还容易丢失。JDK 通过 ThreadMXBean 提供原生死锁检测能力可以直接在业务程序内部做周期性自检。本文实现一套可直接落地的死锁检测器低频检测、完整锁信息上报告警并尝试通过 interrupt 自动恢复同时讲清 interrupt 的能力边界哪些死锁可以恢复哪些只能走重启止血。一、为什么把检测做进程序游戏服里死锁的典型长相背包模块持有自己的锁去调邮件模块邮件模块持有自己的锁反过来调背包两个 tick 线程各自拿着一把锁等对方——这一批玩家就卡死了。等运维登上机器跑 jstack几十分钟过去了现场还未必留着。事后工具的问题不在能力jstack 找环又准又全在时机。而 JVM 其实一直知道哪些线程死锁了——等待图里的环它扫得出来只是默认不告诉你。我们要做的就是把这件事变成程序的一个周期性自检发现、上报、尝试恢复。先交代检测器要满足的三个需求也是这篇的结构能在项目里低频自检挂在 tick 线程不额外起线程发现死锁后上报信息完整到能直接看出环尝试自动打断恢复并且清楚打断的边界在哪。二、JDK 给了什么ThreadMXBean核心 API 就两个都在java.lang.management.ThreadMXBean里ThreadMXBeantmxManagementFactory.getThreadMXBean();long[]idstmx.findDeadlockedThreads();// 扫描全线程的锁等待图找环ThreadInfo[]infostmx.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{privatestaticfinalThreadMXBeanTMXManagementFactory.getThreadMXBean();/** 死锁回调上报、告警、打断恢复都在这里做 */publicinterfaceDeadlockListener{voidonDeadlock(ThreadInfo[]threads);}privatefinalDeadlockListenerlistener;privatefinallongreportIntervalMs;privatelonglastReportAt;publicDeadlockDetector(DeadlockListenerlistener,longreportIntervalMs){this.listenerlistener;this.reportIntervalMsreportIntervalMs;}/** 低频调用比如 tick 里每 30 秒进来一次 */publicvoidcheck(){long[]idsTMX.findDeadlockedThreads();if(idsnull||ids.length0){return;}// true, true带上持有的 monitor 和 ownable synchronizer堆栈取全ThreadInfo[]infosTMX.getThreadInfo(ids,true,true,Integer.MAX_VALUE);longnowSystem.currentTimeMillis();if(now-lastReportAtreportIntervalMs){return;// 死锁不会自愈持续存在时避免每个周期刷屏}lastReportAtnow;listener.onDeadlock(infos);}}两个设计点。回调而不是直接打日志是因为发现死锁之后干什么每个项目不一样有的只要日志有的要推监控告警有的要触发自动恢复——检测器只负责发现。上报做了节流因为死锁一旦形成会一直存在不节流的话每个检测周期都会全量报一次日志和告警都会被刷穿。四、上报让日志自己说明环最简的监听器长这样DeadlockDetectordetectornewDeadlockDetector(threads-{StringBuildersbnewStringBuilder(检测到 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){ThreadtfindByThreadId(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调度与频率游戏服有天然的调度点不用为检测单独起线程privatefinalDeadlockDetectordeadlockDetectornewDeadlockDetector(this::onDeadlock,60_000);privatelongnextDeadlockCheckAt;// 主循环 tick比如 200ms 一次publicvoidonTick(){longnowTimeUtil.now();if(nownextDeadlockCheckAt){nextDeadlockCheckAtnow30_000;// 检测周期 30 秒deadlockDetector.check();}}30 秒是个稳妥的起点死锁从发生到上报最多延迟半分钟扫描成本在这个频率下可以忽略。没有 tick 线程的项目用ScheduledExecutorService每 30 秒跑一次check()等价注意别让调度线程持有任何业务锁免得检测器自己进了环。七、最后检测和恢复都是止血死锁的正解永远是消除——统一加锁顺序、缩小锁粒度、能用单线程模型串行化的就别共享。检测器真正的价值是把死锁发生了从玩家投诉提前到分钟级告警让根因修复有时间发生。一句话总结JDK 一直知道谁死锁了ThreadMXBean 一个调用就能问出来——把它挂进 tick 低频检查、带锁信息上报、对可中断的等待尝试 interrupt你就有了分钟级的死锁自愈能力而救不了的那些环正好提醒你该消灭锁而不是管理锁。
阅读完成 · 觉得有帮助?