Java后端项目跑久了总会碰上几回“灵异事件”CPU突然飙到百分之几百、接口动不动就超时、内存眼见着涨上去又不降下来。这种时候很多人的第一反应是打开Arthas或者翻各种APM监控平台。可如果你手上只有一台裸的Linux服务器连额外组件都没装那JDK自带的三个命令行工具——jstat、jmap、jstack才是真正能救命的家伙。它们不依赖网络、不需要额外安装只要你的JDK装好、JAVA_HOME配对了bin目录下随时待命。jstat看JVM运行状态和GC情况jmap看堆内存里的对象分布jstack看线程到底卡在哪三兄弟分工明确能覆盖绝大多数线上JVM问题。这篇文章就把这三个工具的常用参数、输出指标和实战排查套路完整过一遍附带可以直接复制去用的命令示例适合Java开发、后端运维以及所有被线上问题折磨过的人。1. 先弄清楚三个工具的分工很多排查动作慢是因为没选对工具1.1 三个命令各自负责哪块战场先把三者定位搞清楚后面用起来才会顺手。很多初学者上来就拿着jmap一顿dump或者用jstack拼命运线程日志结果问题定位南辕北辙原因就是对工具边界没有概念。jstatJVM Statistics Monitoring Tool负责看JVM的统计信息核心是类加载、GC次数、GC耗时、编译情况。它回答的是“JVM跑得健康不健康”的问题。比如Young GC是不是太频繁、Full GC是不是已经失控、Metaspace是不是在持续上涨这些都能靠jstat快速确认。它不会告诉你具体是哪个对象占内存也不会告诉你线程卡在哪个方法这些要交给另外两个兄弟。jmapMemory Map Tool负责看JVM堆内存的布局和对象分布。它能输出堆各个分区的容量和使用量能输出堆中对象的实例数和字节数直方图还能把整个堆导出成dump文件供MAT、VisualVM这类工具离线分析。它回答的是“堆里到底装了什么”的问题。当你怀疑内存泄漏、大对象异常、堆参数设置不当时jmap是第一选择。jstackStack Trace Tool负责打印JVM某个时刻的线程快照。它可以列出每个线程的状态、栈帧、锁持有和等待关系帮你定位高CPU线程在做什么、线程卡在哪个锁上、是否存在死锁。它回答的是“线程此刻都在干嘛”的问题。接口卡顿、线程池耗尽、死锁、高CPU靠它定位。用一个生活化类比把JVM当成一栋楼jstat是楼门口的配电箱能看整栋楼的用电是否正常jmap相当于物业拿钥匙进到每个房间清点里面住了多少人、堆了多少货jstack则是楼道里的监控回放看每个人此刻站在哪、是不是都堵在一扇门口等着进。工具核心定位回答的问题典型场景jstatJVM统计信息GC频不频繁、耗时多少Full GC报警、类加载异常jmap内存映像堆里有什么对象、占多大内存泄漏、堆配置不合理jstack线程快照线程在干什么、等什么锁高CPU、死锁、线程池耗尽1.2 动手之前先确认JDK环境真的能用命令能不能用取决于JDK装没装好、环境变量配没配对。这一步看起来基础但实际工作中我见过太多人栽在这里输入jstat提示command not found然后一脸茫然开始搜“jdk环境变量配置失败”“找不到jdk”。排查的第一步不是怀疑工具而是先确认基础环境。# 查看JAVA_HOME是否已设置 echo $JAVA_HOME # 查看java命令实际位置 which java # 查看当前JDK版本 java -version # 列出当前机器上所有正在运行的JVM进程 jps -lv重点说下jps -lv它是排除多JDK环境干扰的利器。-l能显示启动类完整包名或jar路径-v能显示启动时传入的JVM参数。通过它你能看到服务到底用的哪套JDK启动的也能直接拿到服务进程的PID号后面所有工具都离不开这个PID。实际使用中最常见的坑有两类。一类是机器上装了多个JDK系统默认的java是老版本但服务用新版本启动你直接在当前终端执行jmap/jstack就会attach失败报错信息里通常带着版本不匹配的提示。另一类是Linux服务器上服务启动脚本里写死了某个JDK路径而你的环境变量指向另一个JDK同样会导致工具连不上目标进程。解决办法很简单要么用sudo -u 启动用户去执行命令要么在启动脚本里先export一下JAVA_HOME或者直接用服务实际安装路径下的bin目录里的工具。后面第6章我会再展开讲。2. jstatJVM的实时体检表GC情况一眼看穿2.1 jstat命令的格式与最常用的选项jstat的命令格式是jstat [option] pid [interval] [count]interval是采样间隔单位毫秒count是采样次数。如果你只填pid默认会输出一次统计后退出。我在排查时最常用的是-gc和-gcutil一个看详细分区数值一个看百分比两者组合使用效率极高。常用选项说明-gc pid查看堆各分区的容量、已用空间、GC次数和耗时数值单位是KB是完整版。-gcutil pid查看各分区已用空间占总容量的百分比外加GC次数和耗时是速览版。-class pid查看类加载器统计包括加载类数量、卸载类数量、总耗时。-compiler pid查看JIT编译信息比如编译方法数、耗时。-printcompilation pid查看最近被编译的方法。最经典的做法是采样多次看趋势而不是只看一次# 每隔2秒采样一次共采样5次 jstat -gcutil 2315 2000 5这样能观察到Eden区的占用是否在反复冲高、Survivor区是否在正常轮换、Full GC次数是不是在持续增加。单看一次的数据意义不大因为GC次数是累计值你不采样两次以上根本看不出增量。2.2 输出指标逐列拆解拒绝看天书拿一份典型的JDK 8环境下jstat -gc的输出来讲S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 2048.0 2048.0 0.0 1024.0 16384.0 12288.0 40960.0 30720.0 10240.0 8192.0 2048.0 1536.0 35 0.234 7 0.987 1.221逐列拆开看S0C / S1C / S0U / S1USurvivor 0区和Survivor 1区的容量与已用空间。这两个区平时总有一个是空的因为Minor GC后存活对象会在这两个区之间来回拷贝属于正常现象。如果两个区同时长期都有大量存活对象说明对象晋升异常或者Survivor区配置偏小。EC / EUEden区的容量和已用空间。Eden区就是新对象的主要出生地新对象分配快、回收也快。观察EU的上升速度可以直接估算出对象分配速率。分配速率过高Minor GC自然就频繁。OC / OU老年代的容量和已用空间。老年代装的是长寿命对象和晋升对象。如果OU持续上升且降不下来那就要警惕内存泄漏或者大对象了因为老年代空间一旦耗尽就会触发Full GC。MC / MUMetaspace元空间容量和已用空间。JDK 8开始字面量池和类元数据挪到了Metaspace它使用本地内存动态扩容。如果MU持续上涨常见原因是动态生成类CGLIB代理、反射、热部署过多或者类加载器未释放。CCSC / CCSU压缩类空间容量和已用空间同样是JDK 8引入的用来存储类指针压缩后的元数据。这两个值异常增长时问题波动的方向跟MC类似。YGC / YGCTYoung GC次数和累计耗时秒数。YGCT除以YGC就是单次Young GC的平均耗时这个值超过几十毫秒就要注意了说明新生代里的存活对象可能偏多或者GC线程配置不合理。FGC / FGCTFull GC次数和累计耗时。这个指标最敏感Full GC一旦频繁服务卡顿和CPU飙高几乎必然出现。Full GC单次耗时如果超过一秒对线上服务的响应延迟影响是非常明显的。GCT所有GC累计总耗时从JVM启动开始统计。2.3 用jstat判断GC健康状态的三个观察点有了输出怎么判断健康不健康我总结了三个高频观察点基本够日常排查使用。第一Young GC频率是否正常。如果你看到Eden区每次采样都在快速填满YGC次数几十秒就增长几次说明对象分配速率异常高。这时候有两种可能确实是大流量正常请求导致的高分配或者是代码里有大量临时对象在重复创建。前者要考虑扩容堆或者优化业务逻辑后者要去代码里找循环创建对象的地方比如在for循环里用了String.format或者频繁new大数组。第二Full GC是否频繁且耗时高。FGC如果以分钟级频率增长且FGCT在GCT里占比超过一半基本可以断定老年代空间吃紧或者内存泄漏。最典型的场景是代码里往某个静态集合里疯狂塞数据集合没有上限也没有淘汰策略数据量上去之后老年代被塞满JVM开始频繁Full GC。这种问题的终极修复在代码jstat的价值是帮你快速确认“确实是Full GC引起的故障”然后你再往下用jmap找具体对象。第三S0U/S1U是否出现异常。正常情况是两个Survivor区轮流使用一个区有数据另一个区基本是空的。如果你看到两个区同时有大量数据说明对象在Survivor区拷贝之后仍然存活增长到一定程度就要晋升到老年代。这往往意味着对象生命周期偏长或者Survivor区容量设置得太小对象来不及在Young GC中回收就被提前晋升了。说得直白点这类对象本不该进老年代却因为Survivor放不下被硬塞进去了最终导致老年代过早膨胀。提示观察GC指标一定要“看增量别盯存量”。YGC和FGC是JVM启动以来的累计值你只有隔一段时间采样两次做差值才能得到真实的频率和增长率。单看某一次的输出数值再大也没有意义。3. jmap把堆内存翻个底朝天找到内存问题的元凶3.1 用 jmap -heap 快速查看堆配置与使用概况jmap的-heap选项能输出堆内存的整体配置和当前各分区的使用情况。它回答的问题非常直接堆一共多大、各分区分配了多少、现在还剩下多少空间、老年代是不是已经快满了。# 查看PID为2315的进程的堆内存概况 jmap -heap 2315执行后输出中核心信息有两块。一块是启动参数里跟内存相关的部分包括-Xmx、-Xms、-XX:MaxMetaspaceSize等你可以快速确认JVM启动时是不是把最大堆设小了另一块是分代内存情况比如Eden Space的容量和使用率、From Space/To Space的使用情况、Old Generation的使用情况。这个命令最适合用在哪两个场景一是服务刚启动完你用jstat发现Young GC非常频繁这时候用-heap看一眼Eden区和整个堆的容量配比如果堆总容量明显偏小直接改启动参数就能缓解大半问题二是在Full GC前后各跑一次看老年代的空间是不是被填满了以此确认Full GC的根本原因是老年代空间不足还是代码泄漏。有一点要交代清楚jmap -heap执行时会对JVM产生短暂的停顿虽然通常只有几十毫秒到上百毫秒但在高并发生产环境里还是要谨慎。我的习惯是能避开峰值时段就避开至少要跟团队确认一下影响面再执行。3.2 用 jmap -histo 定位对象实例异常jmap -histo输出堆中所有对象的直方图按实例数和占用字节数排序。这是定位“哪个对象把内存吃掉了”的最快途径。# 查看对象直方图只取前40行 jmap -histo 2315 | head -40输出长这样num #instances #bytes class name ---------------------------------------------- 1: 3145728 2684354560 [B 2: 1310720 104857600 java.util.HashMap$Node[] 3: 655360 52428800 com.example.order.OrderInfo 4: 1048576 41943040 [C 5: 327680 2621440 java.lang.String排在最前面的是占用字节最多的类。[B是byte数组[C是char数组java.lang.String本身不算大头但底层char数组会另算一份。在实际业务排查里如果看到某个自定义业务类或者集合类型排在前列且实例数巨大那基本离根因不远了。说一个我踩过的坑。曾排查过一个服务OOM用jmap -histo一看HashMap$Node[]的实例数和字节数都排第一容量远远超过正常业务量。顺着代码一查原来是一个静态的HashMap被当作缓存使用每次请求往里放数据压根没有清除逻辑大流量一冲缓存无限膨胀老年代直接被塞爆。后来改成带容量的LRU缓存Full GC频率立刻回到正常水平。需要特别提醒的是-live参数# 不要轻易在生产环境执行这个命令 jmap -histo:live 2315加了-liveJVM会先触发一次Full GC把已死对象回收掉再统计存活对象。生产环境高并发情况下这次Full GC会造成明显停顿场景上和事故没什么两样。如果是G1收集器还可能触发混合回收影响更大。所以线上排查时我会先用不带-live的版本看总体分布只有在确认安全、流量较低或者测试环境才用-live。3.3 用 jmap -dump 导出堆快照做离线分析当直方图告诉你“某个对象异常多”但看不出引用关系时就需要导出完整的堆快照放到MAT或VisualVM里看引用链和支配树了。# 导出完整堆快照 jmap -dump:formatb,file/data/dump/heap_2315.hprof 2315导出参数说明formatb以二进制格式导出这是MAT等工具的默认识别格式。file指定导出文件的路径目录必须有写权限且磁盘空间充足。不加-live导出的是整个堆的完整快照包含所有存活和已死但未回收的对象最贴近真实内存状态。这里我要认真提醒几个现实问题因为很多人第一次导出就翻车。第一个问题是文件大小。堆快照文件和堆大小强相关堆设置8G导出文件可能就有6G到8G甚至更大。导出的过程中磁盘空间不够或者文件写到一半失败会让你的排查节奏直接断掉。执行前务必先df -h看一下磁盘剩余空间至少预留堆大小1.5倍的空间。第二个问题是导出对线上的影响。导出堆快照比-histo:live轻但它要遍历整个堆内存CPU和IO的开销都不小大堆导出时服务延迟会变得很难看。我的建议是非必要不轻易在生产环境dump实在要dump选在业务低峰期并且提前跟团队打招呼。第三个问题是高版本JDK的命令变化。JDK 9之后jmap的一些功能被整合到jcmd中官方推荐的堆导出方式是# JDK 9 推荐使用jcmd jcmd 2315 GC.heap_dump /data/dump/heap_2315.hprof两者效果类似但jcmd的语义更规范报错信息也更友好在JDK 9及以上版本我优先用这个命令。拿到hprof文件之后用MAT打开重点看Dominator Tree支配树这个视图。它能把“谁把内存占住了”的引用链逐层展开定位到根因对象之后再回到代码里找这个对象的创建点几乎一找一个准。3.4 jmap常见报错与解决思路jmap执行时报错核心就三类。第一类是Error attaching to process原因通常是当前用户和目标JVM的启动用户不一致或者在容器环境里没有权限访问目标进程。解决方式是切换成服务启动用户去执行比如sudo -u appuser jmap -heap 2315。第二类是版本不匹配。你当前的JDK版本和启动服务的JDK版本不一致jmap attach时可能直接失败。解决方式是用服务实际启动的JDK路径下的jmap。第三类是目标进程不是JVM进程或者PID写错了。如果你把PID拿错jmap会干脆利落地报错。这一步可以用jps -lv再确认一下进程。4. jstack线程现场快照卡顿与高CPU的照妖镜4.1 抓取线程快照的基本用法jstack的核心动作很简单把JVM里所有线程的当前状态和调用栈打印出来拍下一张现场照片。# 基础抓取 jstack 2315 # 带上锁信息排查死锁必用 jstack -l 2315-l参数会额外打印锁的持有者和等待者信息排查死锁和锁竞争时非常有用建议默认带上。-e参数会打印额外的线程详情但输出会非常长日常排查很少用到。线程快照输出的每个线程段落里有几行信息要重点看。线程名称、线程状态RUNNABLE/BLOCKED/WAITING/TIMED_WAITING等、nid操作系统原生线程ID的十六进制表示、以及栈帧内容。栈帧是核心它告诉你这个线程到底执行到哪一行代码了。线程状态这块我多说几句因为很多新手容易搞混。BLOCKED是线程在等锁只有一个线程持有锁其他线程都得排队等WAITING是线程在等待被唤醒比如调用了Object.wait()、Thread.join()TIMED_WAITING是带时限的等待比如Thread.sleep()或者wait(1000)。区分这些状态只是为了快速判断线程为什么不动大部分问题最终都要回到栈帧和代码逻辑里去定位。4.2 高CPU线程排查标准流程这是jstack所有用法里实战价值最高的一段完整流程我写在这里。第一步找到CPU占用最高的线程PID。先用top看进程级占用再用top -Hp pid看这个进程内各线程的CPU占用。# 假设服务进程PID为2315 top -Hp 2315按下ShiftP按CPU排序记下占用最高的线程PID假设是3421。第二步把线程PID转成十六进制。jstack输出里的nid是十六进制格式需要转换。# printf把十进制转成十六进制 printf %x\n 3421输出会得到类似d5d的结果。第三步用jstack抓取线程快照并过滤出这个nid。有了nid0xd5d直接grep定位。jstack 2315 | grep -A 30 nid0xd5d这样你就能看到那个吃满CPU的线程正在执行哪个方法、哪一行代码。如果栈帧指向的是GC线程那说明CPU高是GC导致配合jstat -gcutil即可确认如果栈帧指向业务代码的某一行比如while循环或者某个循环体内置逻辑那就去看代码大概率是死循环或者活锁。这个三板斧的整套命令我在实际排查中几乎每次都会用到配合grep过滤后的输出基本两三分钟内就能把“CPU高”和“哪段代码”连上线。4.3 死锁检测jstack自己会告诉你死锁有个好消息JVM本身就会做死锁检测你不需要自己去逐行分析锁关系。当存在Java层面死锁时jstack的输出末尾会自动出现一段带Found one Java-level deadlock:字样的报告下面会列出死锁涉及的线程、锁对象和栈帧。# 死锁上报示例简化 Found one Java-level deadlock: thread-1: waiting to lock Monitor0x00000007d0ba9d80 (Object0x00000007c0b1e000) which is held by thread-2 thread-2: waiting to lock Monitor0x00000007d0ba9d90 (Object0x00000007c0b1e300) which is held by thread-1 Java stack information for the threads listed above:顺着这段输出就能直接看到两个线程互相持有对方需要的锁然后回到代码里找对应的synchronized块或者lock加锁顺序修复方案无非就是统一锁的获取顺序或者改用tryLock并设置超时。还有一类情况要区分开如果你看到的不是死锁报告而是大量线程阻塞在同一把锁上那更可能是锁竞争激烈而不是死锁。比如一个HashMap被多线程并发写一个synchronized加锁的Spring Bean在接口热路径上被高频调用这种场景下jstack里会看到几十个线程都在BLOCKED状态等同一把锁。解决方向是加并发控制、读写锁、或者改造数据结构而不是去排查循环等待。4.4 jstack使用经验与注意事项线程快照只是某一瞬间的状态图如果你看到线程A阻塞判断不了它是偶发还是持续正确做法是隔几秒连续抓3到5份快照再对比线程状态是否一直没变。比如你抓了三次某个线程每次都停在同一个栈帧且状态不变那基本可以断定这个线程卡住了。另外很多老手喜欢配合-m参数抓本地方法栈但我个人建议默认不用。-m会把大量JVM底层C/C栈帧打印出来不但输出量暴增而且其中八成的内容对业务排查没有直接帮助。先看Java栈帧确定问题在JVM层还是业务层再决定要不要深入底层。多数情况下业务层的栈帧已经足够告诉你答案。5. 实战串联一次CPU飙升与频繁Full GC的完整排查过程5.1 故障现象与优先怀疑方向有一回线上服务报警CPU使用率直接冲上500%接口平均响应时间从50毫秒涨到3秒监控面板上Full GC次数每分钟十几次。初期判断有三个方向的嫌疑一是业务流量突增导致正常GC压力变大二是代码里出现死循环导致CPU被空转占满三是存在内存泄漏或者无上限缓存老年代被塞满Full GC不断触发。三个嫌疑方向用三个工具分别去验证。排查的顺序很有讲究我习惯先用jstat看GC情况因为GC问题是Java服务故障里最普遍的一类也是最能直接量化确认的。5.2 jstat先行确认GC节奏和耗时先拿到服务PID然后执行jstat -gcutil 2315 2000 5输出如下S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 95.70 98.32 99.80 87.21 82.10 18523 21.332 1401 1860.224 1881.567 0.00 95.70 84.21 99.92 87.31 82.11 18524 21.356 1402 1864.331 1885.687第一眼就能确认问题FGC每隔2秒就增加1到2次FGCT高达1800多秒累计耗时占比几乎等于全部GC耗时。这说明服务绝大部分的GC时间都花在Full GC上Young GC反而几乎没怎么发生。换句话说老年代已经被塞满对象晋升不进去Minor GC也基本丧失作用JVM把所有精力都消耗在Full GC上。这个阶段已经可以基本排除死循环的嫌疑了。如果是死循环CPU高但FGC不该这么快增长。现在方向很明确老年代空间不足而且大概率有对象在持续堆积。5.3 jmap跟进锁定堆内存大户用jmap看看老年代使用情况再配合直方图找对象jmap -heap 2315确认老年代使用率接近100%。继续jmap -histo 2315 | head -30输出中排在第一位的类实例数和字节数远超常规业务量正是业务里某个订单缓存对象。它的实例数达到数百万总字节数接近整个堆大小的60%。看到这个结果基本可以断定问题就出在这个缓存对象上。再看一眼相关栈信息。这里补充一个小技巧直方图只能告诉你对象多不能告诉你谁在引用。如果你怀疑某个业务类有异常最快的办法是用MAT打开堆快照看引用链但线上没有时间让你慢慢分析时先用直觉排查代码里所有往集合里塞业务对象的入口往往比导dump更快。5.4 jstack收尾确认线程等待与锁竞争拿到堆内存的推测后再用jstack确认一下线程状态排除锁竞争导致的并发拥堵jstack -l 2315 | grep -A 20 java.util.concurrent.ThreadPoolExecutor输出里能看到大量工作线程处于BLOCKED或WAITING状态一部分在等待缓存对象的锁一部分在线程池队列里排队。这时候问题和根因就对上了缓存对象无限增长挤爆老年代引发高频Full GCFull GC导致STW停顿线程被冻结最终又表现为接口大面积超时同时锁竞争进一步加剧等待。这个案例最典型的特征就是三个工具各司其职、环环相扣jstat确认GC异常jmap定位内存大户jstack确认线程被拖累的现场。三者不是一个替代关系是一个递进关系。5.5 修复验证与工具联动总结修复动作是给缓存加容量上限并改成LRU淘汰策略同时把缓存实例数做了监控。代码发布后再跑一次jstat -gcutil 2315 2000 5FGC不再增长Executor线程全部回到RUNNABLE或在正常等待任务的状态CPU降回正常水平。整个故障从发现到修复三套命令只用了一遍效率很高。工具联动这件事我个人的体会是先用jstat把问题的性质定下来再用jmap去定位对象层面的证据最后用jstack还原线程层的现场。顺序不能乱乱了就容易在前置环节浪费大量时间。6. 使用中容易踩的坑与延伸建议6.1 权限问题服务用户与执行用户不一致这是线上环境里最常见的坑。服务通常用专门的系统账号启动比如appuser而你在登录后可能是root或者其他账号。直接执行jmap/jstack时attach机制会因为权限不足而失败报错往往是Error attaching to process或者sun.jvm.hotspot.debugger.DebuggerException。标准解法是切到服务启动用户去执行# 用服务启动用户执行 sudo -u appuser jstack 2315 # 或者先切换用户 su - appuser -c jstack 2315容器环境里情况更特殊。如果你在宿主机上直接执行宿主机PID大概率permission denied需要docker exec进容器内部在容器内找到Java进程PID再执行命令。这里有个细节容器内看到的PID和宿主机PID不一样别拿宿主机PID进容器里面用。6.2 JDK版本不一致多JDK环境下的attach失败现在的服务器上装多个JDK很普遍系统默认的java可能是JDK 8而服务用JDK 17启动。你用默认的jmap去hook一个JDK 17启动的进程版本不匹配会导致attach失败。最稳的排查方式是先看这个服务到底用哪个JDK启动的jps -lv | grep 2315从输出中能看到启动参数里有没有-java-home或者具体JDK路径。确认之后用该JDK的bin目录下的工具去执行# 找到JDK实际安装路径 ps aux | grep java | grep 2315 # 用实际路径下的工具 /opt/jdk-17/bin/jstack 2315另一个做法是在启动脚本里强制统一JAVA_HOME但这属于环境治理范畴短期排查先把人切到对应工具上。6.3 dump文件与磁盘空间导出前先看容量dump大堆的失败案例我见得太多了。堆是8G你导出的hprof文件可能逼近8G要是目录剩余空间只有3G导出直接失败还会留下一堆残破文件。执行前标准的检查顺序# 1. 查看堆设置 jmap -heap 2315 # 2. 查看磁盘容量 df -h /data/dump至少需要预留堆大小1.5倍的空间再动手。另外dump过程中服务可能明显变慢文件越大影响越久。如果服务可以使用短暂停机的场景我甚至建议直接摘流量再dump体验会好很多。6.4 输出解读的常见误区jstat输出里最容易踩的坑就是拿累计值当瞬时值。YGC和FGC是JVM启动以来累计的次数你要真正判断指标必须隔一段时间差一次看增量变化。另一个误区是套用旧版本经验解释新版本行为。JDK 8里看到Metaspace列很直观但到了JDK 11以上G1收集器下jstat的输出虽然列结构还保留着但部分分区比如S0C/S1C的意义已经和经典分代不完全一致。ZGC、Shenandoah这类收集器jstat的部分列含义更是完全不同。所以高版本JDK排查时不要死盯着jstat的老逻辑更多要看GC日志和jcmd的输出。6.5 进阶延伸jcmd与可视化工具JDK 9之后官方推荐用jcmd整合诊断能力。日常排查中很多命令都能用jcmd替代jcmd pid GC.heap_dump path等同jmap -dumpjcmd pid Thread.print -l等同jstack -ljcmd pid GC.class_histogram等同jmap -histo相比jmap/jstackjcmd在报错信息、参数校验、JDK版本兼容性上都做得更好建议新项目直接上手jcmd。但jstat暂时没有完美的替代命令它的定时采样能力依然是最方便的所以jstat这个基本功一定要练好。另外如果想做离线的堆分析MAT是首选想看线性的线程状态变化VisualVM可以连接本地进程采样。这些工具都值得扩展学习但前提还是把jstat、jmap、jstack三兄弟的基本功弄扎实。基本功不扎实给你一堆图表你也看不出门道。我个人最喜欢这三兄弟的一点是它们完全免费、随JDK自带、在任何最小化部署的容器环境里都能直接使用这决定了你在任何环境下都有一个兜底的排查手段。最后分享一个实用小技巧写个简单脚本把jps、jstat -gcutil、jmap -histo和jstack -l一次性执行一遍并保存到日志文件平时用不上真正出问题时这些历史记录能帮你对比故障前后差异省掉很多猜测过程。建议你在非业务高峰期多练几次熟悉每列指标的含义真到故障爆发时手才不会抖。