☰
JDK自带jstat命令:生产环境JVM性能监控与GC排查实战
2026/10/6 4:41:32 网站建设 项目流程

Java应用上了生产之后,迟早要面对“CPU飙高、接口变慢、GC频繁”的故障。我见过不少团队第一反应是拉jmap堆栈、刷jconsole监控面板,唯独把JDK自带的命令行小工具jstat晾在一边。实际上,jstat是JDK自带、无需额外安装的轻量级监控工具,它能在不影响业务进程的前提下,把堆内存使用率、新生代和老年代占用、GC次数、GC暂停耗时一次性输出在终端里。尤其是手头只有一台Linux服务器、一个SSH窗口、没有任何图形界面和监控平台时,jstat往往是我敲下的第一句排查命令。

有人觉得“监控JVM一定要上普罗米修斯、Grafana那套全家桶”,其实未必。jstat小、快、准,它不依赖外部组件,也不需要在应用里埋点,平时用来做快速体检、定位GC问题、判断是否需要调优,完全够用。这篇内容我把jstat的参数、输出列、实战用法和踩坑点一次讲透,力求你拿到手就能直接照着敲。

1. 认识jstat:它在一个JVM监控体系里站在哪个位置

1.1 jstat到底在监控什么

先说结论:jstat是JDK自带的性能统计工具,全称JVM Statistics Monitoring Tool,一般位于$JAVA_HOME/bin目录下,和jps、jmap、jstack属于同一族工具。它通过JVM内部的Attach机制读取进程的统计计数器(performance counters),不需要你在应用里开JMX端口,也不需要额外装agent,也就是说对业务代码完全无侵入。

有个经典的误解:jstat只能看GC。实际上它的监控范围比GC宽得多。按我平时使用的频率排序,它至少能看四类数据:

  • 类加载和类卸载的数量、耗时
  • 即时编译(JIT)编译了多少方法、失败次数、最近编译的方法
  • 堆内存中Eden、Survivor、老年代、元空间等区域的使用量和使用率
  • GC次数、GC累计耗时,以及最近GC触发的原因

如果拿开车来类比,jmap、jstack那些工具类似把引擎盖打开去检查零部件,而jstat更像坐在驾驶座看仪表盘:转速、油量、水温一目了然。它能帮你快速判断“车是不是快没油了”“发动机是不是过热了”,但具体哪个零件坏了,还需要再配合其他工具去定位。

1.2 为什么即便有监控平台,“裸敲”jstat也值得学

我在不同公司待过,有的团队监控体系建得很完善,Grafana大盘上堆内存、GC、CPU曲线一应俱全。但真正出故障的时候,我还是习惯先打开一个终端敲jstat。

原因有三点。

第一,监控平台的采集粒度通常不够细。很多监控系统把JVM指标设置成15秒或30秒采集一次,平时看趋势没问题,但故障发生时GC可能一两秒内密集发生,这种粒度根本看不出突变点。jstat可以做到几百毫秒采样一次,连续输出几十条,GC频率的变化顿时清晰可见。

第二,生产环境登录服务器后,你未必有权限打开图形界面,甚至监控平台的账号都可能要临时申请。而jstat只要你有JDK环境的操作系统账号即可使用,几乎不需要额外授权。在“快速止血”的黄金五分钟里,打开终端敲jstat比登录监控平台再层层点开面板更直接。

第三,一台物理机上可能跑着多个Java进程,监控平台显示的是某个服务整体的指标,没法简单区分具体是哪个PID引起的问题。jstat直接指定进程号采样,问题归属非常明确。

我并不是说jstat能取代监控平台,它当然不能做历史趋势回溯、告警通知、多实例对比这些事情。但它是JVM排障链路里“最前一步”的最佳选择:先靠jstat确认嫌疑区域,再决定要不要上jmap、jstack、MAT这些重家伙。这一套由轻到重的思路,才是排障的正确姿势。

2. 基础用法:找到进程、敲出第一条jstat命令

2.1 先明确jstat的通用语法

jstat的命令格式并不复杂,套用下面这个模板就行:

jstat [generalOption | outputOptions vmid [interval[s|ms] [count]]]

拆开来看:

  • generalOption:一般是-options,用来列出当前JDK支持的输出选项,属于一次性查询。
  • outputOptions:这才是核心,比如-gcutil、-gc、-class,决定你要看哪一类统计数据。
  • vmid:虚拟机标识,本地场景下通常直接写Java进程的PID。远程场景写成protocol:host:port,但日常排查用得很少。
  • interval:采样间隔,单位默认毫秒,可以写成1000,也可以写成1s。
  • count:采样次数,不填就无限输出,直到你按Ctrl+C。

所以下面这条命令的意思就很好理解了:

jstat -gcutil 12345 1000 10

对PID为12345的JVM进程,每隔1000毫秒(即1秒)采样一次,共输出10条-gcutil格式的数据。

在实际操作中,我一般先执行jstat -options看一下当前JDK支持哪些选项,尤其服务器上JDK版本不一的时候,确认一下最稳妥:

$ jstat -options -class -compiler -gc -gccapacity -gccause -gcmetacapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcutil -printcompilation

看到这个列表,再对照JDK文档,心里基本就有数了。需要注意的是,不同JDK版本输出的列会有细微差别,比如JDK8之后多了Metaspace和CCS相关列,JDK9以后的容器环境还能看到线程栈大小等信息,以实际输出为准。

2.2 一条最常用的命令与输出解读

假设我们在服务器上用jps找到了业务进程:

$ jps -l 12345 com.example.order.OrderService

然后执行:

$ jstat -gcutil 12345 1000 5

输出大概长这样:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 63.21 78.50 42.35 86.21 80.33 1024 15.214 12 2.138 17.352 0.00 63.21 81.66 42.35 86.32 80.45 1025 15.241 12 2.138 17.379 0.00 63.21 85.10 42.35 86.51 80.67 1026 15.308 12 2.138 17.446

第一眼很花,拆开看其实就三类信息:堆各区使用率、GC次数、GC累计耗时。

列名全称说明
S0Survivor区0新生代中Survivor区0的使用率,百分比
S1Survivor区1新生代中Survivor区1的使用率,百分比
EEden区新生代Eden区使用率,百分比
O老年代老年代使用率,百分比
M元空间元空间使用率,百分比
CCS压缩类空间类指针压缩空间使用率,百分比,JDK8及以后常见
YGC新生代GC次数也就是Minor GC发生的次数
YGCT新生代GC耗时所有Minor GC累计耗时,单位秒
FGCFull GC次数老年代或整堆Full GC发生的次数
FGCTFull GC耗时所有Full GC累计耗时,单位秒
GCTGC总耗时新生代GC和Full GC的累计耗时总和,单位秒

这里有个经验点:观察GC数据不能只看绝对值,要看变化趋势。比如一分钟之内YGC从1024涨到1200,那相当于一分钟发生了176次Minor GC,平均每秒接近3次,这个频率对大多数业务系统已经偏高了。更值得警惕的是FGCT如果有明显增长,说明系统在做Full GC,老年代会不断被扫描回收,停顿时间很容易以“秒”为单位。第一条命令拿到后,先看两边趋势,一边是YGC与YGCT的增速,一边是O区的占比变化,基本就能判断是否进入“内存压力”状态。

3. 参数详解:按监控目标把jstat选项逐个吃透

3.1 类加载与即时编译:-class、-compiler、-printcompilation

多数人排查GC问题时会忽略类加载和编译这两个维度,但它们恰恰能解释一些诡异现象,比如CPU突然飙高、单次GC间隔异常。

先看类加载情况,命令是:

jstat -class 12345

输出示例:

Loaded Bytes Unloaded Bytes Time 3568 7893.2 102 1534.1 1.35
  • Loaded:已加载的类数量
  • Bytes:已加载类占用的空间,单位KB
  • Unloaded:已卸载的类数量
  • Bytes:已卸载类占用的空间
  • Time:类加载和卸载累计耗时,单位秒

类加载数量异常增长时,要警惕是否有反射生成类、频繁创建动态代理、或者元空间泄漏的苗头。比如某个框架在运行期不断生成新的Class,Loaded数量持续爬升,最终就会把Metaspace顶到上限。

再看即时编译状态,命令是:

jstat -compiler 12345

输出示例:

Compiled Failed Invalid Time FailedType FailedMethod 4626 0 0 12.87 0
  • Compiled:成功编译的方法数量
  • Failed:编译失败的方法数量
  • Invalid:编译后又被判定为无效的方法数量
  • Time:编译累计耗时,单位秒
  • FailedType:编译失败的类型
  • FailedMethod:编译失败的方法名

JIT编译本身是好事,方法被编译成机器码后执行效率更高。但如果Invalid数量剧烈增大,说明编译结果频繁作废,通常和代码逻辑频繁变化、类被重复加载有关。还有一种场景:启动初期Compiled数量会迅速增长,业务还没预热完成就把流量打进来,CPU会被JIT编译分走大量资源,这在大流量发布时很常见。

最后是-printcompilation,它显示的是最近被JIT编译的方法列表:

jstat -printcompilation 12345

输出示例:

Compiled Size Type Method 63 128 1 java/lang/String.hashCode ...

这个方法在实际排障中我会用在“CPU飙高但不知道热点在哪”的场景,配合-compiler看有没有大量方法在编译。注意这个方法只能显示最近一次编译信息,不是历史编译清单,所以更适用于对比:正常情况下编译数量平稳,异常时编译量突然激增。

3.2 堆内存与GC主视角:-gc、-gcutil、-gccause

这三个参数是日常排障的主力,重点展开。

-gc参数输出的是堆内存各区域的“容量和使用量”,单位是KB(不同JDK版本略有差异,但基本都是KB)。命令:

jstat -gc 12345 1000 5

输出示例:

S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 8192.0 8192.0 0.0 5192.0 65664.0 54320.1 175648.0 74262.3 65432.0 56320.1 8192.0 7680.2 1026 15.241 12 2.138 17.379

看着比-gcutil复杂,理解规律其实很简单:每个区域都有C和U两个列,C表示该区域当前容量(Capacity),U表示该区域已使用量(Used)。比如S0C是Survivor区0的容量,S0U是Survivor区0的使用量;EC是Eden区容量,EU是Eden区使用量;OC是老年代容量,OU是老年代使用量;MC是元空间容量,MU是元空间使用量;CCSC是压缩类空间容量,CCSU是压缩类空间使用量。

-gcutil就简单了,它直接给出使用率百分比,和-gc的数据其实是同一件事的两种表达方式。一个看绝对值,一个看百分比。在我看来,如果只想快速判断内存压力,-gcutil更直观;如果要计算“Eden区还剩多少容量”来推算分配速率,那-gc更合适。两者互补着用。

-gccause则在-gcutil的基础上多了两列:LGCC(最近垃圾回收原因)和GCC(当前垃圾回收原因)。命令:

jstat -gccause 12345 1000 5

输出大致这样:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT LGCC GCC 0.00 63.21 88.10 43.20 86.40 80.30 1028 15.400 12 2.138 17.538 Allocation Failure Allocation Failure

这两列非常有用。最常见的原因是Allocation Failure,意思是“分配内存失败,被迫触发GC来腾地方”,本质说明当前内存不够用或者分配速率过快。如果是G1垃圾回收器,还可能看到G1 Evacuation Pause、G1 Humongous Allocation这类原因。G1 Humongous Allocation表示有大对象(超过Region大小一半)直接在老年代分配,往往是大数组、大缓存引起的,这种对象频繁出现很容易造成老年代碎片。

我在定位问题时,先扫LGCC和GCC的出现频率,如果反复出现Allocation Failure,基本可以断定是内存压力;如果出现频率很低,那GC频次高可能是代码里频繁创建大对象引起的,此时就应该转向jmap和代码排查了。

3.3 容量与分段视角:-gccapacity、-gcnew、-gcold及对应capacity

这几个参数适合观察堆内存容量边界和动态扩容状态。

-gccapacity展示了各区域的最小容量、最大容量、当前容量以及段大小。命令:

jstat -gccapacity 12345

输出列很长,重点看这些:

  • NGCMN:新生代最小容量
  • NGCMX:新生代最大容量
  • NGC:新生代当前容量
  • OGCMN:老年代最小容量
  • OGCMX:老年代最大容量
  • OGC:老年代当前容量
  • MCMN:元空间最小容量
  • MCMX:元空间最大容量
  • MC:元空间当前容量

为什么要关注容量?因为很多服务启动时不会立刻把堆撑到-Xmx设定值,而是随着使用量逐渐扩容。如果发现NGC已经等于NGCMX,说明新生代已经到了上限,接下来如果还需要更多Eden空间,就只能触发GC后复用空间,这个阶段最容易出现GC频率上升。老年代同理。

-gcnew只看新生代细节:

jstat -gcnew 12345 1000 5

输出里包含S0C、S1C、S0U、S1U、MT(Min Tenuring阈值)、EC、EU、YGC、YGCT等列。MT是对象晋升到老年代的年龄阈值,默认通常是15,但动态年龄判定(Dynamic Tenuring)会调整这个值。如果你发现老年代OU在快速上涨,但YGC并没有很多次,那可能是MT太小,对象过早晋升了。

-gcold则只看老年代:

jstat -gcold 12345

输出包括OGCMN、OGCMX、OGC、OC、OU、YGC、FGC、FGCT、GCT。重点观察OU(老年代使用量)的曲线,如果它一直增长、从不清零,那么不管YGC次数多低,最终都逃不过Full GC。

另外还有-gcnewcapacity和-gcoldcapacity,它们更多用于容量分配分析,和-gccapacity类似,不过细分到新生代老年代各自的最小、最大、当前容量。这几个参数我不建议一开始就用,容易看花眼。先掌握-gcutil和-gccause,遇到内存边界问题时再看容量类参数,这样节奏最舒服。

4. 实操复盘:用jstat定位一次真实接口变慢

4.1 现象与第一轮观察

有一次线上订单服务接口的RT从20ms涨到300ms,还有持续上升的趋势。接到告警以后我登录服务器,先看了CPU和负载,CPU使用率并不高,内存也没满,这让我第一反应是“可能不是单纯的计算量增加”,而是GC停顿在作祟。

当时我用jps找到进程号后,直接敲了这条命令:

jstat -gcutil 21456 1000 10

前两秒的输出让我立刻警惕起来:

S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 88.10 99.20 65.30 85.20 79.80 26021 218.32 38 96.25 314.57 0.00 62.00 98.70 65.31 85.25 79.85 26022 218.36 38 96.25 314.61

Eden区使用率一直在98%以上,YGC在一秒内涨了一次,FGCT已经累计96秒,FGC一共38次,平均一次Full GC耗时2.5秒以上。这说明系统已经处在非常危险的状态:每次Full GC都冻结业务线程2秒以上,接口RT被拉高到300ms完全对得上。

4.2 连续采样与趋势观察

光有两条数据还不够,我继续执行:

jstat -gcutil 21456 500 20

每500毫秒采样一次,连续20条,这样能看到更细的GC节奏。结果发现一个规律:每隔3到5秒,YGC次数就涨一次,每次YGC之后S1区从接近满的状态降到很低,Eden又从接近满重新开始积累。这说明新生代对象频繁触发Minor GC,但大部分对象在Minor GC之后并没有被清掉,而是熬过了几次复制后进入老年代,导致OU(老年代使用量)也在肉眼可见地增长。

我又敲了-gccause确认触发原因:

jstat -gccause 21456 1000 5

LGCC列反复出现Allocation Failure,这更加确认了判断:内存分配速率远大于回收速率,老年代空间也在持续消耗,最终每隔一段时间就有一次Full GC来“填坑”。

4.3 定位结论与辅助操作

第一轮定位到这里,还不能轻易下结论说是“内存泄漏”,因为有一种常见情况是流量突增导致对象总量变大。所以我接下去做了两个动作。

首先看老年代容量设置。我用jstat -gccapacity 21456看到OGCMX是2GB,OGC是老年代当前容量1.5GB左右,说明堆还有空间,但老年代既然已接近1.5GB且持续上升,问题肯定真实存在。

然后我把jmap和jstack作为辅助工具继续深挖。jmap -histo 21456 | head -30可以看到占用实例数最多的类,jstack 21456则看业务线程在做什么。最终定位到某条缓存线程在频繁往一个静态Map里写数据,key不重复,导致Map无限膨胀。这个Map里的对象被老年代引用,GC回收不掉,OU自然越来越高。

整个排查过程耗时大约10分钟,前5分钟基本靠jstat就锁定了“GC停顿拖垮RT”这条主线,后面jmap和代码审查是验证根因。如果没有jstat,我可能还要先翻监控平台看曲线,再推断是不是数据库慢查询导致RT升高,完全走错方向。

5. 实战避坑:常见问题排查与工具配合顺序

5.1 S0/S1一直为0是正常情况吗

很多刚接触jstat的人会问:为什么我的S0和S1长期是0?是不是监控错了?

大多数情况是正常的。我在JDK8和JDK11下的常见GC组合里都见过这种现象。原因有几个:

第一,当你使用SerialGC或者ParallelGC时,如果新生代里绝大多数对象在Minor GC之后直接晋升到老年代,Survivor区可能根本没有对象存活,S0和S1自然可能为0。

第二,如果你的服务对象生命周期很短,每次GC前Eden区对象本来就不多,Minor GC直接清空,那么Survivor区可能会被清成0。这本身说明“回收很干净”。

第三,G1和ZGC这类垃圾收集器的逻辑和传统分代GC差异很大,G1在-gcutil输出里的Survivor列也不一定会像ParallelGC那样持续有数据。比如ZGC在某些版本里对Survivor区的概念和传统解释完全不同,所以不能拿CMS的老经验硬套。

判断是否异常,不应该只看S0/S1,而要看整体趋势。如果S0/S1长期为0,但Eden区每次都大量存活对象晋升,老年代OU不断上涨,那才值得我们担心。

5.2 jstat与jmap、jstack的配合顺序

排障工具的使用顺序非常重要。我总结的经验是:先看统计,再抓现场,最后分析堆栈。jstat属于统计类,jmap和jstack属于抓现场类。顺序颠倒很容易踩坑。

一个典型的错误是:线程卡顿问题一上来就jstack,看到一堆线程在等待锁,就急着分析锁关系。但如果不先用jstat确认GC状态,完全有可能这堆线程只是在等GC停顿结束,它们所谓的“等待锁”其实是阻塞之后的表现形式。等GC停下来,锁等待自然消失。先jstat排除GC因素,再上jstack,分析出的线程状态才真实可靠。

jmap的情况更要注意。jmap -dump:live,format=b,file=heap.bin <pid>会先触发一次Full GC再生成堆转储,在高峰期执行这个操作等于给服务雪上加霜。所以我通常建议:能只靠jstat和jstack判断的问题,就不轻易jmap;确实需要堆转储,先保证服务允许短暂停顿,或者等低峰期再执行。

排障的完整顺序,我个人比较推荐这样:

  1. jps确认进程号
  2. jstat -gcutil和jstat -gccause看GC趋势和原因
  3. jstack看线程状态,快速确认是否因GC停顿造成大规模阻塞
  4. 必要时jmap -histo看对象分布,最后再做堆转储深入分析

5.3 易错写法与JDK版本差异

我用jstat这些年,遇到过几个非常容易踩的坑。

第一个坑:把进程名当成PID。jstat的vmid必须是数字PID,你直接写进程名它不认识。每次都要先jps或pgrep查进程号,很多人想省这一步结果报错Unknown host或者Illegal vmid,白白浪费时间。

第二个坑:采样参数顺序写错。jstat -gcutil 12345 1000 5表示每1000毫秒采样5次,但如果你写成jstat -gcutil 12345 5 1000,意思就变成每5毫秒采样1000次。5毫秒一次的采样间隔对jstat本身影响不大,但输出几千行数据,肉眼根本看不过来。单位尽量用秒级,比如1s、2s,简单不易错。

第三个坑:权限问题。某些容器环境或者云服务器上,当前操作系统的用户不是Java进程的启动用户,jps可能看不到进程,jstat执行时也会报permission denied。这时候要用sudo -u <启动用户>执行,或者直接切换到目标用户下操作。还有一些高度受限的容器里,JVM的/tmp目录perfdata文件可能无法创建,jstat会报无法获取统计信息的错误,需要先确认容器的临时目录是否可写。

第四个坑:JDK版本差异导致输出列不一样。比如JDK8的-gcutil有CCS列,JDK11的ZGC输出又可能没有传统Survivor概念。还有JDK8中的Metaspace使用率M列,如果用了-XX:MaxMetaspaceSize和未设置时,显示逻辑也不同。所以不同机器之间对比时,先把JDK版本对齐,否则你会拿不同的口径在比较数据。

建议每次换一台新服务器排查之前,先执行java -version和jstat -options两个命令确认环境,不要靠记忆硬写。

6. 写在最后的一些个人经验

最后聊几句我用了这么久jstat的真实感受。

第一,它能解决80%的“Java进程变慢但不知道从哪下手”的问题。每次有人来问我服务卡了怎么排查,我永远先让他们敲一次jstat -gcutil。结果往往立刻就有方向:要么YGC频繁到离谱,要么FGC一涨就是几十秒。先把GC问题排除掉,剩下的代码、锁、数据库问题才会浮现出来。这个顺序帮我少走了很多弯路。

第二,jstat的输出不要只看一条,要连续观察一段时间。我习惯用jstat -gcutil 12345 1s 60这种方式看一分钟趋势。如果YGC增长速率稳定,GC总耗时也平稳,服务可能就是正常状态;一旦速率异常上升,马上就能抓住。只截一张图看瞬时值,很难区分“这一秒恰好GC”和“持续都在快速GC”这两种情况。

第三,jstat还能用来验证调优效果。比如我调整了新生代比例或者换了GC算法,隔半小时跑一次jstat,把YGC频率和FGCT做前后对比,效果一目了然。这种轻量级的验证方式,比起专门搭一套压测环境要简单得多。

工具终究是工具,真正值钱的是把它放到合适的问题上下文里。jstat给我最大的帮助不是它本身有多炫,而是它让JVM从“黑盒”变成“透明盒”。当你能一眼看出Eden区飞速膨胀、老年代缓慢上涨、Full GC越来越频繁的时候,那些关于内存分配、对象生命周期、GC参数的话题,才真正开始有了现实意义。

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

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

立即咨询