☰
jstat实战:用JDK自带工具监控JVM内存与GC
2026/10/20 5:21:45 网站建设 项目流程

在Java服务卡顿、内存飙高、接口超时的时候,绝大多数人第一反应是抓一把jstack看线程,或者拉一份heap dump慢慢分析。但很多时候,问题的根源根本不在某个线程卡在哪行代码,而是整个堆的“新陈代谢”出了问题——对象疯狂创建、回收跟不上、老年代不断膨胀但迟迟不触发Full GC。这种时候,jstack帮不了你,heap dump又太重,真正该掏出的是JDK自带的那个低调到容易被忽略的轻量级监控命令:jstat。

jstat全称是JVM Statistics Monitoring Tool,是JDK命令行工具家族里专门盯JVM运行期统计信息的利器。它不抓线程栈、不导堆快照,只做一件事:持续输出JVM各个区域的内存占用、GC次数、GC耗时、类加载数量、编译统计等量化指标。这听起来朴素,但恰恰是性能排查里最缺的第一手数据——你连GC频率和内存增长趋势都不知道,谈优化方向就是闭着眼猜。

这篇就专门聊聊jstat。我会从最基本的命令参数讲起,把每个常用参数的输出字段都掰开揉碎,然后结合真实排查场景,演示怎么用jstat定位“老年代持续增长但Full GC迟迟不来”这类经典问题,最后分享一些我实际踩过的坑和排查技巧。不管你是刚接触JVM调优的新手,还是被线上问题折磨过的老手,这篇都值得花几分钟看完。

1. 内容整体设计与思路拆解

1.1 jstat到底解决什么问题

先说个真实的场景。某天线上一个订单处理服务突然出现大面积超时,CPU不高,内存也没到OOM,但接口RT(响应时间)就是降不下来。运维同事查了一圈,说老年代占用率一直在90%徘徊,但Full GC的日志看起来又不频繁。这时候你手头有什么工具?

jstack能告诉你线程在等什么锁,但没法告诉你为什么锁迟迟释放不了。jmap能dump堆内存,但几个G的文件导出来,分析一圈下来可能黄花菜都凉了。jstat的定位恰好补上这个盲区——它能在几秒钟内告诉你:Eden区是不是已经塞满、Survivor区是不是在反复搬运对象、老年代是不是在被缓慢撑大、Young GC和Full GC分别多久来一次、每次耗时多少。

所以我个人的习惯是:遇到任何疑似JVM层面的性能问题,第一件事不是上重工具,而是先跑一轮jstat,用几组数据把JVM的“健康画像”勾勒出来,再决定后续用哪个工具深挖。这一步就像是医生先量体温、测血压,而不是直接开核磁共振。

1.2 为什么选jstat而不是其他监控工具

市面上做JVM监控的富工具很多,像VisualVM、JConsole、Arthas,甚至各种APM全家桶都能展示GC曲线。那为什么命令行里的jstat还是不可或缺?

原因有三点。

第一,jstat是JDK自带的,任何一台装了JDK的机器都能直接用,不需要额外安装、不需要开agent、不需要改启动参数,在排查生产环境问题时特别友好。很多公司对生产机器有严格的安全限制,想上传一个Arthas的jar都费劲,但jstat是原生命令,几乎没有被限制的可能。

第二,jstat几乎零侵入、零性能损耗。它读取的是JVM内部的统计计数器,不需要对应用做任何字节码增强,也不会强制触发GC。在吞吐量敏感的服务上,你甚至可以开着jstat持续监听,不用担心它本身成为性能瓶颈。

第三,jstat的输出是纯文本的统计数据,非常适合脚本化采集和趋势分析。你可以在bash里写个循环,每隔几秒采一次jstat -gcutil的数据,攒成一段时序数据,再丢进Excel或任何图表工具里画趋势线。这种灵活度是图形化工具很难给的。

1.3 和其他工具的分工配合

jstat不是万能的,它和jmap、jstack、jinfo更像是一个完整的排查链条。

  • jstat:看JVM运行状态的核心指标,回答“现在到底怎么了”。
  • jmap:导出堆快照或查看堆内存的详细分布,回答“内存里到底装了什么”。
  • jstack:抓线程快照,回答“线程都在哪里卡住了”。
  • jinfo:查看和调整JVM启动参数,回答“当前参数配置是什么”。

用jstat发现问题,用jmap和jstack定位细节,用jinfo确认参数合理性,这就是一个标准的JVM问题排查流程。后面我会结合案例演示这个流程怎么串起来。

2. 核心细节解析与实操要点

2.1 jstat命令格式和参数速览

jstat的基本语法很简单:

jstat -<选项> <进程ID> [采样间隔] [采样次数]

中间那个进程ID是Java进程的PID,可以用jps -l查出来。后面的采样间隔和次数可选项,不写就是只打一次。

举个例子,你想每2秒采一次GC数据,一共采5次,命令就是:

jstat -gcutil 12345 2000 5

这里12345换成你的Java进程PID。

jstat的选项比较多,但日常排查真正常用的就那几个,我按使用频率排个序:

选项作用最常用字段
-gcutil查看各内存区域的使用率百分比和GC时间S0、S1、E、O、M、YGC、FGC、FGCT
-gc查看各内存区域的当前容量和已用量S0C、S1C、EC、OC、OU,以及GC次数和时间
-gccause在-gcutil基础上额外显示最近GC的原因LGCC、GCC
-class查看类加载/卸载数量Loaded、Bytes、Unloaded
-compiler查看JIT编译统计Compiled、Failed、Time
-gccapacity查看各区域从初始值到最大值的容量变化NGCMN、NGCMX、OGCMN、OGCMX

很多初学者一上来就盯着-gc那一大串字段懵了,其实排查时最优解是先跑-gcutil管占比、再看需求用-gccause查原因,性价比最高。

2.2 输出字段逐个拆解

jstat -gcutil的输出大概是这个样子:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 12.50 0.00 58.23 67.89 95.12 94.50 218 3.891 5 1.230 5.121

这里的每个字段都有明确含义:

  • S0、S1:两个Survivor区(幸存者区)当前的使用率百分比。Survivor区在复制算法里扮演“中转站”的角色,Young GC时存活对象会被复制到其中一个区。正常情况两个区不会同时都很高,因为同一时刻只有一个Survivor区在接收存活对象。
  • E:Eden区的使用率百分比。Eden区是对象“出生”的地方,绝大多数对象在这里被创建、死去。Eden区经常被打满其实是正常现象,因为它打满就意味着Young GC要开始了。
  • O:老年代的使用率百分比。这是排查内存泄漏时最需要盯的字段。老年代持续增长且不回落,往往是强引用对象越积越多的信号。
  • M:元空间(Metaspace)的使用率百分比。JDK8以后方法区被元空间取代,存放类元数据。应用动态生成大量类(比如反射、CGLIB代理)时这个值会明显攀升。
  • CCS:压缩类空间(Compressed Class Space)的使用率百分比。
  • YGC、YGCT:Young GC的次数和累计耗时(秒)。YGCT是累计值,不是单次耗时。
  • FGC、FGCT:Full GC的次数和累计耗时(秒)。
  • GCT:GC的总累计耗时,YGCT + FGCT之和。

看清楚这些字段就明白了,-gcutil更像是一张“仪表盘”,告诉你每个区域的健康状态和GC压力。而-gc选项则在此基础上多给出容量数据,字段形如:

S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 7168.0 7168.0 896.0 0.0 57344.0 33432.5 131072.0 88977.3 51200.0 48765.4 6144.0 5812.3 218 3.891 5 1.230 5.121

区别在于-gc给出的是具体的KB容量值和已用量,-gcutil给出的是百分比。实际使用中我通常先看-gcutil快速评估健康状况,确认某区域异常增长后再切到-gc看具体容量空间够不够。

2.3 为什么采样间隔和次数很关键

jstat单次采样的参考价值有限,至少连续采样才能看出趋势。我建议排查问题时用1秒或2秒的间隔,采5到10次,观察每个区域的变化方向和GC频率。

举个例子,如果单看到老年代占用率是80%,你很难判断是正常蓄水还是泄漏。但如果你每秒采一次,连续采8秒,发现老年代占用率从75%、77%、79%、81%一路涨上去,Young GC又频繁得像打地鼠一样,那基本可以断定对象的晋升速度异常,必须引起重视。

单次采样看状态,连续采样看趋势,这是jstat用法的核心心法。另外采样间隔不要太长,否则趋势信息被稀释;也不要太短,否则大量输出反而干扰判断。生产环境上我一般用1000毫秒间隔采10次,总共10秒钟就能对JVM状态有个基本判断。

2.4 一个容易被忽略的细节:进程PID别搞错

在一台机器上跑着多个Java进程的时候,jps -l的输出要仔细看,别只图省事用jps不加参数。jps -l会显示完整的main类名或jar包路径,能帮你准确区分是哪条业务线的进程。

有一次我排查问题,机器上有两个Java进程,一个是业务服务,一个是定时任务平台,PID挨得特别近。我随手用jps看了个短PID就开跑jstat,结果分析半天数据对不上号,最后才发现盯错了进程。这种低级错误,浪费的不是几分钟,而是整个排查方向。

3. 实操过程与核心环节实现

3.1 用jstat给JVM拍一张“状态快照”

先演示最基础的用法。假设我们通过jps -l查到目标Java进程的PID是25412,先看一下类加载情况:

jstat -class 25412

输出示例:

Loaded Bytes Unloaded Bytes Time 8235 15833.2 0 0.0 4.72

这里看到加载了8235个类,说明应用启动到现在类加载是收敛的,没有在运行期疯狂加载新类。如果这个数字在持续飙升,就要怀疑是不是有动态生成类的逻辑在作祟。

再看编译统计:

jstat -compiler 25412

输出示例:

Compiled Failed Invalid Time FailedType FailedMethod 4521 0 0 18.32 0

JIT编译了4521个方法,没有编译失败。这一步主要是确认JIT没有异常,正常情况下后续不需要过多关注。

然后是重点,GC和内存区域的整体状态:

jstat -gcutil 25412

输出示例:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 24.50 0.00 72.13 89.65 94.12 90.50 1532 12.891 12 8.230 21.121

从这组数据能读出什么信息?YGC已经发生了1532次,累计Young GC耗时12.8秒;Full GC只有12次,但FGCT已经8.2秒,平均每次Full GC耗时接近0.7秒——这个耗时明显偏高。老年代占用89.65%,逼近临界值。整体判断:这个JVM已经处于高压状态,GC参数需要调整。

3.2 连续采样看趋势,定位老年代持续增长问题

继续用上面的PID,做个10次、每次间隔1秒的采样:

jstat -gcutil 25412 1000 10

假设输出结果里,O那一列的数据是这样的序列:89.65、90.12、90.78、91.45、92.03、92.67、93.28、93.91、94.52、95.14。

这个序列10秒内涨了5个多百分点,说明老年代在以肉眼可见的速度膨胀。这时候再配合观察YGC的数值是不是也在快速增长,就能拼出一个完整的故事:Young GC非常频繁(可能每秒都要触发好多次),但每次Young GC后依然有大量对象因为Survivor区放不下而直接晋升到老年代,老年代被持续撑大,而Full GC又迟迟没触发,触发时因为老年代接近满,耗时又特别长。

这种模式很典型的诱因是:创建了大量生命周期偏长的对象、缓存没有上限、或者Survivor区配置过小导致对象过早晋升。有了jstat给出的数据方向,下一步的选择就清晰了——要么用jmap看老年代里都是什么对象,要么review代码里的缓存和集合使用方式。

3.3 用jstat -gccause确认Full GC的触发原因

-gcutil能告诉你“现在是什么状态”,-gccause能告诉你“最近的GC是为什么发生的”:

jstat -gccause 25412

输出示例:

LGCC GCC Allocation Failure System.gc()

LGCC(Last GC Cause)显示最近一次GC的原因是Allocation Failure——也就是分配对象时内存不足触发的;GCC(Current GC Cause)显示当前触发GC的原因是System.gc()——说明有代码显式调用了System.gc()。

有意思的地方就在这里。当生产代码里有人调用了System.gc(),配合某些JVM参数(比如-XX:+DisableExplicitGC没配上)会导致Full GC被人为地频繁触发。-gccause在这种场景下就是一个“元凶雷达”,一把抓住显式GC调用。

我遇到过不止一次:团队里有人为了“主动清理内存”在业务代码里写了System.gc(),结果线上Full GC频繁、接口毛刺不断。最后就是用jstat -gccause锁定System.gc()这个原因的。

3.4 结合场景:一个典型的GC压力排查全流程

说一个我实际处理过的模拟场景。某数据同步服务每隔一段时间会出现明显的卡顿,每次持续一两秒,人工观察时服务又能恢复。

第一步,先跑jstat -gcutil 25412 1000 10:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 80.12 0.00 95.34 42.55 88.10 85.20 5421 23.451 0 0.000 23.451 ...

注意几点:YGC已经5421次但FGC还是0。Young GC非常频繁,YGCT累计时间在快速增长,但老年代才用到42%左右。每次Young GC的耗时算下来平均不过几毫秒,但累计时间在涨,说明Young GC频率极高。

第二步,连续多采几轮,观察YGC增长速率。假设每5秒YGC增加100次左右,算下来每秒20次Young GC,这明显不正常。正常服务每秒能有几次Young GC已经算忙了,20次意味着Eden区被迅速塞满,说明对象的分配速率极高。

第三步,用jstat -gc 25412确认Eden区容量的配置:

S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 512.0 512.0 408.2 0.0 4096.0 3904.8 8192.0 3444.7 20480.0 18040.2 2560.0 2180.3 5421 23.451 0 0.000 23.451

Eden区只有4MB,Survivor区只有512KB。这个堆配置对于数据同步服务来说过小了。Eden区太小导致对象刚分配没多久就触发Young GC,Young GC后存活对象进入512KB的Survivor区,空间不足又直接晋升老年代。

到这里,问题指向已经很明确:不是代码bug,而是JVM堆空间规划不合理。后续调整方向是增大堆内存、合理设置新生代比例,或者改用G1并调整-XX:MaxGCPauseMillis目标。这就是jstat给出的数据和方向。

3.5 做个可视化的小脚本方便持续观察

命令行输出看久了眼睛容易花,我习惯用一个小脚本把关键指标提取出来,形成一行更紧凑的摘要:

jstat -gcutil 25412 1000 10 | awk 'NR==1 {print $0} NR>1 {printf "E:%s%% O:%s%% YGC:%s FGC:%s GCT:%.3f\n", $3, $4, $7, $9, $11}'

这段awk只是把Eden占比、老年代占比、Young GC次数、Full GC次数和总GC耗时摘出来。真要长时间持续监测,也可以结合date命令给每行加个时间戳:

jstat -gcutil 25412 1000 1000 | while read line; do echo "$(date +%H:%M:%S) $line"; done

这样积累一段时间的数据,拉到Excel里画折线图,老年代的Trend曲线一眼就能看出有没有泄漏苗头。我在做容量评估时经常这么干,用一晚上的数据判断服务的GC水位是否稳定。

4. 常见问题与排查技巧实录

4.1 jstat查看远程进程或容器进程怎么办

很多服务现在跑在容器里,直接在宿主机上执行jps不一定能列出容器内的Java进程。这时要么进容器内部执行jstat,要么在宿主机上用ps -ef | grep java找到Java进程的PID再执行jstat。注意容器PID namespace隔离的问题,宿主机上看到的PID和容器内看到的可能不一致,最稳妥的做法是直接docker exec进容器里执行jstat。

如果遇到Unable to open socket file之类的报错,通常是进程的/tmp目录下有权限问题,或者Java进程不是当前用户启动的。最简单的排查方式是用ps -ef确认进程的启动用户,然后用相同用户切换身份执行命令,或者临时调整java.io.tmpdir参数,但这个需要重启进程,不是紧急场景建议做的。

4.2 jstat显示数据不变或指标异常怎么办

有时候jstat输出的数据看起来“僵住”了,YGC和各项数值一直不变。这种时候先别慌着下结论,很可能是这几秒内JVM确实没有触发任何GC,不代表出了问题。如果应用本身有持续的内存分配,但YGC停留在同一个数字很久,这反而说明当前堆空间很充裕,对象分配不需要频繁触发GC。

反过来,如果YGC数字跳得飞快,但YGCT的增量却很小,说明Young GC本身很快,问题在于频率过高,这是典型的“分配速率过高”或“堆太小”信号。如果YGC频率不高,但YGCT单次耗时很长,就要考虑是不是有大量对象在Survivor区反复复制,或者当前GC算法在特殊场景下性能不佳。

最诡异的情况是:jstat显示FGC=0,但线上明显有停顿感。这种时候要怀疑是不是STW(Stop The World)停顿并非来自GC,而可能是JIT编译导致的(-compiler选项可以辅助判断)、偏向锁撤销导致的、或者线程转储时锁竞争导致的。jstat只是GC统计工具,它说没GC不代表JVM真的没停顿过。

4.3 jstat输出字段含义对照速查表

为了省去每次查文档的麻烦,我把最常用的字段整理成一张速查表,贴在这里方便收藏:

字段含义使用建议
S0C / S1CSurvivor 0/1区容量(KB)判断Survivor是否太小(过小导致对象直接晋升老年代)
S0U / S1USurvivor 0/1区已用量(KB)结合S0/S1百分比看复制算法工作状态
EC / EUEden区容量和已用量EU接近EC意味着Young GC即将触发
OC / OU老年代容量和已用量OU持续上涨是内存泄漏的最强信号
MC / MU元空间容量和已用量动态生成类场景重点盯这个
YGC / YGCTYoung GC次数/累计耗时高频+低耗时指向分配速率问题
FGC / FGCTFull GC次数/累计耗时FGCT大说明老年代回收成本高,或者GC算法配置不当
GCTGC累计总耗时全貌指标,GCT占运行时间比例过高就必须处理

4.4 关于G1和JDK新版本的注意事项

如果你的服务运行在JDK8u之后并使用了G1收集器,jstat输出的字段含义是完全一样的,仍然是S0、S1、E、O这些区域统计。但要注意一个细节:G1是一个逻辑上分代但实际上物理内存分区的垃圾回收器,它没有传统意义上的连续Eden、Survivor区域,jstat显示的百分比是G1各个Region集合的汇总结果。这意味着你会在G1下看到S0、S1经常是0%,这是正常的,不代表Survivor区没工作,而是G1在回收时会切换使用不同的Region作为Survivor区。

JDK11之后引入了ZGC、JDK14引入了Shenandoah,这些低延迟收集器在jstat输出里依然会显示YGC/FGC等指标,但解读方式完全不同。ZGC的GC基本都是并发执行,你以为的“FGC=0”不代表它没在做回收。所以在用jstat解读新GC算法时,建议同时查阅对应收集器的官方说明,别拿着G1之前的思维惯性硬套。

4.5 踩坑实录:jstat不是用来抓线程卡顿的

我刚接触jstat时犯过一个方向性错误:线上接口突然卡住,我第一反应是跑jstat看GC数据。结果GC数据一切正常,根本没有Full GC痕迹,但问题确实存在。折腾了半天才发现,是数据库连接池被耗尽,线程都在等连接,跟GC毫无关系。

这个教训后来让我形成了一个习惯:任何JVM问题的排查,先做“二分”。先确认是线程问题(jstack)、内存问题(jstat+jmap)还是资源竞争问题(结合监控系统)。jstat回答的是“JVM的内存与GC状态”,不是“线程为什么卡住”。方向错了,再好的工具也是浪费时间。

5. 经验总结与工作流建议

说了这么多,最后分享一点我沉淀下来的工作流习惯,希望能帮你把jstat融入日常排查手段里。

第一个习惯:问题现场先留底。无论是线上突发还是压测发现异常,第一步一定是jstat -gcutil <PID> 1000 10连续采集一组数据,连同时间点一起记录下来。哪怕当时看不出端倪,后续排查时这份现场数据就是最宝贵的参照系。

第二个习惯:建立GC基线。服务正常运行时,抽空用jstat采集几组数据,记录下正常状态的YGC频率、FGC间隔、老年代水位线。日后出了问题,拿异常数据跟基线一对比,偏离一眼就能看出来。这个成本几乎为零,但价值极高。

第三个习惯:jstat和jmap配合使用。jstat告诉你老年代在涨,jmap执行jmap -histo:live <PID>能进一步告诉你老年代里占比最高的对象类型是什么。如果发现某个业务对象占据了大量内存,代码问题基本就有了明确的搜索方向。

第四个习惯:别忽略服务启动参数。看到jstat数据异常,第一时间检查启动参数里-Xms、-Xmx、-XX:NewRatio、-XX:SurvivorRatio这些配置是否合理。很多“GC频繁”的根本原因就是堆大小和比例配错了。确认参数问题后调整重启,往往是性价比最高的解决方式。

jstat这个工具,比它看上去能做的事情多得多。它没有花哨的界面,没有炫目的图表,输出的只是一行行乍看枯燥的数字。但正是这些数字,构成了判断JVM健康与否最可靠的地基。把jstat用熟了,你在JVM排查时就不再是靠感觉瞎猜,而是手里攥着实打实的数据。下次你的服务再出现诡异的卡顿,不妨先别急着上重型分析工具,敲一句jstat -gcutil看看它告诉了你什么。

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

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

立即咨询