搞 JVM 的人,绕不开线程 dump。线上服务突然没响应、接口延迟忽高忽低、CPU 被打满,这类问题一出现,我一般不会先去看业务日志,而是先给目标进程来一份线程 dump,把“系统表象”直接翻译成“代码位置”。VisualVM 是我在这条路上用得最顺手的工具之一,它既能一键触发线程 dump,又能把几百行纯文本转换成可展开、可搜索、可反复对比的图形界面,定位“卡在哪”这类问题尤其快。
这篇文章不打算照抄官方文档,而是把我在实际项目里用 VisualVM 生成和分析线程 dump 的完整套路写下来,包括什么时候该做 dump、怎么生成、怎么看懂里面的状态和锁、以及在 VisualVM 里做死锁识别和多次采样的经验。无论你是刚开始碰 JVM 的初学者,还是已经会用 jstack 的老手,里面都有一些可以直接抄作业的细节。
1. 为什么是线程 dump:先搞清楚排查对象的本质
1.1 线程 dump 到底在 dump 什么?
线程 dump(Thread Dump)在设计上非常简单:把 JVM 在某个时刻所有存活线程的状态“拍一张快照”,包括每个线程的名字、优先级、线程 ID、线程状态、当前调用栈、以及锁相关的细节信息。你可以把它理解成一个多线程系统的工作现场照片,照片上每个人(线程)停留在哪段代码、是否在等锁、一直在执行什么任务,全都一目了然。
举个例子,一个普通 Java 进程里通常会有几十上百个线程。除了业务线程,还有 GC 线程、编译器线程、信号线程、JMX 线程、数据库连接池的 worker,数量上来之后,管理难度也随之增大。只要整个 JVM 出现“卡死”“变慢”或者“CPU 飙高”的现象,绝大多数情况都逃不出这几个线程的行为异常。线程 dump 的价值就在于,它用非常小的侵入成本——比抓 heap dump 要小得多——把现场记录下来,而且不影响进程本身的运行。
我在排查时一直把它当作第一选择,就是因为性价比高。对比 heap dump 动辄几十 MB 到几个 GB,线程 dump 往往只有几十 KB 到几百 KB,完全可以“随手采一份”,压力比较小,生成过程也不会让正在闹毛病的服务雪上加霜。更关键的是,线程 dump 能保留足够多的定位线索,后续无论是看线程状态、锁等待还是调用栈,它都给出了原始依据。
1.2 什么时候该做线程 dump?
不是所有问题都适合先做线程 dump。我自己的判断标准很简单:出现以下三类现象,第一时间想线程 dump。
第一类是“服务无响应”,比如接口一直不返回、健康检查失败、压测过程中并发上不去。这种场景绝大多数是线程阻塞在锁、IO 或某个等待条件上,dump 里能直接看到一堆 BLOCKED、WAITING、TIMED_WAITING 状态的线程堆在一处,定位到具体的锁对象和代码位置并不难。
第二类是“CPU 飙高”,比如进程的 CPU 占用突然到 90% 以上,还持续不降。如果一次 dump 里看到某个业务线程长时间 RUNNABLE,并且多次采样它的调用栈都停在同一个 Java 方法上,基本可以认定这段代码就是热点,往循环、计算、GC 的方向排查基本没错。
第三类是“线程数量暴增”,比如从正常的几百个涨到几千个,JVM 开始报unable to create new native thread。这种情况往往伴随大量线程创建后无法退出,线程 dump 能帮你数出线程的真实分布,区分是业务线程泄漏,还是某个线程池没有正确 shutdown。
反过来,如果是内存持续增长、OOM、频繁 Full GC 之类的现象,线程 dump 就不是最优解,应该优先看 heap dump 和 GC 日志。搞清楚“该用哪个工具”比“学会工具”更重要,既省时间,也省机器资源。
2. 生成线程 dump 的多种方式与选型
2.1 jstack、jcmd、kill -3、VisualVM 对比
生成线程 dump 其实不止 VisualVM 一种手段。常用的是四条路:最经典的jstack -l <pid>、官方推荐的jcmd <pid> Thread.print、Unix 系系统上的kill -3 <pid>,以及图形化的 VisualVM。它们最终生成的都是一份“线程转储文本”,区别在于使用方式和适用场景。
| 方式 | 命令或操作 | 侵入性 | 主要优点 | 局限 |
|---|---|---|---|---|
| jstack | jstack -l <pid> | 低 | 简单、稳定、适合脚本化 | 需要 JDK 环境,纯文本阅读性一般 |
| jcmd | jcmd <pid> Thread.print -l | 低 | 官方推荐,诊断入口统一 | 功能与 jstack 类似,没有图形化 |
| kill -3 | kill -3 <pid> | 极低 | 机器上甚至不需要 JDK 工具 | dump 会写进日志文件,后续整理麻烦 |
| VisualVM | 图形界面右键/按钮 | 低 | 直观、可保存、可分析复用 | 本地附加进程或远程配置有额外前提 |
先说jstack。它几乎是从 JDK 早期就提供的工具,命令行里用jps -l拿到目标进程 PID,然后jstack -l 12345就能把线程快照打到终端里。-l参数表示打印锁的详细信息,排查阻塞、死锁时必须带上。它的最大优势是“适合进脚本”。我写定时采样脚本时基本都是 jstack 一把梭,几秒一份,自动存文件,后面慢慢对比。缺点也很明显:几十个线程的调用栈堆在终端里,没有折叠、没有状态分类,人肉去看非常累,尤其当进程有上千线程时,基本等于大海捞针。
jcmd是官方后来主推的诊断命令,jcmd <pid> Thread.print -l的效果和 jstack 类似,但诊断子命令覆盖更全面,入口也统一。新环境里我一般优先用 jcmd,jstack 则留在老环境里应急。
kill -3比较特殊,它通过在系统层向 JVM 进程发送特定信号,让 JVM 自己把线程 dump 写到标准输出,通常就是应用日志里。这个操作几乎零侵入,哪怕机器上没装完整 JDK,只要跑的是 Java,都能把现场打出来。但它有两个麻烦:一是 dump 会混进业务日志,还得去文件里捞对应段落;二是容器环境对信号处理并不总是那么友好,输出不一定能顺利拿到。所以它更适合“环境极度受限时的最后手段”,不适合日常主动分析。
VisualVM 的位置不一样。它不是用来替代命令行工具,而是在“分析”这件事上足够直观。你用 jstack 拿到的是原始素材,VisualVM 则把素材变成了可交互的界面:左边是线程列表,右边是选中线程的完整调用栈,能直接看线程状态,还能标记死锁。更关键的是,VisualVM 允许把 dump 保存下来,之后随时重新打开,不需要依赖当时的进程还活着。这让“事后复盘”和“多次采样对比”变得非常自然。
2.2 VisualVM 生成的线程 dump 有什么不一样?
从内容本质上说,VisualVM 生成的线程 dump 和 jstack 的输出是同一种信息,里面都会包含时间戳、JVM 版本、线程列表、线程状态和锁信息。但 VisualVM 做了几件纯文本做不到的事情。
第一,它把线程按状态和类型分好了。打开后你会看到线程列表里有多个分组,一眼扫过去就能看出哪些线程处于什么状态,而不是挨个去读文字。第二,它保留了一个可视化层。打开转储文件时,可以定位到任意线程的每一帧堆栈,也能看到线程持有的锁和等待的锁。第三,它能做到“保存/加载”闭环。你可以对活着的进程生成 dump,保存成.tdump文件,等用户反馈问题之后再打开同一份文件分析;也可以批量收集多份 dump 文件,需要时逐个加载,横向对比线程行为变化。
这个能力在生产环境里特别实用:问题发生时,你未必有时间立刻细看,更可能的是先在现场把数据完整采集下来,事后再慢慢分析。VisualVM 这套“先保全、再分析”的工作流,是我愿意把它放在 jstack 前面的最重要原因。
3. VisualVM 生成线程 dump:从启动到保存
3.1 启动 VisualVM 与附加进程
VisualVM 在不同 JDK 版本里的“出现方式”不太一样。老版本 JDK 会把jvisualvm可执行文件放在$JAVA_HOME/bin下,直接敲命令就能用;较新版本的官方 JDK 发行包里不再捆绑,需要单独下载独立发行包。拿到之后解压、启动对应平台的启动脚本即可。进入主界面后,左侧的“Applications”树里会列出本机正在运行的 Java 进程,双击进程节点就能进入该进程的监控视图。
附加进程这一步有几个常见前提。本机要有权限访问目标进程,通常同一用户启动的进程可以直接附加;跨用户或容器环境可能需要调整权限,否则会看到“无法附加”的提示。另外,VisualVM 附加的是运行中的本地 JVM 进程,不是附加到 class 文件或 jar 包,这个概念和调试器不要混淆。
如果目标进程在另一台机器上,用本地 VisualVM 直接附加会比较麻烦,一般要依赖 JMX 远程连接,或者换一种思路:先在远程机器上用 jstack 采集线程 dump 文本,把文件拉回本地,再用 VisualVM 打开分析。这一步我个人的习惯是先处理权限问题,再谈效率。线上环境如果目标进程是以另一个服务账号启动的,直接附加大概率失败;保险做法是先用jps -l确认进程归属,再用对应身份操作。
3.2 在进程视图里触发线程转储
进入进程视图后,找到顶部“Threads”页签,或者直接在应用节点上点右键,菜单里会有一项“Thread Dump”。点击后 VisualVM 会立刻对目标进程做一次线程采样,并自动打开一个新的“Thread dump”标签页。
触发之后,界面左侧会列出所有采集到的线程,右侧是对应线程的完整调用栈。顶部还会显示这次 dump 的基本信息,比如采集时间和线程总数。这里有个容易混淆的点:界面上线程状态的颜色,和“Threads”页签里时间线展示的颜色不一样,因为线程转储是某个时刻的“快照”,而 Threads 页签展示的是某段时间内的线程状态变化,二者是不同的维度,不能互相替代。
如果你是第一次用,建议先拿一个本地测试进程练手。写一个简单的 Java 程序,起几个处于不同状态的线程,然后触发一次 dump,这样能非常直观地建立“代码 - 线程 - 状态”的对应关系。比如一个线程执行Thread.sleep(5000),它在 dump 里就是 TIMED_WAITING;一个线程在wait()方法上,它在 dump 里就是 WAITING。这个对应关系一旦建立,之后看生产环境的 dump 会快很多。
注意:线程 dump 操作本身是轻量的,但也不是完全零成本。在非常高频地反复触发时,每一次采集都会让所有线程短暂停顿在安全点(safepoint),某些对延迟极度敏感的应用可能会观察到轻微抖动。常规三五次采样完全没问题,但不要做成每秒一次,避开过度采集就好。
触发 dump 之后,不要急着关闭标签页。先整体看一眼线程列表的分布,比如多少个 RUNNABLE、多少个 BLOCKED、多少个 WAITING,这决定你下一步往哪个方向深挖。如果发现大量线程都困在同一种状态,这往往就是定位到具体类和方法的第一个线索。
3.3 保存转储文件并重新加载
分析完成之后,建议立刻把这次 dump 保存下来。在“Thread dump”标签页上找到“Save”按钮,保存为.tdump文件。这个文件本身是纯文本格式,即使没有 VisualVM,也可以直接打开查看,只是没有图形化分类而已。
后续想重新分析时,不需要再连上原进程,直接在主界面点“File”菜单 → “Load”,选择之前保存的.tdump文件,VisualVM 就会重新打开同一份快照。这时你完全可以像分析刚生成的 dump 一样,展开线程、看堆栈、识别死锁。很多线上问题只会瞬间出现,或者当时根本没有条件登录服务器反复操作,“先保存证据、后集中分析”是唯一可靠的做法。
保存文件命名也值得注意。我一般按“应用名_节点名_时间串”的风格来命名,例如order-svc_172-16-3-8_20250101_143000.tdump。别看这个细节不起眼,当你手里堆了十多个 dump 文件要对比时,清晰的文件名能省掉大量“打开才知道是谁”的时间。
4. 线程 dump 读法:状态、堆栈和锁
4.1 线程状态速查:六种状态的排查方向
线程 dump 里最直观的信息就是线程状态(java.lang.Thread.State)。新手拿到 dump 常常第一眼就晕,其实只需要把六种状态的含义和排查方向记清楚,这份文本就能读懂了。
| 状态 | 含义 | 排查方向 |
|---|---|---|
| NEW | 线程刚创建,尚未启动 | 正常,通常不需要关注 |
| RUNNABLE | 线程正在运行,或者在等待 CPU 调度 | 结合多次 dump 看栈是否固定在热点方法 |
| BLOCKED | 线程被锁阻塞,等待进入同步块 | 找到锁对象,查持有锁的线程 |
| WAITING | 线程无限期等待另一个线程唤醒 | 查wait()、join()、park()的调用点 |
| TIMED_WAITING | 线程在有限时间内等待 | 查sleep()、带超时的wait()、parkNanos() |
| TERMINATED | 线程已结束 | 正常,通常不需要关注 |
排查阻塞类问题时,重点自然是 BLOCKED 和 WAITING。两者虽然都表示“线程没在干活”,但原因有本质区别。BLOCKED 的线程明确想要进入一个被占用的 synchronized 代码块,它在“等锁”;WAITING 的线程则是因为调用了wait()、park()这类方法,主动放弃了 CPU,在“等事件”。搞清楚这个区别,你才知道下一步是去查锁持有者,还是去查唤醒条件。
RUNNABLE 状态要小心,它不是“一定在消耗 CPU”的同义词。Java 里的很多网络 IO、文件 IO,在 JVM 线程状态上依然显示为 RUNNABLE,因为底层用的是非阻塞或者比较特殊的系统调用。所以如果你看到某个线程 RUNNABLE,但调用栈停在 Socket 读取方法上,别急着下“死循环”的结论,它很可能只是在等网络数据。
4.2 锁信息:monitor、waiting to lock、parking 该怎么读
真正读懂一个线程 dump,必须会看锁相关的标注。这里有一段典型的 dump 片段,我截取并简化过:
"pool-1-thread-3" #24 prio=5 os_prio=0 tid=0x00007f8c1c01c800 nid=0x2a34 waiting for monitor entry [0x00007f8c8f7f4000] java.lang.Thread.State: BLOCKED (on object monitor) at com.demo.OrderService.process(OrderService.java:82) - waiting to lock <0x00000000e1c5a9d0> (a java.lang.Object) at com.demo.OrderService.run(OrderService.java:40) at java.lang.Thread.run(Thread.java:748) "pool-1-thread-1" #22 prio=5 os_prio=0 tid=0x00007f8c1c01e000 nid=0x2a32 runnable [0x00007f8c8f9f4000] java.lang.Thread.State: RUNNABLE at com.demo.OrderService.process(OrderService.java:90) - locked <0x00000000e1c5a9d0> (a java.lang.Object)这段信息包含了几个关键信号。waiting to lock表示线程正在等待某个对象锁,后面括号里就是锁对象的地址和类型;locked表示线程当前持有了这个对象的监视器锁。如果把两个线程的片段放在一起看,结论就很清晰:thread-3 想进入process方法第 82 行,但锁被 thread-1 占着,所以它只能 BLOCKED。
还有一种常见标注是parking to wait for,通常出现在基于AbstractQueuedSynchronizer的并发组件里,比如ReentrantLock、CountDownLatch、线程池的worker。它本质也是在“等锁”,但层级更深,需要再往调用栈上方看一两帧,找到对应的LockSupport.park和具体锁对象。
排查死锁时,锁信息的价值直接翻倍。死锁的典型结构是:线程 A 持有锁 1,等待锁 2;线程 B 持有锁 2,等待锁 1。dump 里你会看到两个线程各自停在waiting to lock,并且等待的锁对象,正好和对方持有的锁对象一致。这种情况人工比对会有遗漏风险,交给 VisualVM 的自动检测会更稳。
4.3 调用栈怎么定位到代码行
线程状态和锁信息能告诉你“它在等什么”,调用栈则能告诉你“它在哪一段代码里等”。每个线程的栈帧从栈顶到栈底,就是方法调用的完整链路。栈顶是当前正在执行的方法,往下是它的调用来源。排查时优先看栈顶三层,一般就能判断问题性质。
多次采样是定位“周期性卡顿”的重要技巧。只做一次 dump,你只能看到瞬间状态;每隔几秒采一次,连续采五次以上,如果发现某个线程每次都停在同一个方法的同一行,那基本可以把问题锁定在那行代码附近。如果线程状态每次都不同,则可能是偶发阻塞或者锁竞争激烈,需要用更长时间的采样数据来画趋势。
需要注意的一个反直觉现象:有时候“罪魁祸首”并不是那个 BLOCKED 的线程,而是那个看起来正常 RUNNABLE 的锁持有者。我遇到过不少案例,线上大量线程在等一把锁,但持锁线程本身不是在忙业务,而是卡在了一个外部服务调用的读取上。你以为问题是“锁竞争太激烈”,实际上是“持锁线程响应太慢”,把锁释放时间拖得太长。这个判断一定要结合调用栈整体来看,不能只盯着阻塞线程。
5. VisualVM 分析线程 dump 的实战路径
5.1 加载转储文件后的分析界面
实际使用中,我分析 dump 文件的比例,比当场触发 dump 的比例还要高。打开 VisualVM,用“File → Load”加载.tdump文件后,界面会显示专门的分析视图。左侧的线程列表支持按状态分组和排序,右侧展示选中线程的完整调用栈,顶部还有线程总数和采集时间信息。
面对这个界面,我的分析顺序基本固定:先看整体分组,确认哪类状态占比异常;再按状态排序,把异常状态的线程挑出来;最后逐个展开调用栈,寻找公共的方法。比如有一个分组里有 40 个线程全是 BLOCKED,那它们 likely 在等同一把锁,展开任意两三个线程确认锁对象地址是否一致即可。如果锁对象地址一致,问题基本就是一个并发热点;如果不一致,则需要看是否有多把锁互相牵扯。
VisualVM 分析视图还有一个很有用的交互:点开某个线程后,调用栈里每一帧都能展开对应的类和方法名。如果现场代码还保留了源码路径,甚至可以直接跳转到对应行。即使没有源码,拿到完整的类名+方法名+行号,也足够去代码仓库里定位了。
5.2 用 VisualVM 识别死锁
VisualVM 对死锁的识别是我比较依赖的功能之一。当你加载或触发一份线程 dump 时,如果存在 Java 层面的死锁,工具会在界面里直接提示,并把参与死锁的线程高亮出来。这不代表它能解决死锁,但能帮你跳过最费眼的人工比对环节,直接看到结论,再人工复核。
有一次排查一个定时任务长时间不结束的问题,我用 VisualVM 删取 dump 后发现提示存在死锁。点开高亮线程一看,线程 A 持有一把业务锁,正在等一个数据库连接池内部的锁;线程 B 持有连接池内部锁,正在等业务锁。两边都不肯放手,任务自然就卡死了。按照提示找到两个锁在代码里的获取顺序,调整为一致的加锁顺序后,问题就不再复现。
死锁检测也依赖数据完整性。如果你的 dump 是jstack -l生成后手动转成 VisualVM 能识别的格式,锁信息丢失的风险更高。我自己更推荐直接在 VisualVM 中生成或加载原生.tdump文件,锁信息最完整,检测结果才可信。
5.3 一次多 dump 对比的模拟分析
用一个虚拟的“订单处理服务”场景来串一遍流程。现象是:压测时接口超时率暴增,从 5% 涨到 60%。我先用 VisualVM 对进程连续做了五次线程 dump,每次间隔 5 秒。
第一次看整体,发现五个 dump 里都有大量 RUNNABLE 线程集中在同一个类上,类名是OrderService.process。进一步展开其中一个线程的栈,发现它卡在了一个数据库连接获取调用的内部。这时如果只看一次 dump,可能会误判为“CPU 热点”;但对比五份数据,会发现这些线程每次都停在同一个获取连接的等待逻辑上,并不是在持续计算。
接着检查线程状态分布,发现 BLOCKED 线程数量也在涨,说明连接池耗尽后,越来越多的线程在等池子归还连接。真正的瓶颈不是某段 CPU 计算,而是数据库连接池的资源上限和获取连接的持有时间。后续验证方式也从线程 dump 转移到了连接池监控,最后调大了池上限并优化了连接释放逻辑,超时率才降下来。
这个例子说明,一次 dump 只能给出现象,多次 dump 对比才能给出趋势。VisualVM 的“保存/加载”机制让多文件对比变得很简单,我建议每次排查至少留下五份采样文件,宁可多存,不要少采。
6. 常见问题与排查技巧实录
6.1 生成和加载 dump 时的常见坑
用 VisualVM 生成和分析线程 dump 的过程中,有几个坑我反复踩过,整理成速查表:
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 附加进程失败 | 跨用户启动、权限不足 | 用进程所有者身份操作,或者改用 jstack 远程采集 |
| 触发 dump 后空白 | 目标进程可能在安全点停顿过久 | 等待片刻刷新,或再触发一次 |
加载.tdump看不到线程列表 | 文件格式不符 | 优先用 VisualVM 原生保存的.tdump,或重新用jstack -l转存 |
| docker 容器内 PID 不一致 | 容器内看到的 PID 是隔离后的 | 先进入容器命名空间,或直接对宿主机实际 PID 执行 jdk 工具 |
| 一直提示“无法打开”或者“Not a valid thread dump” | 文件被编辑过、编码不对 | 确认文件保持原始内容,不要用带 BOM 的编辑器重存 |
权限问题是生产环境里最常见的拦路虎。如果 VisualVM 附加不了,我一般会让运维同事在目标机器上执行jcmd <pid> Thread.print -l > /tmp/thread_$(date +%Y%m%d_%H%M%S).tdump,然后把文件传回来分析。只要能拿到标准文本,VisualVM 依然能做很好的分析,只是部分可视化分类可能不如原生导出那么细致。
Docker 环境也有自己的烦恼。容器里跑的 Java 进程,直接用 VisualVM 附加往往因为容器隔离导致权限和 PID 映射错位。更稳妥的办法是在宿主机上找到实际进程,或者用jstack在容器内生成 dump 后拖到本地分析。如果你经常在容器环境里排查,建议把常用 JDK 工具打包进运维镜像,省得现场抓瞎。
6.2 一次不够:多份 dump 的采样策略
做线程采样,我强烈建议执行“3 次最低,5 次及格”的规则。只采一份,你只能看到一个瞬间状态;如果问题带有周期性,比如某个阻塞每 10 秒出现一次,单份 dump 很容易错过。
采样间隔一般取 3 到 5 秒,连续采 5 份,基本能覆盖大部分瞬态问题。如果是 CPU 高负荷问题,间隔可以缩短到 1 秒;如果是周期性的批处理卡顿,间隔可以拉到 10 秒以上。关键不是频率,而是“多份之间要有可比性”——保持同样的采集格式,保持同样的间隔,文件命名带时间戳,这样后续对比时才不会乱。
还有一种实用做法:在问题发生前后各采一组。比如压测从正常到异常的过程中,我在第 0 秒、第 30 秒、第 60 秒各采一份,比较不同阶段的线程分布变化。这种“前后对照”比单纯采多份更能讲清楚问题发展的过程。
6.3 线程 dump 分析之外:什么场景它不擅长
线程 dump 不是万能的。它擅长回答“线程卡在哪”“谁在等锁”“是不是死锁”,但在下面几类场景里,它帮不上忙,甚至可能误导你。
一是 CPU 高但线程不多且都 RUNNABLE 的场景。dump 只能告诉你每个线程的位置,不能告诉你 CPU 时间都花在哪个线程上。想看 CPU 分配,VisualVM 的“Sampler”和“Profiler”更合适。二是在内存泄漏、OOM 的场景,线程 dump 几乎给不出内存对象的分配线索,这时要看 heap dump 或者 GC 日志。三是涉及外部依赖慢调用时,dump 只能显示线程停在 Socket 读取这类方法上,但具体是哪次调用、耗时多少,还得靠 APM 或链路追踪的数据来补充。
所以在使用线程 dump 前,先判断问题域是否匹配。它是我工具箱里最常用的一件工具,但绝对不是唯一一件。
6.4 几个实用的个人习惯
最后分享几个我自己长期使用的小习惯。
第一,线程池命名要规范。在代码里创建线程池时,用new ThreadFactory或者guava的ThreadFactoryBuilder给线程起一个带业务含义的名字,比如order-async-worker。如果当初线程池命名是乱的,dump 里全是pool-1-thread-1,你就很难分清是哪条业务链路的线程。这个改造成本很低,排查收益却极高。
第二,线上排查优先用命令行工具,回到本地再用 VisualVM 分析。生产服务器上不一定有图形环境,也不一定方便暴露 JMX 端口,远程桌面操作也不现实。在这种条件下,jstack 或 jcmd 是更可靠的选择——采集现场数据,然后把数据文件拉到本地,喂给 VisualVM 做图形化分析。前者解决“能不能拿到数据”,后者解决“能不能快速看懂数据”。
第三,不要忽视 dump 文件里的头部信息。每次 dump 顶部都会带上时间和 JVM 参数之类的元数据。遇到跨时区协作时,先确认时间是本地时间还是 UTC,避免把时间线比对错位。这个细节看起来小,但在多人协作排障时,时间口径不统一会导致严重的误判。
我个人在实际排查中最深的体会是:线程 dump 的价值不取决于你会不会执行命令,而取决于你能不能把一行行堆栈翻译成代码逻辑。VisualVM 给了你一张地图,但最终走到终点还得靠对业务代码和并发模型的理解。刚开始可以多拿测试程序练手,多积累“这个状态对应那种代码”的直觉,遇到线上问题才不会慌。
如果你还没有把线程 dump 纳入日常排查流程,我建议从下一次压测开始,顺手在 VisualVM 里点一下“Thread Dump”并把文件存下来。存得多了,你自然会发现,很多看似诡异的问题,其实早就藏在某一份 dump 的锁信息里。