做Java开发或者运维的朋友,应该都有过这样的经历:线上应用突然CPU飙高,接口响应变慢,日志里频繁出现OutOfMemoryError,服务偶尔还直接宕掉。这时候你手头如果只有top、free这些系统命令,能看到的只是表象;真正要定位JVM内部发生了什么,最顺手的就是JDK自带的命令行监控工具,而jstat就是其中专门用来监控JVM运行状态的那一把利器。
jstat的全称是 JVM Statistics Monitoring Tool,从 JDK 5 开始就内置在JDK里,不需要额外安装任何组件,也不需要重启应用或者改一行代码。它能够实时统计类加载、垃圾回收(GC)、JIT编译等关键数据,输出堆内存各区域的使用率、GC次数、GC耗时等指标。对正在学习JVM调优的开发者、负责线上运维的工程师、做性能压测的人,或者单纯想搞清楚“JVM进程里的内存到底去哪了”的Java程序员来说,这个命令都值得熟练掌握。这篇文章我会把jstat的常用参数、输出字段、实战思路和踩坑记录完整梳理一遍,尽量让你看完就知道怎么用,遇到问题也知道怎么看。
1. jstat到底在监控什么:先从JVM内存模型说起
1.1 堆内存到底分了哪几块:为什么jstat输出全是字母缩写
第一次看jstat输出的人,多半会被一屏的字母缩写搞晕:S0C、S1C、EC、OC、OU、MC……这些字母并不是无意义的编号,它们全部对应JVM运行时内存里的具体区域。要读懂jstat,先要记住 HotSpot 虚拟机把堆(Heap)分为新生代(Young Generation)和老年代(Old Generation),JDK 8 之后又把类的元数据放到了堆外的元空间(Metaspace)。
新生代内部还可以继续细分。大多数对象刚创建时先进入 Eden 区,相当于对象的“出生地”。Eden 区满了之后触发 Minor GC,存活下来的对象会被移动到 Survivor 区。Survivor 区又分为 S0 和 S1 两块,作用和“备份区”类似,对象在 S0 和 S1 之间反复交换,经过多次 GC 仍然存活的对象,才会被提升到老年代。jstat输出里的 E、S0、S1、O、M 这几个字母,对应的就是 Eden 区、Survivor 0 区、Survivor 1 区、老年代和元空间。
老年代存放生命周期较长的对象,比如 Spring 容器里的单例 Bean、缓存中的常驻数据。元空间则在堆外管理类元数据,默认情况下会根据加载的类数量自动扩容。理解这个内存布局之后,再看jstat输出就不会觉得字母是一团乱码,而是能直接在脑子里映射出一张 JVM 运行时数据区的图。
1.2 GC回收机制与jstat指标怎么对应
JVM 的垃圾回收采用分代收集理论,因为不同代的对象存活特征差异很大:新生代对象绝大多数“朝生夕灭”,存活率低,适合用复制算法;老年代对象存活率高,回收时要用标记整理或者标记清除算法。于是 GC 被分为两类:发生在新生代的 Minor GC,和发生在老年代的 Major GC/Full GC。jstat输出里的YGC字段统计的是 Minor GC 次数,FGC字段统计的是老年代 GC 次数,YGCT和FGCT则分别对应它们的累计耗时。
这里必须提醒一个容易误判的地方:jstat的FGC数值,在不同垃圾收集器下含义不完全一样。比如 CMS 收集器在并发标记和清理阶段会产生一些主 GC 计数,这些计数并非每一次都对应传统的 STW(Stop-The-World)全停顿 Full GC。所以我见过很多人看到FGC增长很快,就紧张得不行,结果去看 GC 日志才发现是正常的 CMS 周期行为。正确做法是把jstat和 GC 日志结合起来看,不要在没确认收集器类型时单凭FGC数字下结论。
1.3 不同垃圾收集器下,jstat表现为什么不一样
HotSpot 主流的垃圾收集器有 Serial、Parallel、CMS、G1、ZGC 等,它们对堆内存的管理方式不同,jstat输出也会呈现不一样的特征。最典型的是 G1。G1 把整个堆划分成大量大小相同的 Region,新生代和老年代不再是物理上连续的区域,而是由若干 Region 动态组成。因此用 G1 时,你可能会看到jstat输出里S0C、S1C经常为 0,或者 E 区容量在不断变化,这都是正常现象,不代表内存异常。
ZGC 和 Shenandoah 这类以低停顿为目标的收集器也一样,它们的堆区域和回收模型更特殊,jstat的一些字段参考意义会下降,更多时候要配合 GC 日志来分析。所以拿到jstat数据,第一件事不是看数值大小,而是先确认当前 JVM 配置的是哪种垃圾收集器。可以通过jinfo -flag UseG1GC这类命令确认,或者直接看启动脚本里的-XX:+UseG1GC参数。知道收集器类型,才知道哪些字段可信、哪些字段需要换一种解释方式。
2. jstat命令语法与常用参数逐个拆解
2.1 命令格式:interval和count不只是数字
jstat的基本命令格式可以写成:
jstat [options] <pid> [interval] [count]pid是 Java 进程的进程ID,interval是采样间隔(毫秒),count是采样次数。举几个实际用法:
# 看一次 jstat -gcutil 12345 # 每1秒采样一次,共采10次 jstat -gcutil 12345 1000 10 # 不加count参数则持续采样,Ctrl+C中断 jstat -gc 12345 1000很多入门资料会忽略interval和count的作用,我反而觉得它们是使用jstat最容易出效果的地方。比如上线一个 JVM 参数改动之后,我想确认 GC 压力有没有下降,就可以用jstat -gcutil <pid> 1000 10连续观察 10 秒,而不是只看一次输出。单次jstat输出反映的是 JVM 自启动以来的累计状态,并不能体现“当前正在发生什么”,连续采样能看到 Eden 区使用率从低到高再回落的过程,这才是实时监控的意义。
2.2 类与编译监控:-class、-compiler、-printcompilation
先看-class参数,它统计类加载和卸载的情况。执行:
jstat -class 12345 Loaded Bytes Unloaded Bytes Time 5632 6133.1 0 0.0 3.12四列依次是:已加载类数量、加载的类占用的字节数(KB)、卸载类数量、卸载的字节数、类加载与卸载累计耗时(秒)。这个参数排查类加载器泄漏非常有用。比如应用使用 CGLIB、动态代理、反射频繁生成新类,如果Loaded数值持续上涨且Unloaded始终为 0,基本可以断定有类加载器无法释放的迹象。如果配合jmap观察非堆内存中的元空间使用率,往往能第一时间发现问题。
-compiler参数查看 JIT 编译统计:
jstat -compiler 12345 Compiled Failed Invalid Time FailedType FailedMethod 2099 1 0 10.53 1 java/lang/StringBuilder appendCompiled表示 JIT 编译的方法数量,Failed表示编译失败数量,Invalid是编译后又被判定无效的数量,Time是编译总耗时。FailedType和FailedMethod显示了最近一次编译失败的编译类型与方法名。如果Failed持续增加,常见原因包括方法过于复杂、CodeCache 容量不足、编译线程受限等。还有一个-printcompilation参数,用来输出最近一次编译的方法信息,可以配合-compiler使用:先用-compiler看整体编译压力,再用-printcompilation看最近编译的是哪些方法,定位是不是某个热点方法在反复触发编译。
2.3 GC视图:-gc、-gcutil怎么读才准
-gc是查看堆内存使用与 GC 行为的核心参数。执行jstat -gc 12345,输出是:
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 1024.0 1024.0 512.3 0.0 8192.0 6453.6 20480.0 11082.5 10240.0 9712.3 2048.0 1923.1 12 0.085 2 0.032 0.117我把整行字段拆成三类说明。容量与使用量类:S0C、S1C分别表示两个 Survivor 区的当前容量(KB),S0U、S1U是对应区域的使用量;EC和EU是 Eden 区容量与使用量;OC和OU是老年代容量与使用量;MC、MU是元空间容量与使用量;CCSC、CCSU是压缩类空间容量与使用量。GC 统计类:YGC、YGCT是新生代 GC 次数与累计耗时(秒);FGC、FGCT是老年代 GC 次数与累计耗时;GCT是所有 GC 的总耗时。这些字段单位比较重要,容量是 KB,耗时是秒,别把单位看反了。
-gcutil则是用百分比形式展示使用率,执行jstat -gcutil 12345输出:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 50.00 0.00 78.76 54.11 94.87 93.91 12 0.085 2 0.032 0.117这里的 S0、S1、E、O、M、CCS 分别对应 Survivor 0、Survivor 1、Eden、老年代、元空间、压缩类空间的使用率百分比。我习惯先用-gcutil扫一遍,哪个区域超过 90% 再切到-gc看详细容量,这样定位快很多。Eden 区使用率经常飙升是正常的,说明有新对象在快速分配;但老年代 O 长期在 90% 以上就要警惕了,它往往预示着 Full GC 即将频繁发生。
2.4 细分视图:-gccapacity、-gcnew、-gcold各自负责什么
-gccapacity参数输出的是各区域容量的上下限和当前容量,能帮你判断堆是否在合理范围内自动伸缩。执行效果类似:
NGCMN NGCMX NGC S0CMX S0C S1CMX S1C ECMX EC OGCMN OGCMX OGC OC MCMN MCMX MC CCSMN CCSMX CCSC YGC FGC 5632.0 83200.0 8192.0 27648.0 1024.0 27648.0 1024.0 83200.0 8192.0 16384.0 174592.0 20480.0 20480.0 0.0 10240.0 10240.0 0.0 1024.0 2048.0 12 2重点看每个区域的 MN、MX 和当前容量:NGCMN/NGCMX是新生代最小和最大容量,NGC是当前容量;OGCMN/OGCMX是老年代最小和最大容量,OGC、OC是老年代当前容量。如果当前容量经常接近最大值,说明 JVM 默认的堆扩容机制已经“吃紧”,可以考虑手动把-Xmx和-Xms设为一致,避免运行期频繁扩容带来的性能抖动。
-gcnew和-gcold则分别拆开看新生代和老年代。比如想确认对象晋升速率是否过高,可以用-gcnew观察 S0、S1 的使用量变化;想知道老年代压力,用-gcold看OU是否持续增长。这些细分视图适合在明确知道问题区域之后做针对性观察,日常巡检我用-gcutil就够了,不需要每次都把十几个字段全看一遍。
2.5 采样间隔与输出格式:避免拿到“假数据”
jstat的采样间隔很有讲究。间隔设得太短(比如 50ms),会频繁读取目标 JVM 的 perf 数据文件,反而干扰 JVM 运行,数据也可能失真;设得太长又会错过瞬时波动,看不出 Eden 区快速被填满的过程。根据我的经验,日常巡检用 1 秒或 5 秒间隔采样 5 到 10 次就足够;定位具体问题时,先用 1 秒间隔观察几轮找到规律,再放大时间窗口确认趋势。
jstat还支持-t参数,可以在输出前加一列时间戳(单位是秒,相对 JVM 启动时间的秒数)。例如jstat -gcutil -t 12345 1000 5,输出第一列就是采样时刻对应的运行时间。把jstat数据和业务访问高峰对照起来,排查“为什么每天早上 9 点 Full GC 变多”这类问题会直观很多。在脚本里给jstat定期输出加时间戳,落到日志文件里,甚至可以直接作为轻量化的历史监控数据使用。
3. 实战案例:jstat定位JVM性能问题的三个完整过程
3.1 案例一:YGC次数激增,接口响应变慢
有一次线上订单服务的接口 RT 突然从 50ms 涨到 500ms,用户反馈下单经常转圈。我先用top确认 CPU 不高、内存没有被打满,初步判断问题不在操作系统层面,而是 JVM 内部 GC 行为异常。接着用jstat -gcutil观察进程:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 92.31 35.70 93.10 92.50 35124 112.34 5 3.21 115.55YGC 已经超过 3.5 万次,YGCT 累计 112 秒,平均每次 Minor GC 耗时 3.2 毫秒,次数太多才是主要问题。我再用 1 秒间隔连续采样,发现 Eden 区使用率每次都是从 10% 左右快速冲到 90% 以上,然后回落到低位,说明对象分配速率非常高,每次 Minor GC 刚回收完,Eden 区很快又被新一轮对象占满。这个模式基本可以判断为“瞬时大对象分配过密”。
接着用jmap -histo:live导出存活对象统计,定位到一个内部缓存 Map 在持续膨胀:每笔订单的查询结果被封装成一个大集合后放进 Map 里,但 Map 没有清理逻辑,越积越多。改造方案是把普通 HashMap 换成带容量上限的 LRU 缓存,并定期清理过期订单数据。上线后 YGC 频率从每小时两三千次降到两三百次,接口 RT 也恢复到正常水平。这个案例给我的体会是:jstat先帮我把问题圈定在 GC 频率异常,再用jmap去抓根因,效率比瞎猜高很多。
3.2 案例二:老年代打满,Full GC频繁停顿
另一个典型案例是数据批处理应用,每次跑批时接口停顿明显,某些任务甚至出现几十秒的“假死”。用jstat -gcutil观察:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 100.00 5.80 97.50 90.20 88.90 850 2.13 142 108.34 110.47这张图就很典型了:老年代 O 使用率 97.5%,FGC 发生了 142 次,FGCT 累计 108 秒。对象升到老年代之后基本回收不动,老年代持续逼近 100%,Full GC 频繁触发,每次 Full GC 又因为老年代太大、存活对象太多而耗时极长。批处理应用最容易出现这种“堆越用越满,GC越收越慢”的恶性循环。
当时第一步用jmap把堆 dump 下来,用 MAT 分析后确认了大量重复的历史数据对象被某个全局集合持有,无法释放。第二步调整启动参数,把-Xmx从 2GB 升到 4GB,同时改造批处理逻辑,不再把所有历史数据一次性加载到内存,改成按批次读取、处理完及时清空引用。调整后 FGC 次数从 142 次降到个位数,FGCT 从 108 秒降到不到 2 秒,批处理总耗时反而缩短了将近三分之一。这个过程里,jstat的价值在于用最快的速度量化了“老年代有多满、Full GC 有多频繁”,让优化前后的效果对比有了明确的数据支撑。
3.3 案例三:元空间持续增长,类加载器泄漏排查
还有一个比较隐蔽的场景,发生在一个做了大量动态代理的服务上。服务本身堆内存使用正常,YGC 次数不高,但运维反映 JVM 莫名其妙地频繁触发 Full GC。用jstat -gcutil观察后发现 O 区正常,M 区(元空间)使用率却从一个很低的水平不断攀升:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 56.80 10.50 35.20 97.10 95.30 180 0.32 68 46.70 47.02M 区 97.1% 意味着元空间接近满,而元空间扩容达到上限后就会触发 Full GC 尝试回收无效类。动态代理每次运行时通过反射生成新的代理类,如果缓存了 Class 对象但对应的 ClassLoader 始终无法卸载,元空间就会持续增长。jstat的-class参数也验证了这个判断:Loaded类数量不断增加,Unloaded始终为 0。
排查方法是把 JVM 启动参数加上-XX:MaxMetaspaceSize限制上限,然后对动态代理创建逻辑做了复用优化:把生成过的 Class 缓存起来,不再重复创建;同时检查了框架中是否存在 ThreadLocal 持有 ClassLoader 的问题。优化后 M 区使用率降到稳定水平,Full GC 次数大幅下降。这个案例想说明的是,jstat不仅能监控传统堆区域,元空间(M 字段)和压缩类空间(CCS 字段)的监控,对新框架、动态代理、热部署场景同样关键。
3.4 从jstat数据到优化动作的四步判断法
看过三个案例之后,我给你总结一个拿到jstat输出后的判断顺序,照着做基本能把数据翻译成动作。第一步看YGC和YGCT,计算单次 Minor GC 平均耗时,如果单次超过 100 毫秒甚至几百毫秒,说明新生代中存活对象太多或者 GC 算法配置不合理。第二步看FGC、FGCT和 O 区使用率,如果老年代长期在 90% 以上且 FGC 频繁,优先考虑加大堆、检查大对象缓存、调整对象晋升阈值。第三步看 Eden 区使用率的波动模式,如果采样时 Eden 区总是快速从低位冲到高位,对象分配速率异常,需要排查代码中是否不断创建大集合、临时对象。第四步看 M 区和 CCS 区,如果持续增长且没有回落趋势,排查元空间泄漏、动态代理类创建、重复反射等问题。
这四步走完,你基本可以判断出是哪一类 GC 出了问题、是哪个内存区域压力大、问题大概在代码哪一层,接下来再用jmap、jstack等工具去抓具体对象和线程栈,定位就会快很多。我自己排查问题时,其实很少直接盯着一堆原始jstat数字发呆,思考路径从头到尾都是按这个顺序走的。
4. 常见问题与排查技巧实录
4.1 jstat连不上进程:权限、PerfData和容器环境的坑
jstat最常见的报错是Unable to open socket file和Permission denied。前者通常是因为权限不够,比如你用普通用户去监控 root 启动的 Java 进程。jstat读取的是 JVM 在/tmp/hsperfdata_<user>/<pid>目录下生成的 perf 数据文件,如果这个目录或者文件不可读,就必然连不上。解决办法是用目标进程相同权限的用户执行jstat,或者用 sudo 提升权限。
Windows 环境还有一个坑:如果 JVM 启动参数里显式带了-XX:-UsePerfData,jstat会直接报找不到资源。正常情况下 PerfData 是默认开启的,但某些安全加固脚本或启动模板会把它关掉,遇到这种情况需要检查启动参数。容器环境下还可能遇到目标进程运行在别的 PID namespace 里的问题,JVM 生成的 perf 目录不在当前容器可见的路径下,这时最好在容器内直接执行命令,或者改用 JMX 暴露指标的方式来监控。
4.2 “FGC=135次”到底是不是Full GC:收集器差异
之前提到过,jstat的FGC字段在不同垃圾收集器下的含义并不完全一致。CMS 收集器在并发标记阶段会生成 CMS cycle 计数,这些计数会被累加到FGC里,但并不都对应 STW 的完全停顿。Parallel 和 G1 收集器下FGC含义相对接近传统 Full GC,但 G1 的FGC也可能是并发周期的一部分。因此看到一个很大的FGC数值时,别急着下结论,最好到 GC 日志里确认对应时间段的 GC 事件类型。
如果项目里同时配置了-XX:+PrintGCDetails(JDK 9 之后用-Xlog:gc),打开日志对比一下就一目了然。没有 GC 日志的情况下,可以通过观察FGC增长的实际间隔、FGCT与真实停顿是否匹配来辅助判断。这个习惯真的很重要,我在售后群里见过不止一次有人拿着jstat的FGC数字来问“是不是要挂了”,结果其实只是 CMS 的正常并发周期。
4.3 容器内存限制:jstat显示的容量可能“骗人”
容器环境使用jstat时要额外注意。如果 JVM 没有正确识别容器的 CGroup 内存限制,它可能以为宿主机的内存可以随便用,jstat显示的堆容量上限也会远大于容器实际能分配到的配额。于是经常出现“jstat 看起来一切正常,老年代使用率不高,但容器被 OOM Killer 杀掉”的诡异现象。
解决关键是在启动参数里显式指定堆大小,比如-Xmx、-Xms,并确认使用的 JDK 版本默认开启了容器感知能力(JDK 8u191+ 和 JDK 10+ 默认开启UseContainerSupport)。拿到一台机器后,我还会手动看一下/sys/fs/cgroup/memory/memory.limit_in_bytes,和 JVM 的堆上限做一次对账,确认堆在容器配额之内。jstat输出的容量字段是 JVM 视角的容量,不等于容器真实可用内存,这一点必须养成习惯。
4.4 学会和jstack、jmap、jcmd配合使用
jstat虽然强大,但它只覆盖了 JVM 运行统计的一个侧面,持续高 CPU、死锁、内存泄漏等问题往往需要组合工具一起看。我常用的组合是:jstat先看 GC 和内存趋势;jstack看线程栈定位死锁、阻塞;jmap做堆 dump、查看对象分布,或者用jmap -histo快速看 Top 对象;jinfo查看启动参数是否生效;jcmd可以替代一部分jmap和jstack的功能,在 JDK 8+ 上更推荐。
如果是想做长期监控和告警,我建议再往前走一步:通过 JMX 把 JVM 指标暴露给 Prometheus,用 Grafana 做可视化看板,把 YGC 次数、FGC 次数、老年代使用率、元空间使用率等做成面板。jstat适合事故现场排查和参数调整后的快速验证,JMX 指标适合长期趋势分析和告警。两者不是替代关系,而是递进关系。比如调整 GC 参数后,先用jstat观察几次采样确认好转,再在监控看板上设定 YGC 频率、FGC 耗时等告警阈值,问题发生时第一时间被通知,而不是等到用户报障。
4.5 几个容易忽略的jstat使用细节
最后聊几个实操细节。第一,jstat输出的是 JVM 自启动以来的累计值,而不是采样周期内的增量。比如连续两次执行jstat,看到 YGC 从 100 变成 110,这 10 次增量才是这个时间窗口内的 GC 次数,不要直接拿累计值当实时速率。第二,在并行 GC 收集器下,YGCT 和 FGCT 是所有 GC 线程耗时的总和,可能大于实际业务感知到的停顿时间,看到数值很大先别慌。第三,jstat命令本身会占用一点 CPU 和文件 IO,如果写脚本无限循环高频执行,它会对目标 JVM 产生轻微干扰,生产环境不建议用死循环方式监控。
我在实际中还有一个习惯:把常用的jstat命令封装成 shell 脚本。比如写一个jstat_gc.sh,传入 PID 后自动输出-gcutil和-gc两个视图,并按 10 次采样输出到带时间戳的日志文件。这样每次排查或者巡检,不用重新敲一长串命令,数据也能归档下来。后面再想复盘“上次调优之后 GC 趋势怎么变化”,翻日志比回忆要靠谱得多。