GC 收集器与日志:从暂停和分配失败反推回收链路
接口 p99 突然抖到 1.8 秒,监控同时出现 Full GC。有人建议把堆从 4 GB 加到 8 GB,另一个人建议直接换 ZGC。两种建议都跳过了最关键的问题:应用的分配率是多少、每轮存活多少、并发标记是否来得及、暂停花在哪个 phase、容器是否还有余量。
GC 日志应当作为时间线读取,而不是参数词典。先从吞吐、暂停和 footprint 三个目标选择收集器,再用统一日志观察 Young、Concurrent Mark、Mixed、Humongous、Evacuation Failure 与 Full GC。所有调优都必须有前后对照负载,不能用一次偶然曲线证明。
选收集器前先确定目标和日志基线
Serial、Parallel、G1 和现代分代 ZGC 的差异只有落到吞吐、停顿与 footprint 目标时才有决策价值。G1 的 region、remembered set、并发标记、mixed collection、humongous object 和统一日志可以解释常见服务现场;选择 Shenandoah 等其他收集器时,还要额外核对目标发行版是否提供、支持周期和实际负载证据。
容量总账有独立专题,暂停目标也不是收集器给出的承诺。在 JDK 25 的多数 server-class 配置中,G1 是默认选择;JDK 24 起 ZGC 只保留分代模式。真正上线时,仍要确认目标发行版是否支持,并用自己的负载压测。
给生产服务一套 JVM 参数基线
参数基线不是为了“炫技”,而是为了出事时能留下证据、能控制资源边界、能回滚。没有 GC 日志、没有 heap dump、没有 hs_err 文件,线上 OOM 后往往只剩一句“服务没了”。
JDK 17+ 可以先从这套基线开始,再按服务类型调整:
JAVA_OPTS="
-Xms2g
-Xmx2g
-XX:MaxMetaspaceSize=256m
-Xss512k
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/myapp/heapdump
-XX:ErrorFile=/var/log/myapp/hs_err_pid%p.log
-Xlog:gc*,safepoint:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
"
java $JAVA_OPTS -jar app.jar如果运行在容器里,而且希望 JVM 根据容器限制计算堆,可以用百分比参数:
JAVA_OPTS="
-XX:InitialRAMPercentage=50.0
-XX:MaxRAMPercentage=70.0
-XX:MaxMetaspaceSize=256m
-Xss512k
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/myapp/heapdump
-Xlog:gc*,safepoint:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
"如果容器 CPU 限制、JVM 识别和业务线程池计算不一致,可以显式指定:
-XX:ActiveProcessorCount=2启动后先确认参数和日志确实生效:
jcmd "$PID" VM.flags | grep -E 'MaxHeapSize|MaxRAMPercentage|InitialRAMPercentage|MaxMetaspaceSize|ThreadStackSize|Use.*GC'
ls -lh /var/log/myapp/你应该能看到堆大小、Metaspace 上限、线程栈大小、GC 选择和 GC 日志文件。目录权限要提前验证,OOM 时 JVM 如果写不进去,配置等于没有。
参数基线里有几项不能跨版本照抄:
JDK 8 的 GC 日志参数不是 -Xlog。老版本通常用 -Xloggc、-XX:+PrintGCDetails、-XX:+PrintGCDateStamps 和日志轮转参数;迁移 JDK 时要按目标版本重新核对。-Xms 和 -Xmx 固定相等能减少堆伸缩抖动,但也会让启动后内存占用更稳定地顶上去;小容器或多实例环境要结合密度评估。HeapDumpPath 指向容器内临时目录,容器重启后可能丢失现场。
GC 日志文件不轮转,会把磁盘打满;轮转太小,又会覆盖事故窗口。
参数变更要能灰度、能对比、能回滚。一次同时改 GC、堆大小、线程池和容器资源,出了问题就无法判断是哪一项导致的。每次只改一类变量,并在发布单里写清楚预期指标变化。
GC 不要先背算法,先看现象
接口慢、P99 抖动、CPU 飙高、内存锯齿,都可能和 GC 有关,也可能完全不是。判断 GC 问题要看三件事:停顿时间是否影响业务,回收后内存是否下降,老年代或活跃数据是否持续增长。
先看当前 GC 和堆:
jcmd "$PID" VM.flags | grep -E 'Use.*GC|MaxGCPauseMillis|InitiatingHeapOccupancyPercent'
jcmd "$PID" GC.heap_info
jstat -gcutil "$PID" 1000 10再看 GC 日志:
tail -n 200 /var/log/myapp/gc.log
grep -E 'Pause|Full|Concurrent|OutOfMemory|Humongous|to-space exhausted' /var/log/myapp/gc.log | tail -n 100JDK 9+ 的统一日志可以按标签读。下面是一段简化后的 G1 日志样例,重点不是背格式,而是知道每行回答什么问题:
[12.345s][info][gc,start] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
[12.346s][info][gc,heap ] GC(42) Eden regions: 128->0(96)
[12.346s][info][gc,heap ] GC(42) Survivor regions: 12->18(18)
[12.346s][info][gc,heap ] GC(42) Old regions: 420->438
[12.346s][info][gc,heap ] GC(42) Humongous regions: 8->8
[12.352s][info][gc ] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 960M->520M(2048M) 7.123ms这几行要这样读:
GC(42) 是一次 GC 事件编号,用它把 start、heap、phases、safepoint 相关日志串起来。Pause Young 说明这是年轻代疏散停顿,不等于 Full GC。960M->520M(2048M) 表示本次回收前、回收后、总堆容量。关键看“回收后”是否持续上涨。
Eden 128->0 说明 Eden 被清空;Survivor 12->18 说明存活对象进入 Survivor;Old 420->438 说明有对象晋升到老年代。Humongous 8->8 说明大对象 region 没减少。如果这个值长期高,要查超大数组、超大字符串、一次性大响应和文件读入内存。
如果看到下面这种关键词,要提高优先级:
| GC 日志关键词 | 常见含义 | 下一步 |
|---|---|---|
to-space exhausted / to-space overflow | G1 疏散时目标空间不足,存活对象或晋升压力过大 | 查老年代、Survivor、humongous、堆是否太满 |
Full GC | 进入重型回收或回退路径 | 立刻对齐业务慢窗口、heap dump、发布变更 |
Concurrent Cycle 连续触发 | 并发标记追不上分配或触发阈值过晚 | 看分配速率、IHOP、CPU 余量和活跃数据 |
Allocation Stall | 应用线程等 GC 腾空间 | 低延迟收集器也可能撑不住分配速率 |
Metadata GC Threshold | Metaspace 触发 GC | 查类加载器、动态类、代理和热部署 |
排障时可以按这个方向判断:
把日志与业务时间线对齐后,可以先做四个判断:
Young GC 频繁但停顿短,不一定是事故。Full GC 频繁、停顿长、回收后老年代仍不下降,更像泄漏或长期缓存失控。CPU 高同时 GC 日志密集,可能是分配速率过高或堆压力过大。
慢请求时间线和 GC 停顿时间线不重合时,不要硬把锅甩给 GC。
收集器名称和停顿次数本身还不够下结论:
只看一次 Full GC 就下结论。GC 要结合时间线、业务高峰、发布变更和堆趋势看。把 CMS 参数带到新 JDK。CMS 已在 JDK 14 移除,现代 JDK 不应该再依赖 CMS。看到 MaxGCPauseMillis 就以为可以精确控制停顿。它是目标,不是保证。
只看堆,不看分配速率。接口批量返回大对象、日志打印大 JSON、频繁创建临时集合,都可能把 GC 压力打上去。
普通后端服务先从默认 GC 和清晰日志开始,不必过早追求“调优参数大全”。先治理对象生命周期、缓存边界、批处理大小、响应体大小和线程池隔离,收益通常比改 GC 参数更稳定。只有证据指向 GC 停顿或堆布局问题时,才进入 GC 参数调优。
G1 和 ZGC 的选择要按目标看:
G1 把堆切成 region,年轻代、老年代和 humongous 对象都以 region 形式管理。它是多数现代服务的默认选择,MaxGCPauseMillis 是停顿目标,不是实时保证。ZGC 面向低延迟,主要重活尽量并发完成,适合对停顿特别敏感且能接受更多并发开销的服务。JDK 24 之后 ZGC 已是分代模式,老的 ZGenerational 选项已经移除;JDK 21 使用分代 ZGC 时才需要额外关注 -XX:+ZGenerational。
不要把“更低停顿”理解成“更少内存”。低延迟收集器通常更需要给堆和 CPU 留余量,否则会出现 allocation stall、并发周期追不上分配速率等问题。
G1 可以先按 region 模型理解。
官方文档里,G1 的 heap layout 原图先看这个。这里保留官方原图,是为了让你知道 Oracle 文档强调的重点:G1 不是把年轻代、老年代做成连续大块,而是把堆拆成一组等大小 region,再给 region 赋予 Eden、Survivor、Old、Humongous 等角色。

下面的重绘图采用排障视角:官方图告诉你 region 长什么样,重绘图把 region 类型和日志、现象、下一步动作接起来。
再看 G1 收集周期。Oracle 原图把 Young-Only phase、Concurrent Start、Remark、Cleanup、Space-Reclamation 和 Mixed collection 放在同一条环上,适合用来理解“为什么不是每次 GC 都处理老年代”。

重绘图把这条环翻译成线上判读顺序:先看 Young GC 是否只是正常疏散,再看是否进入 Concurrent Start,然后盯 Remark、Cleanup 和 Mixed collection 是否密集,最后判断是否要回到分配速率、存活对象和 IHOP。
ZGC 则先按目标理解:它追求极低停顿,把标记、整理和重定位的大部分工作并发化。代价是需要 CPU 和堆余量支撑并发 GC 追上业务分配速率。JDK 21 的分代 ZGC、JDK 23 默认分代、JDK 24 移除非分代模式,是同一条演进线,不要拿旧文章里的 -XX:+ZGenerational 当现代固定模板。
GC 调优不要从参数开始,要从证据链开始:
调参时可以按这张表处理:
| 证据 | 优先动作 | 参数动作 |
|---|---|---|
| Young GC 很频繁但停顿短 | 查分配速率、批处理大小、日志大对象、JSON 序列化 | 必要时增大堆或降低批量峰值 |
| 老年代回收后不下降 | 查缓存、集合、队列、ThreadLocal、类加载器 | 先修引用链,别先加 -Xmx |
| G1 humongous region 很多 | 查超大数组、超大字符串、批量响应、文件读入内存 | 必要时调 G1HeapRegionSize,但先减小对象 |
| 并发标记追不上分配 | 查流量尖峰、CPU 余量、对象生命周期 | 评估 InitiatingHeapOccupancyPercent、堆大小和并发线程 |
| 低延迟目标压不住 | 评估业务是否真的需要亚毫秒级停顿 | 再考虑 ZGC,并给 CPU/内存留余量 |
生产调参优先顺序:先确认对象分配和存活规模,再确认容器内存与 CPU 余量,最后才调 MaxGCPauseMillis、InitiatingHeapOccupancyPercent、region 相关行为或切换收集器。
用固定分配负载读懂日志,而不是拿它跑收集器排名
examples/backend-development/jvm/gc-collectors-logs/AllocationPressureDemo.java 以固定块大小制造短命对象,只保留少量 survivor。它不是 GC 基准,而是让同一份代码稳定产生 young collection,便于练习把暂停原因、堆前后变化和 survivor 数对回日志。
$sources = Get-ChildItem examples/backend-development/jvm -Recurse -Filter *.java
javac --release 17 -Xlint:all -Werror -d out/jvm $sources.FullName
java -Xms32m -Xmx32m -XX:+UseG1GC -Xlog:gc*=info -cp out/jvm example.jvm.gc.AllocationPressureDemo程序最终会打印 survivors=...,GC 日志则应出现多次年轻代回收。比较收集器时必须保持 JDK、堆、CPU 配额和这段负载一致,但不能据此宣布某收集器适合生产:对象大小、存活比例、线程并发和停顿目标都与真实服务不同。这个探针只验证“日志采集能工作、字段能解释、改参数后日志确实变化”。生产结论还要回到同负载的分配率、回收后存活集、业务吞吐和 P99。
调参完成的证据,不是曲线偶尔变好
回到 p99 事故,正确顺序是固定负载和版本,保存原始 GC 日志,算分配率、暂停分布与存活集,再判断是应用制造了过多长寿命/巨型对象、并发周期启动太晚,还是资源预算确实不足。改堆、改 pause target、改收集器一次只动一类变量;能在同等负载下复现改善,并有旧镜像和旧参数可回切,才算调优完成。
读 GC 日志时随手打开的官方入口
JDK 25 Available Collectors:Serial、Parallel、G1 与 ZGC 的目标差异。
JDK 25 G1 Tuning:Full GC、evacuation failure、humongous object 和暂停阶段的排查顺序。
JDK 25 ZGC:现代分代 ZGC 的适用目标和主要调节入口。JDK 25 java 命令:-Xlog、堆大小和收集器参数的实际语法。
日志里出现一次 Full GC,并不能直接推出“堆太小”;一次 p99 下降,也不能证明某个参数有效。把同一负载下的分配率、GC 后存活集、各阶段暂停和 CPU 余量放在一起,再看改动前后是否稳定重复。收集器选择最终服务的是业务延迟与吞吐,不是参数数量。
