Java 面试笔记III · 05 / 14

Lesson 28 · JVM 原理与调优

GC 日志分析实战:读懂每一行 GC 输出

高级·#JVM·#调优·#实战

第 1 站

面试官:你怎么排查线上 GC 问题?

"线上服务响应变慢了,你怎么定位是不是 GC 的问题?"

大多数候选人会说"看 GC 日志"。但面试官追问一句"GC 日志里具体看什么?每一行代表什么意思?"——能答清楚的人不到 20%。

当线上服务出现延迟飙升、CPU 打满、甚至 OOM 之前,GC 日志是最直接的第一手证据。它忠实地记录了每一次垃圾回收的触发原因、暂停时间、内存变化——但前提是你得读得懂。

一组真实数据:某电商大促期间,服务 P99 延迟从 50ms 飙到 2s。运维第一反应是加机器,但打开 GC 日志一看——每隔 40 秒就有一次 1.8 秒的 Full GC。问题不在流量,而在内存泄漏导致的频繁 Full GC。

这篇文章,我们用一个真实的 GC 日志文件,逐行拆解 Young GC、Full GC、Mixed GC 的输出格式,搞清楚每一个字段代表什么。读完之后,你能做到:

  • 看懂 GC 日志的每一列含义,不再"凭感觉猜"
  • 区分 Young GC / Full GC / Mixed GC 的触发条件
  • 用 GC cause 快速定位问题根因
  • 掌握 GCViewer、GCEasy 等工具做可视化分析
面试官视角

能在白板上手写 GC 参数的人很多,但能把一行 GC 日志逐字段解释清楚的人极少。这篇文章给你的就是"逐字段拆解"的能力。

第 2 站

开启 GC 日志:JDK 8 vs JDK 9+

巧妇难为无米之炊。分析 GC 日志的前提是你得先让它输出。遗憾的是,很多线上环境压根没开 GC 日志,等到出事时只能干瞪眼。

生产环境必开

GC 日志的性能开销极低(通常 < 1%),建议所有生产环境默认开启。这不是可选项,而是基本操作规范。

JDK 8 及以前

JDK 8 使用 -XX 系列参数控制 GC 日志输出:

jdk8-gc-flags.sh
# JDK 8 GC 日志标准配置
java -XX:+PrintGCDetails \
     -XX:+PrintGCDateStamps \
     -XX:+PrintGCTimeStamps \
     -XX:+PrintHeapAtGC \
     -XX:+PrintGCApplicationStoppedTime \
     -Xloggc:/var/log/gc/gc-%t.log \
     -XX:+UseGCLogFileRotation \
     -XX:NumberOfGCLogFiles=10 \
     -XX:GCLogFileSize=20M \
     -jar app.jar
参数作用建议
-XX:+PrintGCDetails输出 GC 详细信息(分区变化、耗时)必开
-XX:+PrintGCDateStamps每行日志加日期时间戳必开
-XX:+PrintGCTimeStamps输出相对 JVM 启动的秒数建议开
-Xloggc:fileGC 日志输出到文件必开
-XX:+UseGCLogFileRotation日志轮转,防止撑爆磁盘生产必开

JDK 9+ 统一日志框架

JDK 9 引入了统一日志系统(Unified Logging),用一个 -Xlog 参数搞定所有日志配置:

jdk9-gc-flags.sh
# JDK 9+ 统一日志配置
java -Xlog:gc*,gc+age=trace,safepoint:file=/var/log/gc/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=20M \
     -jar app.jar

语法结构是 -Xlog:标签:输出目标:装饰器:轮转参数。相比 JDK 8 的散装参数,统一日志更灵活也更强——但在面试中,JDK 8 的参数你仍然要记得,因为大量存量系统还在用。

GC 日志参数速记
  • JDK 8:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:file 三件套
  • JDK 9+:-Xlog:gc*:file=file 一行搞定
  • 生产环境一定要开日志轮转,防止磁盘写满
第 3 站

Young GC 日志解读:逐字段拆解

Young GC(Minor GC)是最频繁发生的 GC 类型。我们拿一条真实的 G1 收集器日志来逐字段拆解:

gc-young.log
2024-03-15T14:23:07.392+0800: 128.456: [GC pause (G1 Evacuation Pause) (young), 0.0234567 secs]
   [Eden: 256M(256M)->0B(224M) Survivors: 0B->32M Heap: 512M(1024M)->288M(1024M)]
   [Times: user=0.04 sys=0.01, real=0.02 secs]
GC 日志解剖:Young GC 全字段解析 2024-03-15T14:23:07.392+0800: 128.456: [GC pause (G1 Evacuation Pause) (young), 0.0234 secs] 日期时间戳 绝对时间,定位问题时用 128.456 秒 JVM 启动后经过的时间 G1 Evacuation Pause GC 触发原因(GC Cause) (young) GC 类型 0.0234 secs GC 暂停时间 (STW) [Eden: 256M(256M)->0B(224M) Survivors: 0B->32M Heap: 512M(1024M)->288M(1024M)] Eden 区变化 256M→0B:使用量变化 (256M)→(224M):容量变化 Survivors 区变化 0B→32M:存活对象 复制到了 Survivor 整个堆变化 512M→288M:回收 224M (1024M):堆最大容量 [Times: user=0.04 sys=0.01, real=0.02 secs] ← GC 线程 CPU 耗时 vs 实际挂起时间
图 1 — Young GC 日志逐字段解剖:每条信息的含义一目了然

关键指标解读

1) GC Cause — 触发原因

日志中的 (G1 Evacuation Pause) 表示这是 G1 的正常 Young GC 疏散暂停。如果是 (Allocation Failure),说明 Eden 区不够分配新对象了。

2) Eden 区变化:256M(256M)->0B(224M)

格式是 使用量(容量)。GC 前 Eden 用了 256M(容量 256M,已经满了),GC 后清零到 0B。但注意容量从 256M 变成了 224M——G1 会动态调整 Region 数量。

3) Survivors 变化:0B->32M

GC 前 Survivor 区是空的,GC 后存活对象占了 32M。存活率 = 32M / 256M = 12.5%,说明大部分对象都是朝生夕灭的,这是健康状态。

4) Heap 变化:512M(1024M)->288M(1024M)

整个堆从 512M 降到 288M,回收了 224M。堆最大容量是 1024M,还有余量。

5) Times:user=0.04 sys=0.01, real=0.02

user 是所有 GC 线程的 CPU 时间之和(多线程所以比 real 大),real 是实际挂起用户线程的时间。real 才是影响响应延迟的那个数字。

Young GC 健康指标
  • 暂停时间 real < 50ms:正常;50~200ms:需关注;> 200ms:必须排查
  • Survivor 存活率 10~30%:健康;> 80%:对象晋升老年代压力大
  • 频率 > 10 次/分钟:可能分配速率过高或新生代太小
第 4 站

Full GC 日志解读:最危险的信号

如果说 Young GC 是"例行体检",Full GC 就是"进了急诊室"。一次 Full GC 的暂停时间通常是 Young GC 的 10~100 倍

gc-full.log
2024-03-15T14:25:12.789+0800: 253.853: [Full GC (Ergonomics)
   [PSYoungGen: 65536K->0K(76288K)]
   [ParOldGen: 173568K->139264K(175104K)]
   239104K->139264K(251392K),
   [Metaspace: 67840K->67840K(1107968K)],
   0.8923456 secs]
   [Times: user=3.42 sys=0.05, real=0.89 secs]

逐字段分析:

  • Full GC (Ergonomics):Ergonomics 表示 JVM 自己判断需要 Full GC(通常是 Young GC 后老年代仍然不够)
  • PSYoungGen: 65536K->0K(76288K):新生代 64M 全部清空
  • ParOldGen: 173568K->139264K(175104K):老年代从 169M 回收到了 136M,只回收了 33M——说明大部分对象确实还在用
  • Metaspace: 67840K->67840K:元空间没有任何回收,类都还在加载
  • real=0.89 secs:用户线程被暂停了 890 毫秒!这对在线服务是致命的
  • user=3.42:4 个 GC 线程并行工作,总计 3.42 秒 CPU 时间,但实际耗时 0.89 秒

常见 GC Cause 对照表

GC Cause含义严重程度排查方向
Allocation Failure堆内存不够分配新对象增大堆 or 减少分配
ErgonomicsJVM 自动判断需要 GC看是 Young 还是 Full
System.gc()代码显式调用查谁调了 System.gc()
Promotion Failed对象晋升老年代时空间不足增大老年代或减少晋升
Metadata GC ThresholdMetaspace 达到阈值检查类加载泄漏
Last ditch collection最后手段的 GC极高即将 OOM
G1 Humongous Allocation大对象分配(>Region/2)检查大数组/大字符串
System.gc() 是坑

线上出现 System.gc() 触发的 Full GC,通常是第三方库(如 RMI、NIO DirectByteBuffer)在偷偷调用。可以用 -XX:+DisableExplicitGC 屏蔽,或者用 -XX:+ExplicitGCInvokesConcurrent 改为并发 GC。

Full GC 回收效率 = (GC 前堆使用量 − GC 后堆使用量) / GC 暂停时间

上例:(239M − 136M) / 0.89s = 115 MB/s。如果这个值持续走低,说明老年代存活对象越来越多——大概率是内存泄漏。

第 5 站

G1 特有日志:Mixed GC、并发标记、疏散失败

G1 是目前 JDK 的默认收集器(JDK 9+),它的日志比 Parallel GC 复杂得多——因为它引入了 Mixed GC并发标记周期

Mixed GC — G1 的独门武器

Mixed GC 同时回收新生代和部分老年代 Region,是 G1 避免 Full GC 的关键机制:

gc-mixed.log
2024-03-15T14:30:45.123+0800: 586.187: [GC pause (G1 Evacuation Pause) (mixed), 0.0456789 secs]
   [Eden: 128M(128M)->0B(112M) Survivors: 16M->32M Heap: 896M(2048M)->544M(2048M)]
   [Times: user=0.12 sys=0.01, real=0.05 secs]

关键区别:日志中的 (mixed) 标记。Mixed GC 比纯 Young GC 多回收了一些老年代 Region,所以暂停时间稍长(50ms vs 23ms),但远小于 Full GC。

为什么 G1 不直接做 Full GC,而是搞出一个 Mixed GC?

Full GC 是 STW 全程暂停所有用户线程,堆越大暂停越久。Mixed GC 把老年代回收拆成多次小暂停(每次只回收一部分老年代 Region),用"分期付款"的方式避免一次性长暂停。这就是 G1 的"可预测停顿"设计目标。

并发标记周期(Concurrent Cycle)

Mixed GC 不会凭空发生——G1 需要先做一次并发标记,找出哪些老年代 Region 垃圾多、值得回收:

gc-concurrent.log
[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0045 secs]
[GC concurrent-mark-start]
[GC concurrent-mark-end, 0.1234 secs]
[GC concurrent-cleanup-start]
[GC concurrent-cleanup-end, 0.0012 secs]

并发标记期间用户线程正常运行(不是 STW),所以日志中没有 pause 时间。整个过程分 4 步:

  1. root-region-scan:扫描根 Region(初始标记的后续,STW 很短)
  2. mark:并发遍历对象图,标记存活对象(耗时最长,但不暂停)
  3. cleanup:计算每个 Region 的垃圾比例,排序回收价值
  4. 标记完成后,后续的 Young GC 会变成 Mixed GC

Evacuation Failure — 疏散失败

当 G1 在 Young/Mixed GC 时找不到空闲 Region 来放置存活对象,就会发生疏散失败,日志里会出现刺眼的 to-space exhausted

gc-evacuation-failure.log
2024-03-15T15:02:33.456+0800: [GC pause (G1 Evacuation Pause) (young)
   to-space exhausted, 1.2345678 secs]
   [Eden: 256M(256M)->0B(256M) Survivors: 32M->0B
    Heap: 1920M(2048M)->1890M(2048M)]

暂停时间飙升到 1.2 秒!原因是堆快满了(1920M / 2048M = 93.7%),G1 找不到足够的空闲 Region 来复制存活对象,最终退化成 Full GC。

G1 日志关键词速查
  • (young):纯新生代回收,正常
  • (mixed):新生代 + 部分老年代,正常
  • to-space exhausted:疏散失败,危险信号
  • concurrent-mark:并发标记,正常后台操作
  • G1 Humongous Allocation:大对象分配,可能引发 Full GC
第 6 站

分析工具:让 GC 日志可视化

人肉看 GC 日志适合快速排查,但要做趋势分析、对比优化,你离不开可视化工具。

GCViewer — 本地离线分析利器

开源免费的桌面工具,把 GC 日志拖进去就能看到完整的可视化图表:

  • 堆使用趋势线:如果 GC 后堆使用量持续上升(锯齿底部不断抬高),基本可以确认内存泄漏
  • 暂停时间柱状图:一眼看到哪些时间点出现了长暂停
  • GC 频率统计:Young GC / Full GC 各发生了多少次,总暂停多久

GCEasy — 在线分析平台

上传 GC 日志到 gceasy.io,自动生成分析报告。它会给你一个健康评分,并标注异常指标:

GCEasy 关键输出指标
GC Health Score:     65/100  ← 低于 80 就要关注
Avg GC Pause:        45ms    ← 平均暂停时间
Max GC Pause:        1.89s   ← 最大暂停时间(Full GC)
GC Throughput:       97.2%   ← 应用运行时间占比
Promoted Avg:        28MB    ← 平均每次晋升到老年代的大小
Created/sec:         120MB   ← 对象分配速率
GCEasy 的隐藏功能

上传两份日志(优化前 vs 优化后),GCEasy 会生成对比报告,直接看到调整效果。这在写性能优化报告时非常有用。

jstat — 实时监控 GC 状态

当你没法拿到 GC 日志文件时,jstat 是最后的武器:

jstat 实时监控
# 每 2 秒输出一次 GC 统计,共 100 次
jstat -gc <pid> 2000 100

# 输出示例:
 S0     S1     E      O      M     CCS    YGC   YGCT   FGC   FGCT    GCT
0.0   32.0  128.0  512.0  64.0  5.8   156   3.42    3   2.67   6.09

# 关键字段:
# YGC  = Young GC 次数       YGCT = Young GC 总耗时
# FGC  = Full GC 次数        FGCT = Full GC 总耗时
# GCT  = GC 总耗时(YGC + FGC)
# E    = Eden 使用量(KB)     O    = Old 使用量(KB)

从上面的输出可以算出:

Young GC 平均耗时 = YGCT / YGC = 3.42 / 156 = 21.9ms(健康)

Full GC 平均耗时 = FGCT / FGC = 2.67 / 3 = 890ms(需要优化)

GC 时间占比 = GCT / 应用运行时间 ≈ 6.09 / (uptime) —— 超过 5% 就要警惕

工具类型优势适用场景
GCViewer桌面 / 离线免费、功能全、支持离线开发环境分析
GCEasy在线 SaaS自动评分、对比分析快速诊断、写报告
jstatCLI / 实时无需日志文件、实时线上紧急排查
GCPause Inspector桌面 / 离线轻量、启动快快速预览
工具选型建议
  • 线上出问题时先用 jstat 实时看,拿到第一手数据
  • 拿到日志文件后用 GCViewer 做详细分析
  • 写优化报告时用 GCEasy 的对比功能
第 7 站

总结:GC 问题排查手册与监控体系

到这里,你已经能读懂绝大多数 GC 日志了。最后,我们把常见的 GC 问题和解决方案整理成一张速查表,然后聊聊怎么建立 GC 监控体系。

常见 GC 问题速查表

问题现象日志特征可能原因解决方向
频繁 Young GC YGC 频率 > 10次/分钟 新生代太小、分配速率高 增大 -Xmn;减少临时对象创建
Young GC 暂停长 real > 200ms 存活对象太多、Survivor 太小 调整 Survivor 比率;检查大对象
频繁 Full GC FGC 持续增长 内存泄漏、老年代过小 MAT 分析堆转储;增大 -Xmx
Full GC 后堆不下降 GC 后 old 使用量不降 内存泄漏(对象一直被引用) dump 堆 → MAT 找 GC Root 链
Metaspace OOM Metadata GC Threshold + Last ditch 动态类加载泄漏(CGLib / Groovy) 增大 -XX:MaxMetaspaceSize;排查类加载
G1 to-space exhausted 疏散失败、暂停飙升 堆使用率 > 85%、IHOP 设置不合理 增大 -Xmx;降低 -XX:InitiatingHeapOccupancyPercent
G1 Humongous Allocation 频繁出现大对象分配 大数组、大字符串 增大 Region 大小 -XX:G1HeapRegionSize;优化分配
System.gc() 触发 GC Cause = System.gc 第三方库显式调用 -XX:+DisableExplicitGC

生产环境 GC 监控体系

生产环境 GC 监控三层体系 采集层 -Xlog:gc* 输出日志 JMX 暴露 GC MXBean Prometheus jmx_exporter 存储层 日志文件 → Filebeat 指标 → Prometheus TSDB 保留 30 天历史数据 告警层 Grafana Dashboard 可视化 AlertManager 阈值告警 钉钉/飞书 Webhook 通知 推荐告警规则 jvm_gc_pause_seconds{quantile="0.99"} > 0.5 → P1 告警(长暂停) rate(jvm_gc_full_count[5m]) > 0.5 → P2 告警(Full GC 频繁) jvm_heap_used / jvm_heap_max > 0.85 → P2 告警(堆使用率过高)
图 2 — 生产环境 GC 监控三层体系:采集 → 存储 → 告警

关键监控指标

指标正常范围告警阈值PromQL 示例
Young GC 平均暂停 < 50ms > 100ms rate(gc_young_time[5m]) / rate(gc_young_count[5m])
Full GC 频率 0 次/小时 > 1 次/10分钟 rate(gc_full_count[10m]) * 600
堆使用率 < 70% > 85% jvm_heap_used / jvm_heap_max
GC 时间占比 < 3% > 5% rate(gc_total_time[5m])

全文核心要点回顾

  • GC 日志是排查 GC 问题的第一手证据——生产环境必须开启,性能开销可忽略
  • Young GC 日志看 pause 时间、Eden/Survivor/Heap 变化、存活率
  • Full GC 日志重点看 GC Cause——Ergonomics、System.gc()、Promotion Failed 各有不同排查方向
  • G1 特有的 Mixed GC 和并发标记是避免 Full GC 的关键机制;to-space exhausted 是危险信号
  • 工具组合:jstat(实时)+ GCViewer(离线)+ GCEasy(对比报告)
  • 监控体系:采集 → 存储 → 告警,核心关注暂停时间、Full GC 频率、堆使用率
面试满分回答模板

"线上 GC 问题我分三步:第一步用 jstat 看实时状态,确认是 Young GC 频繁还是 Full GC 频繁;第二步拿 GC 日志用 GCViewer 分析趋势,看暂停时间和堆使用变化;第三步根据 GC Cause 定位根因——如果是 Promotion Failed 就看大对象,如果是 Ergonomics 就看内存泄漏。同时我们会配 Grafana 监控 GC 暂停时间和 Full GC 频率,做到问题早发现。"