GC日志如何看:从FullGC频繁到问题定位

对应简历段落

简历里关于 JVM 调优的描述,经常会写成:

负责核心保险业务系统 JVM 性能调优,结合 GC 日志、监控指标和业务链路定位 Full GC 频繁、接口抖动、批量任务高峰内存飙升等问题,优化堆参数、导出链路和批处理并发策略,降低停顿时间并提升系统稳定性。

这句话在面试里一定会被追问。面试官不会满足于“我看过 GC 日志”,而会继续问:你怎么看?看哪些字段?Full GC 频繁一定是内存泄漏吗?G1 的 Young GC、Mixed GC、Full GC 分别代表什么?你怎么从日志推断是大对象、晋升失败、老年代泄漏还是批量任务峰值?参数是怎么调的,调完怎么验证?

这篇文章的目标,是把 GC 日志从“看起来很吓人的一串文本”拆成可复盘、可讲述、可落地的诊断链路。面向资深 Java 面试,不只背概念,而是能把一次线上 Full GC 频繁问题讲成完整项目经验。

业务背景

保险业务系统的 JVM 压力往往不是来自持续高 QPS,而是来自低频但重型的业务操作。比如承保系统在月末集中处理批量核保,理赔系统批量生成赔付清单,运营后台导出几十万行保单数据,财务系统凌晨跑对账和佣金计算。这些任务会在短时间内创建大量 DTO、List、Map、字符串、Excel 单元格对象和 JSON 中间对象。

线上表现通常是这样的:接口响应时间突然从几百毫秒升到数秒;网关开始出现 502、504;业务日志中大量请求超时;机器 CPU 看起来很高,但业务线程并没有明显计算热点;监控里 Full GC 次数突然变多,单次停顿从几百毫秒到十几秒不等。此时如果只看数据库慢 SQL,可能会误判为数据库问题;如果只把堆调大,可能只是推迟下一次故障。

一个典型案例是运营人员在工作日上午发起“保单全量导出”。接口一次性查出 60 万条保单,每条保单又关联客户、险种、渠道、缴费计划等信息。应用堆设置为 -Xms4g -Xmx4g,使用 G1。导出开始后年轻代快速被填满,大量对象还没来得及死亡就晋升到老年代,老年代空间被导出对象、缓存对象和日志字符串挤占。几分钟内出现连续 Full GC,每次 Full GC 后老年代只回落一点点,接口进入长时间停顿。

GC 日志在这个场景里的作用,是把“系统卡了”转化为可验证的问题:什么时候开始 GC 频繁,什么类型的 GC,停顿多久,GC 前后堆变化如何,老年代是否回落,是否有 Humongous 对象,是否出现 To-space exhausted 或 evacuation failure。

核心原理

看 GC 日志之前,要先理解几个核心问题。

第一,GC 日志关注的不是某一次 GC,而是趋势。一次 Young GC 停顿 80ms 不一定有问题,连续每秒多次 Young GC 并伴随老年代持续上涨,就说明分配速率、对象存活率或晋升压力异常。一次 Full GC 也不一定代表故障,发布后类加载、手工触发、低峰整理都有可能;但短时间连续 Full GC,且停顿影响接口,就是线上风险。

第二,Full GC 频繁不等于内存泄漏。泄漏的典型特征是 Full GC 后老年代低水位持续抬升,最终越来越接近堆上限。批量任务峰值的特征是老年代在任务期间快速上涨,任务结束后能明显回落。参数不合理的特征可能是年轻代太小、晋升过快、Mixed GC 跟不上。大对象问题则可能在 G1 日志里看到 Humongous Allocation。

第三,不同垃圾收集器日志重点不同。CMS 要关注 initial mark、concurrent mark、remark、concurrent mode failure、promotion failed。G1 要关注 Pause Young、Concurrent Mark Cycle、Pause Mixed、Humongous regions、to-space exhausted、Pause Full。JDK 8 和 JDK 11 之后日志格式也不同:JDK 8 常用 -XX:+PrintGCDetails -XX:+PrintGCDateStamps,JDK 9+ 使用统一日志 -Xlog:gc*

第四,停顿时间要和业务 SLA 结合。后台批处理服务一次 GC 停顿 1 秒可能可接受,交易链路核心承保接口停顿 1 秒就可能触发网关超时、客户端重试和线程堆积。看 GC 日志不能脱离接口 RT、错误率、线程池队列、数据库连接池、容器重启等指标。

常见的 GC 日志字段可以按四类理解:时间、原因、内存变化、耗时。时间用于和业务日志对齐;原因说明为什么触发 GC;内存变化说明回收效果;耗时说明对业务的影响。面试中不用逐字符解释日志,但要能说清楚自己从哪些维度判断问题。

项目落地

在项目里落地 GC 日志分析,第一步是统一开启可用的日志参数。JDK 8 常见配置如下:

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintGCApplicationConcurrentTime
-Xloggc:/data/logs/app/gc-%t.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M

JDK 11 之后可以使用:

-Xlog:gc*,safepoint:file=/data/logs/app/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100M

第二步是把 GC 日志纳入故障复盘流程。每次出现接口抖动或实例重启,不只是看应用日志,还要把同一时间段的 GC 日志、监控截图、业务操作记录拉出来。比如 10:05 导出开始,10:07 Young GC 变密,10:09 Concurrent Mark 启动,10:10 Mixed GC 后老年代回收不明显,10:11 出现 Full GC,10:12 网关超时升高。这条时间线比单独说“Full GC 频繁”更有说服力。

第三步是建立调优闭环。调优不是把 -Xmx 从 4G 改成 8G 就结束。必须回答:问题是峰值对象太多、长期对象太多、分配速率太高,还是收集器策略不合适?如果是导出导致,核心改造应该是分页流式写、异步导出和并发限制;如果是缓存占用老年代,应该治理缓存容量、过期和命中率;如果是 G1 Mixed GC 跟不上,才考虑调整 IHOP、Region、暂停目标等参数。

第四步是将关键指标可视化。项目里建议至少监控 Young GC 次数和耗时、Full GC 次数和耗时、堆使用率、老年代使用率、GC 后老年代低水位、分配速率、线程数、接口 P95/P99 RT。真正有用的不是“堆用了 80%”这个单点,而是“Full GC 后老年代仍从 65% 升到 82%,且低水位连续三小时抬升”。

排查流程

第一步,确认影响面。先看是单个实例还是整个集群。如果只有一个实例 Full GC 频繁,优先怀疑该实例承接了特殊请求、导出任务、流量倾斜或本地缓存异常。如果所有实例同时异常,可能是业务峰值、定时任务、下游变慢导致请求堆积,或者新版本代码引入了公共问题。

第二步,对齐时间线。拿 GC 日志时间戳和业务日志、网关日志、操作审计、发布记录对齐。面试回答里可以强调:“我不会直接从最后一条 OOM 日志开始看,而是先找老年代上涨和 Full GC 变频繁的起点,再反查那个时间点发生了什么业务动作。”

第三步,看 GC 类型和频率。Young GC 很频繁说明分配速率高;Mixed GC 频繁但老年代回收不明显,说明老年代里可回收对象不足或回收效率不佳;Full GC 连续出现说明 G1 正常回收路径已经撑不住。还要看停顿时间,如果一次 Full GC 停顿 10 秒,业务线程会全部暂停,接口慢只是结果。

第四步,看 GC 前后内存。重点关注 Full GC 后老年代是否下降。如果从 3.6G 降到 1.2G,说明有大量临时对象,可能是峰值任务;如果从 3.8G 只降到 3.5G,说明老年代里大部分对象仍然可达,要怀疑泄漏、缓存膨胀或长生命周期对象异常。

第五步,看异常关键字。G1 中看到 Humongous 要联想到超大对象,比如大 byte 数组、大字符串、大集合、Excel 大对象;看到 to-space exhausted 或 evacuation failure,说明复制转移空间不足;CMS 中看到 concurrent mode failure,说明并发回收没赶上老年代分配速度,被迫退化为更重的回收。

第六步,决定是否抓 Dump。GC 日志能说明内存变化,但不能直接告诉你哪个对象占内存。若 Full GC 后老年代不下降,应抓堆 Dump,用 MAT 看 Dominator Tree 和 GC Roots;若只是导出期间峰值冲高,则结合接口日志、导出参数、对象分配链路分析即可。

常见坑

第一个坑是只看堆使用率,不看 GC 后低水位。堆到 90% 不一定有问题,关键是 GC 后能不能回落。很多正常应用在高峰期堆使用率会比较高,只要停顿可控、回收有效,就不必过度调参。

第二个坑是把所有 Full GC 都归因于内存泄漏。批处理、大导出、缓存预热、类加载、手工 System.gc() 都可能触发 Full GC。判断泄漏要看长期趋势和引用链,而不是看到 Full GC 就下结论。

第三个坑是盲目调大堆。堆变大后,Full GC 可能变少,但单次停顿可能更长;如果根因是导出链路一次性持有几十万对象,调大堆只是让问题晚一点出现。面试时要表达“参数调优是辅助手段,业务对象生命周期治理才是核心”。

第四个坑是忽略容器内存。应用 -Xmx 只限制堆,容器里还要容纳元空间、线程栈、直接内存、JIT、GC 结构和 Native 内存。如果容器限制 4G,堆也设 4G,很容易被 OOM Killer 干掉。

第五个坑是日志没有保留。线上故障发生后,如果 GC 日志没有滚动保留,或者容器重启后文件丢失,就无法复盘。建议 GC 日志落盘并采集,至少保留覆盖业务高峰和故障窗口的周期。

面试追问

面试官可能会问:你怎么判断 Full GC 是内存泄漏还是业务峰值?你看 GC 日志时最关注哪几个字段?G1 出现 Humongous Allocation 说明什么?为什么 Full GC 会导致接口超时?只调大堆能不能解决问题?你线上敢不敢抓 Dump?GC 日志和 Arthas、MAT 分别解决什么问题?

还可能继续追问具体参数:MaxGCPauseMillis 是不是越小越好?InitiatingHeapOccupancyPercent 调小有什么影响?年轻代应该固定吗?为什么 G1 不建议随便设置 NewRatio?JDK 8 和 JDK 11 的 GC 日志参数有什么区别?

这些问题的核心不是让你背参数,而是看你是否理解“现象、证据、原因、改造、验证”这条链路。

推荐回答

可以这样回答:

我看 GC 日志会先建立时间线,把 GC 时间和业务日志、监控告警、发布记录对齐。然后看 GC 类型、频率、停顿时间和 GC 前后内存变化,尤其关注 Full GC 后老年代能否回落。如果 Full GC 后老年代低水位持续抬升,我会怀疑泄漏或长生命周期对象异常,需要抓 Dump 看 Dominator Tree 和 GC Roots;如果只在导出或批处理期间冲高,任务结束后能回落,就更像峰值对象过多,需要改导出和批处理链路。

之前有一次保单导出导致 Full GC 频繁,GC 日志里能看到 Young GC 变密,随后老年代快速上涨,Mixed GC 回收效果不好,最后出现连续 Full GC。结合业务日志发现同一时间有全量导出请求。后续我们把同步全量导出改成异步任务,数据库分页读取,使用流式 Excel 写出,并限制单实例并发导出数。调参上只做了辅助优化,核心是减少一次请求持有的对象数量。上线后 Full GC 次数明显下降,导出期间接口 P99 也稳定很多。

这个回答的好处是既讲工具,又讲业务,还讲了验证指标。资深面试最看重的就是这种闭环。

延伸学习路线

第一阶段,先熟悉 JVM 堆结构、年轻代和老年代、对象晋升、STW、GC Roots、可达性分析。不要急着背所有收集器参数,先理解对象为什么能被回收、为什么不能回收。

第二阶段,重点学习 G1 日志。现在很多 Spring Boot 服务使用 G1,建议能看懂 Young GC、Concurrent Mark、Mixed GC、Full GC、Humongous、to-space exhausted 等关键字,并能结合实际场景解释。

第三阶段,练习用工具分析。可以用 GCViewer、GCEasy、JDK Mission Control 看 GC 日志趋势,用 MAT 分析 Dump,用 Arthas 在线观察线程和内存。工具不必都精通,但要知道各自解决什么问题。

第四阶段,结合业务复盘。找几个典型场景:大 Excel 导出、缓存膨胀、ThreadLocal 泄漏、批处理并发过高、下游慢导致请求堆积。分别写出监控表现、GC 日志特征、Dump 证据和改造方案。

第五阶段,形成面试表达模板:先说背景,再说现象,再说日志怎么证明,再说根因,再说改造,再说效果。能把 GC 日志讲到业务闭环,才是真正能打动面试官的 JVM 调优经验。