GC 收集器与日志
垃圾收集器为持续分配对象的程序回收堆空间。它需要识别仍在使用的对象、释放其他对象占用的空间,并在必要时移动存活对象和更新引用。不同收集器把这些工作放在不同阶段,形成吞吐、暂停时间、CPU 和内存占用之间的取舍。
读 GC 日志时,首先确认收集器与版本,再把一次事件的原因、堆变化和耗时连起来。Young GC 频繁、Full GC 出现、分配失败或容器被杀,分别需要不同的解释。
GC 日志的组成
实验环境为 amd64/x86_64 架构的 Temurin 17.0.20+8。需要从 Eclipse Temurin 下载页安装完整 JDK,而不是只有 java 的精简运行时;实验还会调用 javac。在 Linux Bash 中以普通用户设置实际安装路径,再确认两个命令来自同一套 JDK:
export JDK17_HOME=/opt/jdk-17
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
"$JDK17_HOME/bin/java" -version
"$JDK17_HOME/bin/javac" -version生产服务至少要在启动时记录 GC 选择、事件、heap 变化与 phase。JDK 17 的 unified logging 可以从下面的滚动配置开始;目录、保留周期和 I/O 预算应按部署环境确定,示例数值只控制日志文件,不代表 heap 配置:
-Xlog:gc*=info:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=8,filesize=32m在压测或隔离复现中需要更细的 G1 phase 时,可以临时升到 gc*=debug,再按实际问题缩小 tag。详细日志本身会增加体积和处理成本,不应因为“可能有用”长期打开所有 trace 级别。JDK 17 java 文档给出了 -Xlog 的 selector、level、decoration 与轮转语法。
一组最小 G1 事件会出现这类结构:
[...][info][gc,start ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
[...][info][gc,heap ] GC(0) Eden regions: 14->0(15)
[...][info][gc,heap ] GC(0) Old regions: 0->0
[...][info][gc,heap ] GC(0) Humongous regions: 0->0
[...][info][gc ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 13M->1M(32M) 1.104msUsing G1 先确认实际收集器;GC(0) 把同一次事件的 start、phase、heap 和结束行连起来。Pause Young 是 event,Normal 是 G1 周期中的位置,G1 Evacuation Pause 描述主要工作;13M->1M(32M) 是这次事件的 heap before、after 与当前 capacity,最后才是 duration。
一次 before→after 下降只能说明本次事件回收了空间,不能单独证明应用没有泄漏;一次 duration 较长也不能证明它命中了慢请求。要比较多轮同类型事件后的 live-set 走势,并用日志时间戳对齐业务窗口。安全点的 reaching、cleanup 与 at-safepoint 仍按《对象分配、GC Roots 与安全点》单独读取。
回收算法与收集器选择
标记、清理、复制与整理
标记确定哪些对象仍然存活。清理可以把死对象的空间交还分配器,却可能留下分散的小空洞;复制把存活对象迁移到目标空间,再释放原区域;整理让存活对象更紧凑,以减少碎片。它们是工作方式,不能直接当成某一个收集器的名字。
原区域:存活 A | 垃圾 | 存活 B | 垃圾
清理后:存活 A | 空闲 | 存活 B | 空闲
复制后:原区域整体空闲;目标区域保存 A、B
整理后:存活 A | 存活 B | 连续空闲“并行”描述多个 GC 线程共同执行工作;“并发”描述 GC 工作与应用线程在时间上重叠。并发收集仍可能需要暂停协调,也需要写屏障或读屏障维护引用信息。移动对象后,要让程序继续取得正确对象,而非继续使用已失效的旧地址。
分代利用“多数对象较早死亡”的常见分布,优先回收年轻对象。对象年龄、Survivor 空间和晋升策略共同影响它何时进入老年代,不能把某个固定年龄写成所有收集器通用规则。老年代引用年轻对象时,还需要 remembered set 等结构记录跨区引用,否则每次年轻代收集都要大范围扫描。具体策略见 JDK 17 G1 机制指南。
按实际约束选择
JDK 17 收集器指南提供各收集器的适用条件。选择前先明确:允许多长的暂停、吞吐损失能接受多少、heap 与 CPU 能为并发 GC 留多少余量。
| JDK 17 收集器 | 工作主要放在哪里 | 更合适的起点 | 主要代价与验证点 |
|---|---|---|---|
| Serial | 单 GC 线程,回收期间 STW | 小数据集、单处理器、短生命周期工具 | heap 变大后暂停扩展,不能利用多核加速回收 |
| Parallel | 多 GC 线程执行分代 STW | 批处理、离线计算、吞吐优先且允许较长暂停 | 高吞吐不等于低尾延迟,要对齐最长暂停与业务 SLA |
| G1 | Young/Mixed evacuation STW,大部分标记并发 | 常规服务,需要吞吐与暂停折中 | region、remembered set 和并发线程有额外成本;pause target 只是目标 |
| ZGC | 大部分标记与重定位并发,短暂停做协调 | 极低暂停优先、能提供 CPU 与 heap headroom | 可能牺牲吞吐;并发回收追不上分配时会出现 Allocation Stall |
G1 在 JDK 17 的多数 server-class 配置上是默认选择,但 vendor、架构和容器可见资源都可能改变 ergonomics。不要从“通常默认”推断现场,启动时直接看日志:
"$JDK17_HOME/bin/java" -Xlog:gc=info -version显式选择时分别使用 -XX:+UseSerialGC、-XX:+UseParallelGC、-XX:+UseG1GC 或 -XX:+UseZGC。先在同一 JDK、heap、CPU 配额、数据集和请求速率下比较,再决定是否切换;把不同 heap 大小下的两个收集器直接排名没有意义。
这里的固定实验使用 JDK 17 非分代 ZGC,日志里没有 G1 的 Young 或 Mixed 语义。分代 ZGC 在 JDK 21 以可选模式引入、JDK 23 成为默认,JDK 24 移除了非分代模式;升级后的行为应按目标 JDK 重新取证,不能套用这里的 JDK 17 事件词汇。JEP 439、JEP 474和 JEP 490记录了这条版本边界。
JDK 25 与其他实现
JDK 25 使用分代 ZGC;它的年轻代与老年代周期需要按新版本日志解释,不能继续套用下文非分代 ZGC 的完整阶段名称。JDK 25 GC 指南提供当前版本入口。
部分发行版还提供 Shenandoah,它也把大量回收工作并发化。分代模式是否可用、是否默认以及支持的平台,应按发行版确认;Red Hat OpenJDK 25 说明记录了其分代支持。CMS 属于历史收集器,现代 JDK 已移除;迁移旧参数前查目标 JDK 的选项状态。
G1 用 region 和双环周期回收空间
G1 把 heap 划成等大小 region,再动态赋予 Eden、Survivor、Old 和 Humongous 等角色;同类 region 不要求在地址上连成一大片。region 让 G1 能选择回收收益较高的 old regions,而 remembered set 记录跨 region 引用,使收集器不必每次扫描整个 old generation。
Young-only 内环从分配开始。Eden 不足时,G1 暂停应用,把仍存活的对象复制到 Survivor 或 Old,释放 collection set。日志中的 Eden regions: X->0(Y) 表示本次清空了多少 Eden region 与下一轮目标,不是对象个数;Old regions: X->Y 是 region 数变化,也不能精确等同于晋升字节数。
old occupancy 达到自适应 IHOP 判断后,一次 Concurrent Start young collection 启动外环。Concurrent Mark 与应用并发计算 old-region liveness,随后 Pause Remark 完成标记,Pause Cleanup 整理候选信息并决定是否进入空间回收;回收收益足够时,Pause Young (Prepare Mixed) 执行最后一次 young-only 收集,接着一个或多个 Pause Young (Mixed) 同时收集 young regions 与选中的 old regions,之后回到 young-only。
在标记完成且回收收益足够时,周期可以写成:
Young-only × N
→ Concurrent Start
→ Concurrent Mark(并发)
→ Remark(STW)
→ Cleanup(STW,判断是否进入空间回收阶段)
→ 候选 region 值得回收时:Prepare Mixed(STW)
→ Mixed × N(STW)
→ Young-onlyCleanup 后也可能继续 young-only;新版本还存在无需完成标记的 Concurrent Mark Undo 路径,见 JDK 25 G1 周期说明。Full GC 是空间压力下的回退,不是正常环的固定下一站。当应用分配和晋升快于 G1 释放空间、并发标记没及时完成,或 evacuation 缺少目标 region 时,日志可能先出现 to-space exhausted;JVM 可能在 evacuation failure 后继续运行,压力持续才可能回退到 Full GC。把 to-space exhausted → Full GC 画成必然一步,会掩盖中间是否恢复和后续压力是否继续。
Humongous 描述对象的大小分类。HotSpot 的 is_humongous 判定使用严格大于关系:对象总大小严格大于 region 一半时,G1 把它作为 humongous object,从 old generation 的连续 regions 分配,总大小包括对象头和对齐,最后一个 region 可能留下尾部浪费;分配本身还可能触发并发周期。Humongous regions: X->Y 只能说明前后 region 数,不能证明 X 与 Y 是同一批对象。调大 G1HeapRegionSize 会同时改变阈值、region 数、remembered-set 和 evacuation 粒度,应先治理超大数组、字符串、响应体和整文件读入。
关联同一事件的原因、空间和耗时
读日志的顺序固定为 collector → event → cause → heap/region delta → phase/duration。相同英文词放在不同收集器里可能不是同一种故障:
| Collector 与日志 | 能推出什么 | 还不能推出什么 |
|---|---|---|
Serial/Parallel Pause Young (Allocation Failure) | 一次分配请求触发了 Young GC | Java 分配最终失败或即将 OOME;成功实验也会出现该 cause |
G1 Pause Young (Normal) | 正常 young-only evacuation | Old 应该在这次事件中显著下降 |
G1 Concurrent Start/Prepare Mixed/Mixed | 并发标记开始、Mixed 准备或回收 old 候选 | 某次 Mixed 后 old 不降就一定泄漏 |
G1 to-space exhausted | evacuation 目标空间不足,本次出现 evacuation failure | JVM 必然立即 Full GC 或已经 OOME |
Pause Full (System.gc()) | 应用或外部调用显式请求了 Full GC | heap 太小或收集器自动失败回退 |
G1 Pause Full (Allocation Failure) | 自动分配压力已经走到重型回退 | 只要增大 heap 就能永久修复 |
ZGC 17 Allocation Stall | mutator 在等待可分配空间 | 这是 G1/Parallel 的通用暂停名称 |
同一个 GC(n) 内,先看结束行的 before→after→capacity,再看 gc,heap 的 region delta 和 gc,phases 的时间分解。end 行释放很多而事件频繁,通常先指向短命对象 churn 或 heap/young sizing;适当 Mixed 或 Full 之后的 after 值在同负载下持续上升,才说明 live set 正在变大,但“泄漏”仍需 Root path 证明。
phase 也要结合工作量读。G1 的 Evacuate Collection Set 高,说明复制与更新引用占据暂停;Reference Processing 高要回到弱、软、虚引用规模;gc,cpu 中 Real 明显大于 User + Sys,还要检查 CPU 是否被抢占。不能仅凭总暂停时间就选择一个参数。
safepoint 日志通常没有同一个 GC id,应用慢窗口也没有。它们应按统一时钟和 timestamp 对齐,不能把 GC(42) 写成跨业务、GC 与 safepoint 的万能关联号。生产中完整的 PID、权限、JFR 与 dump 取证由《JVM 诊断》展开。
常见压力与调整方向
短命对象 churn。 现象是 Young 事件密集、每次 after 明显回落、长期 live-set 基线稳定。先把 allocation rate 与 QPS、批次大小、响应体和序列化对齐,削减临时数组、重复编码和一次性集合。Young GC 频繁但短且没有命中 SLA,不必为了减少次数牺牲吞吐;复测看同 QPS 下 allocation rate、GC CPU 与暂停分布。
live set 上升或并发周期来不及。 相同负载下,适当 old-reclaiming 事件后的 after 基线持续抬升;Concurrent Start 启动过晚,或并发标记与 Mixed 尚未完成,自由 region 和保留余量已经被分配耗尽,随后可能出现 evacuation failure 或自动 Full GC。先限制缓存、队列和批次并查 Root path;只有确认 live set 合法、并发标记确实启动太晚且 CPU 有余量,才评估更大 heap、让 marking 更早开始或增加并发资源。InitiatingHeapOccupancyPercent 不是看到 Full GC 就修改的固定答案。
humongous 压力。 Humongous regions 相对 old regions 占比高,并与大数组、大字符串、压缩包或整文件读入时间吻合。优先切片、流式处理和限制响应体;只有对象尺寸无法改变时,才评估 region size 与 heap。复测要确认同数据分布下 humongous region 峰值、并发周期和 Full GC cause 都改善。
重型回退或低延迟 stall。 Full GC 先按 cause 分流:System.gc() 找调用方或工具,Allocation Failure 对齐 live set、marking 进度与 evacuation,metadata cause 路由类加载器生命周期。ZGC 的 Allocation Stall 则表示并发回收没有及时提供空间,要同时看 allocation rate、heap headroom 与 CPU;切换低延迟收集器不能替代削减无界保留。
JDK 17 G1 调优指南建议先使用默认启发式,只在证据明确后调整。MaxGCPauseMillis 是目标而非承诺;给 G1 固定 -Xmn 或 NewRatio 会限制它为暂停目标自适应 young generation 的能力。调参必须说明牺牲项:更早 marking 消耗更多并发 CPU,更大 heap 可能拉长最坏回收,更小 young 可能提高 GC 频率。
分步产生并读取 GC 日志
下载完整实验包,进入 gc-collectors-logs 目录,继续使用前面配置的 Linux x86_64 与 JDK17_HOME。源码 CollectorLogProbe.java提供短命分配、显式回收、G1 周期、大数组与保留模式。
LAB_OUT=$(mktemp -d /tmp/gc-collectors-logs.XXXXXX)
"$JDK17_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT" CollectorLogProbe.java在相同分配任务下切换收集器
for collector in UseSerialGC UseParallelGC UseG1GC UseZGC; do
"$JDK17_HOME/bin/java" -Xint -Xms32m -Xmx32m "-XX:+$collector" \
'-Xlog:gc*=info:stderr:none' -cp "$LAB_OUT" \
example.gc.CollectorLogProbe churn \
>"$LAB_OUT/$collector.out" 2>"$LAB_OUT/$collector.log"
cat "$LAB_OUT/$collector.out"
done
grep -E 'Using |Pause Young|Garbage Collection' "$LAB_OUT/UseG1GC.log"四份标准输出都应有 survivors=27、checksum=84146。日志事件数量与耗时可以不同。Serial 和 Parallel 的正常 Young GC 也会写 Allocation Failure,任务最终仍然成功;这里的 cause 表示触发回收的分配需求,并非已经抛出 OOME。
如果 JVM 不支持某个收集器,会在启动阶段报错,先检查发行版和架构,不把失败日志混进性能比较。
用大数组观察 humongous
for mode in humongous-small humongous-large; do
"$JDK17_HOME/bin/java" -Xint -Xms64m -Xmx64m \
-XX:+UseG1GC -XX:G1HeapRegionSize=1m \
'-Xlog:gc*=info:stderr:none' -cp "$LAB_OUT" \
example.gc.CollectorLogProbe "$mode" \
>"$LAB_OUT/$mode.out" 2>"$LAB_OUT/$mode.log"
cat "$LAB_OUT/$mode.out"
grep 'Humongous regions' "$LAB_OUT/$mode.log"
done两种模式都保留四个数组。400 KiB 载荷远低于阈值,600 KiB 载荷高于阈值;后者在固定环境的显式收集事件中出现 Humongous regions: 4->4。引用仍保留着四个数组,所以回收前后数量不变。
固定堆预算,比较无界与有界保留
if "$JDK17_HOME/bin/java" -Xint -Xms32m -Xmx32m -XX:+UseSerialGC \
-cp "$LAB_OUT" example.gc.CollectorLogProbe retained-pressure \
>"$LAB_OUT/pressure.out" 2>"$LAB_OUT/pressure.err"; then
printf '异常:无界保留意外完成\n' >&2
else
LAB_EXIT=$?
printf 'exit=%s\n' "$LAB_EXIT"
cat "$LAB_OUT/pressure.err"
fi
"$JDK17_HOME/bin/java" -Xint -Xms32m -Xmx32m -XX:+UseSerialGC \
-cp "$LAB_OUT" example.gc.CollectorLogProbe bounded-retention负例应退出 1,异常为 OutOfMemoryError: Java heap space;有界模式退出 0,retained-blocks=8、checksum=5232。其他非零退出原因仍属实验失败。不要把这项 32 MiB 小堆实验运行到业务 JVM 中。
完整周期与自动检查
bash run.sh脚本严格编译并拒绝 JAVA_TOOL_OPTIONS、JDK_JAVA_OPTIONS 与 _JAVA_OPTIONS。它让四种收集器执行相同 churn checksum,不比较耗时或 GC 次数;再分别验证 G1 young/region 字段、完整 concurrent→prepare-mixed→mixed 周期、1 MiB region 下 400 KiB/600 KiB 的 humongous 阈值、显式 GC 的具名 cause,以及 JDK 17 ZGC 的非分代日志边界。
成功输出以真实断言收尾:
serial-log=GC(n) Pause Young (Allocation Failure) <heap> <duration>
parallel-log=GC(n) Pause Young (Allocation Failure) <heap> <duration>
g1-young-log=GC(n) Pause Young (Normal) (G1 Evacuation Pause) <heap> <duration>
g1-cycle-order=Concurrent Start -> Concurrent Mark Cycle -> Pause Remark -> Pause Cleanup -> Prepare Mixed -> Mixed
g1-humongous-log=GC(n) Humongous regions: 4->4
zgc17-phase-order=GC(n) Pause Mark Start -> Pause Mark End -> Pause Relocate Start
retained-pressure=EXPECTED_OOME(Java heap space)
collector-startup-jdk17-amd64=PASS
serial-normal-allocation-failure=PASS
parallel-normal-allocation-failure=PASS
g1-young-region-fields=PASS
explicit-collection-causes=PASS
zgc17-nongenerational-cycle=PASS
g1-humongous-threshold=PASS
g1-concurrent-prepare-mixed=PASS
bounded-retention-same-workload=PASS
PASS gc-collectors-logs前六行提取自实际 GC 日志;原始事件号、heap 数字和耗时会随运行变化,所以只隐藏这些动态值,不隐藏 event、cause、region delta 或 phase 顺序。第七行来自 retained-pressure 子进程的非零退出状态和 OOME 首行断言,不是 GC 日志摘要。
负例与修复都请求 96 次、每次 512 KiB,并使用同一 32 MiB heap。无界模式保留每一块,必须在完成请求循环前以预期非零状态结束并出现 OutOfMemoryError: Java heap space;有界模式只保留最近 8 块,必须完成全部 96 次分配和 checksum。它证明“无界保留导致最终分配失败,有界生命周期可以在同预算和请求上限下走完”,却不把某种 Full GC、to-space exhausted 或 ZGC Allocation Stall 设成必现条件。这些日志依赖 heap shape、时序和启发式,需要在目标环境中结合上下文判断。
记录生产基线时,保存 JDK、collector、heap、CPU 配额、数据规模、请求速率和持续时间。调整时一次只改变对象生命周期、heap、pause target 或 collector 中的一类;复测比较吞吐、p99、暂停分布、GC CPU、同类型回收后的 live-set 走势与失败事件,并保留旧参数回切。只有同等负载下改善能重复出现,且没有把压力转移到 CPU、RSS 或吞吐,才适合保留新的 GC 配置。
实验结束,保留所需日志后清理:
case "$LAB_OUT" in
/tmp/gc-collectors-logs.*) rm -rf -- "$LAB_OUT" ;;
*) printf '拒绝清理未知路径\n' >&2 ;;
esac权威资料与规范地址
按正文中的概念与命令查阅原始规范。版本化文档对应标注的 JDK,升级后应查目标版本的同名章节。
