← 返回博客
2026-09-02 15:41:48

GC 日志没开、又不能重启:产线 JVM 在线排查三板斧

GC 日志没开、又不能重启:产线 JVM 在线排查三板斧

前一篇《GC 调参入门》的验证方法是「改一个参数、重启、跑同一份流量、对比日志」。这套流程有两个前提:启动命令带了 GC 日志,而且允许重启。真实产线经常两条都不满足——进程是历史启动脚本拉起来的,-Xloggc/-XX:+PrintGCDetails 一个没带;同时它是核心服务,不能随便重启,因为重启会清掉现场:堆状态归零、累积的 FGC 计数归零,正在发生的问题可能重启完就不再出现,还平白给线上添一次启动抖动。

这篇讲不重启、只靠 HotSpot 自带工具,把「有没有 GC 问题、问题在哪」在线查清楚。全文按 JDK 8 / JDK 9+ 双版本标注,命令在 Linux / Windows 通用,个别差异会点出来。和前一篇的关系:那篇是主动调参,这篇是被动救火,但判断思路是一条线——先看现象,再决定该看 GC 还是看堆,别上来就乱敲工具。

先定症状,再选工具

救火最忌一上来 jmap/jstat 挨个试。先花十秒把症状归个类,决定第一眼该看什么:

症状大概率的问题第一眼该看什么
整机 CPU 打满GC 线程忙,或业务死循环先分是哪种(见「分症状走查」场景 B)
周期性几秒卡顿、FGC 在涨老年代被占满jstat 连续采样,确认 FGC/FGCT 涨不涨
偶发高 RT、但没到 Full GC单次 young 停顿大 / 分配压力动态补开 GC 日志,看单次停顿
老年代一路涨、逼近上限晋升失控或泄漏jcmd GC.heap_info + jmap -histo 找谁占的
快 OOM 了现场稍纵即逝jmap -dump 抢现场(会卡一下也值)

下面按侵入性「由轻到重」把三斧工具过一遍,再回来看怎么组合。

三斧工具:jstat、jcmd/jmap、VM.log

先用 jps -l 找到目标进程的 pid(Linux 也可以 ps -ef | grep java,Windows 用 jps -l 就够)。注意:attach 要求与进程同 OS 用户;进程在容器里就在容器内执行同款命令。下文统一用 代指进程号。

第一斧:jstat 连续采样,判断有没有 Full GC 风暴

jstat -gcutil 1000 每秒打一行(不指定间隔就打单行快照)。JDK 8 和 JDK 9+ 都有的列:S0/S1 Survivor 列(JDK 8 的 Parallel/CMS 有两块固定半区、都显示数字;G1 只有一边有值、另一边是一杠,见下面怎么读)、E Eden、O 老年代、M 元空间、CCS 压缩类空间(Compressed Class Space,元空间里专门放类元数据的那一块,默认跟着 -XX:+UseCompressedClassPointers 开启,上限默认 1G)、YGC Young GC 累计次数、YGCT 累计耗时、FGC Full GC 次数、FGCT Full GC 累计耗时。JDK 9+ 额外多出 CGC/CGCT(并发周期计数)。

JDK 26 实测的一段真实输出:

  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT     CGC    CGCT       GCT
     -  95.26  83.87   0.35  41.15   6.57   1359     1.058     0     0.000     0     0.000     1.058
     -  60.74   0.00   0.35  41.15   6.57   1490     1.159     0     0.000     0     0.000     1.159
     -  69.98  22.58   0.35  41.15   6.57   1617     1.260     0     0.000     0     0.000     1.260

怎么读:先看最左边 S0-——这是一杠,不是 0.00。G1 没有传统收集器(Parallel/CMS)那种「两块固定大小、轮流承接存活对象的 S0/S1 半区」,它把存活对象拷进一组动态分配、会变大小的 Survivor Region,JVM 的监控只把这份 Survivor 记在 S1 名下——jstat -gccapacity 一眼能看出来:S0 容量是 0,S1 有值。S0 容量为 0,占用率无从算起,jstat 就按「不适用」打 -;0.00 是「这块空间存在、只是空的」,那只会在 JDK 8 的 Parallel/CMS 上见到。所以 G1 下想盯 Survivor 看 S1:这三行 95.26 → 60.74 → 69.98,是每轮 Young GC 后 Survivor 被重新填充的波动。再看 E Eden:83.87 → 0.00 → 22.58,中间那个 0.00 正赶上一轮 Young GC 刚把 Eden 清空、下一秒又重新往上堆——「Eden 满就 Young GC」在采样里就长这样。M 41.15、CCS 6.57(元空间里的压缩类空间)都不高且没在涨,元空间没压力。最后看次数:三行各隔 1 秒,FGC 一直是 0、FGCT 不涨,说明没有 Full GC;YGC 从 1359 → 1490 → 1617,约每秒 130 次 Young GC,可每次平摊只有 0.8ms 上下(用相邻两行的 YGCT 增量 ÷ YGC 增量算)——这是前一篇定义的「健康形态:Young GC 频繁但停顿小」,问题不在 GC 本身,在分配太凶。反过来,如果看到 FGC 从 0 一路涨、FGCT 同步涨,就是 Full GC 在打——老年代撑不住了,往下走第二斧。

第二斧:jcmd / jmap 看堆,回答「谁占着老年代」

定位到 Full GC 后,要看老年代被谁塞满,两条路:

garbage-first heap   total reserved 262144K, committed 262144K, used 92518K
 region size 1M, 86 eden (86M), 4 survivor (4M), 3 old (3M), 0 humongous (0M), 163 free (163M)

eden/survivor/old 三块一眼分清;humongous 非 0 就是有大对象在抢 Region——GC 系列前一篇专门讲过这个坑。这条命令 JDK 8 也认,只是 Parallel 没有 Region/humongous 这套说法,输出变成按代列的行、不带百分比,读代大小不如老牌 jmap -heap 省事。JDK 8(1.8.0_401,默认 Parallel)实测——为演「老年代被占」,实验特意养了一批长命对象、让它们晋升进老年代:

# JDK 8:jmap -heap <pid>
Parallel GC with 8 thread(s)

Heap Configuration:
   MaxHeapSize              = 268435456 (256.0MB)
   NewSize                  = 89128960 (85.0MB)
   MaxNewSize               = 89128960 (85.0MB)
   OldSize                  = 179306496 (171.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 76546048 (73.0MB)
   used     = 6124432 (5.8407135009765625MB)
   8.000977398598032% used
From Space:
   capacity = 6291456 (6.0MB)
   used     = 2359408 (2.2501068115234375MB)
   37.50178019205729% used
To Space:
   capacity = 6291456 (6.0MB)
   used     = 0 (0.0MB)
   0.0% used
PS Old Generation
   capacity = 179306496 (171.0MB)
   used     = 67654016 (64.5198974609375MB)
   37.73093418768275% used

怎么读:Heap Configuration 段先把前一篇那张参数表的默认值原样打出来了——MaxHeapSize 256MB 拆成 NewSize 85MB + OldSize 171MB,正好 1:2,对应 NewRatio 2(老年代:年轻代 = 2:1);SurvivorRatio 8 也和表里一致。Heap Usage 下面:PS Young GenerationEden/From/To 是年轻代三块,From 37.5%、To 0.0% 是 JDK 8 真实的两块 Survivor 半区——每轮 Young GC 后 from/to 角色互换,这次 To 刚被清空所以是 0.0%,正好呼应第一斧那句:Parallel 的 S0/S1jstat 里都显示真实小数、空闲就是 0.00,- 那一杠是 G1 独有。重点在最后的 PS Old Generationused = 64.5MB 是老年代真实被占的量(就是那批晋升进来的长命对象)。三段连起来就是完整判据:old used 一路涨、逼近容量 → 离 Full GC 不远 → 才轮到下一斧 jmap -histo 回答「被谁占的」。

第三斧:动态补开 GC 日志

前两斧能回答「现在有没有 Full GC、谁占着堆」,但回答不了「单次停顿多大、young 到底多频繁」——这需要 GC 日志,而日志恰恰没开。JDK 9+ 可以在运行中补开

jcmd <pid> VM.log output=gc.log what=gc*   # 开
jcmd <pid> VM.log rotate                   # 日志轮转
jcmd <pid> VM.log disable                  # 关

output 的相对路径以目标进程的工作目录为准,给绝对路径最省心。开完等一会儿,gc.log 里就是统一格式的停顿行了,和前一篇实验日志同款:数 Pause Young 能算单次停顿和总停顿,Pause Full 出现就抓个正着。版本注意:VM.log 是 JDK 9+ 统一日志框架的子命令,个别早期小版本不一定全——拿不准先 jcmd help 看有没有这一项,别硬记版本号。

JDK 8 是硬伤-XX:+PrintGCDetails/-Xloggc 属于只能启动时设的参数,运行中开不了(统一日志框架在 8 也不存在),这类参数 jinfo 也改不动。JDK 8 线上想补日志基本只能靠重启,或者借 APM/Arthas 这类旁路观测——那是另一套体系。这也是本文收口那句「启动命令是给未来留的」的由来。

补一句 heap dump 的取舍:jmap -dump:live,format=b,file=heap.hprof 能落一份快照,离线用 MAT 之类找泄漏根。但它会 stop-the-world,堆越大停越久。判据是:现场值不值得为它卡这一次。濒临 OOM 或怀疑泄漏时值得;只是好奇不建议。

分症状走查

场景 A:FGC 在涨 / 周期性秒级卡顿。 jstat 隔十秒打两发,看到 FGC: 0 → 3 → 7FGCT 同步涨,就是 Full GC 在打。接着 jcmd GC.heap_infoold 是否已占满,再用 jmap -histo 找 top 类:常见是缓存无界增长、静态集合只进不出、大对象常驻。处理方向是修代码去释放/加限长,不是调参——这正是前一篇判断表里「Full GC 频繁先查缓存和代码」要拦住的错误动作。改完再用 jstatFGC 还涨不涨来验证。

场景 B:CPU 满但 FGC 不动。 既然没有 Full GC 却在烧 CPU,先分清是 GC 在忙还是业务在忙。Linux:top -Hp 看线程,GC Thread#0/ConcGCThread 之类占大头是回收线程忙,业务线程占大头就是死循环或锁竞争。Windows 没有现成的 top -H,用 jcmd Thread.print 连打几次,找反复出现的 Runnable 堆栈热点。

把「最烧 CPU 的线程」落成代码,Linux 上还差关键一步:top -Hp 给的是十进制线程号,而 jstack 里线程号是十六进制nid,中间要转一次:

top -Hp <pid>             # 记下 CPU 最高的线程号(十进制),比如 24642
printf '%x\n' 24642       # 转十六进制 → 6042
jstack <pid> | grep -A 25 'nid=0x6042'   # 看这根线程的栈卡在哪

printf '%x' 输出小写十六进制,正好对上 jstack 的 nid=0x...。转到 jstack 后怎么判:如果热线程是 GC 线程(名字带 G1/GC),看到的是它停在回收相关栈上——那是 GC 本身在忙,回到第一斧看分配压力;如果热线程是业务线程(线程池 worker、http 线程),看它 Runnable 时停在哪个栈帧,反复出现的就是死循环或锁等待热点,配合 3-1 的 jstack 死锁识别一起用。

若 GC 线程忙而 FGC = 0,多半是 young GC 过密(分配压力大),回到第一斧看 YGC 频率和 YGCT;若业务线程忙,往代码、锁、慢 SQL 方向查,别把账赖在 JVM 上。

场景 C:RT 偶发抖动,但没有 Full GC。 这是最容易被低估的一类。此时第三斧派上用场:补开 GC 日志,数 young 停顿。单次只有 1~2ms,但一秒发生几十次,累计停顿可观,用户感知的 RT 抖动其实是「停顿叠加」——对应前一篇「单次停顿小不是重点,总停顿才是要盯的指标」的结论。方向:年轻代相对流量偏小或分配太凶,可以拿自己的流量按前一篇的实验方法复测,而不是凭空改参数。

场景 D:快 OOM 了。 OOM 是「现场只有一次」的问题。先 jmap -dump 抓一份(哪怕卡一下),再考虑重启。复盘时大概率会发现启动命令没带 -XX:+HeapDumpOnOutOfMemoryError——当初带了,OOM 那一刻自动落盘,根本不用抢。

两条收口原则

面试被问「线上 JVM 出问题怎么排查」,别背工具清单,按这套讲:先 jstat 看有没有 Full GC → 有就 jcmd/jmap 看谁占堆,没有就分是 GC 忙还是业务忙 → 需要单次停顿数据就 JDK 9+ 动态补开日志。和前两篇连起来是一条完整的线:日志怎么看 → 参数怎么调 → 现场怎么救。

本文关键词:JVM、GC、性能优化、jstat、jcmd