GC 选型被讨论成"哪个收集器更先进",真实的约束只有三个:吞吐(业务线程占 CPU 时间比)、停顿(单次 STW 时长及其分布)、内存开销(收集器自身需要的额外堆与元数据)。三者互相拉扯,选型的实质是判断当前系统最不能牺牲哪一项,以及堆规模是否已把系统推进了另一个区间。
1. 三类目标与各自的量化指标
| 目标类型 | 主指标 | 次指标 | 可接受门槛(典型量级) | 典型场景 |
|---|---|---|---|---|
| 吞吐优先 | 单位时间完成请求数、业务线程 CPU 占比 | 平均停顿 | 停顿百毫秒级可忍,吞吐损失控制在个位数百分比 | 离线批处理、报表导出 |
| 延迟优先 | P99 / P999 停顿、单次 STW 上限 | 吞吐 | P99 停顿十毫秒级或更低 | 在线交易、行情推送 |
| 内存受限 | 常驻内存上限、堆外开销 | 停顿 | 堆不得越容器限额 | 高密度部署、边缘节点 |
把三类目标混进一套指标谈,就会得到"既要 P99 一毫秒又要吞吐不掉"的伪需求。先定哪项是硬约束。
2. 收集器的适用区间
| 收集器 | 启用方式 | 典型适用堆 | 停顿特征 | 主要代价 |
|---|---|---|---|---|
| Serial | -XX:+UseSerialGC |
百 MB 级 | 全堆 STW | 堆一大就不可用 |
| Parallel | -XX:+UseParallelGC |
数 GB | 全堆 STW,暂停与堆近似成正比 | 停顿时长不可控 |
| G1 | -XX:+UseG1GC |
数 GB 到数十 GB | 目标化停顿,多数情况可控 | 额外内存与写屏障开销 |
| ZGC | -XX:+UseZGC |
数十 GB 起才有意义 | 与堆规模弱相关 | 吞吐损失、内存放大、版本要求 |
| Shenandoah | -XX:+UseShenandoahGC |
数 GB 到数十 GB | 与堆规模弱相关 | 吞吐损失、发行版支持差异 |
# 目标化停顿:G1 的入口参数
java -XX:+UseG1GC -Xms8g -Xmx8g \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:G1HeapRegionSize=8m \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags \
-jar app.jar
换收集器能解决的:把长停顿切碎、把停顿目标化、在大堆上压住 STW 时长。
换收集器不能解决的:分配速率本身超过回收速率、老年代真实存活集合持续增长(泄漏)、单次年轻代回收就超过停顿预算。
参数只在"回收能力够、只是分布难看"时有效。分配速率与存活集问题不是收集器问题。
3. 停顿来源拆解
按来源分类,才知道该动哪个旋钮。
| 停顿来源 | 触发条件 | 停顿量级 | 归属 |
|---|---|---|---|
| 年轻代回收 | Eden 满 | 与存活对象量成正比 | G1 / Parallel 均有 |
| 混合回收 | 老年代占用越过启动阈值 | 比年轻代长,可分摊到多次 | G1 及以上 |
| Full GC | 并发周期失败、晋升失败、显式触发 | 与整堆成正比 | 全部 |
| 并发周期失败 | 回收速度赶不上分配速度 | 之后紧跟一次 Full GC | G1 |
| 晋升失败 | 老年代无足够连续空间容纳晋升对象 | 紧跟在年轻代回收后 | G1 |
| 元空间与类卸载 | 动态类生成、代理膨胀 | 通常较短 | 全部 |
# 最简:只记每次停顿与耗时
-Xlog:gc:file=/var/log/app/gc-simple.log:time,uptime
# 细节:分代大小、回收前后占用
-Xlog:gc+heap=debug:file=/var/log/app/gc-detail.log:time,uptime
Pause Young / Pause Full 的时长是唯一可信的停顿证据;面板上的 GC 次数与时间占比是聚合值,分不清哪类停顿在拖尾。
4. G1 旋钮的实际可控程度
| 旋钮 | 作用 | 可控程度 | 常见误用 |
|---|---|---|---|
MaxGCPauseMillis |
停顿目标(非硬保证) | 中:收集器据此反推年轻代大小 | 设成个位数毫秒,年轻代被压得过小,GC 频率飙升 |
InitiatingHeapOccupancyPercent |
并发周期启动的老年代占比阈值 | 中:调早可避免并发失败 | 与高分配速率不匹配,仍会 Concurrent Mode Failure |
G1HeapRegionSize |
区域大小,取 1/2/4/8/16/32m | 高 | 忽略 humongous 阈值 = region 的一半 |
ParallelGCThreads |
并行阶段线程数 | 高,但受 CPU 配额限制 | 容器里按宿主机核数推导,实际抢不到 CPU |
ConcGCThreads |
并发标记线程数 | 中 | 与业务线程争抢,反而推高延迟 |
G1NewSizePercent / MaxNewSize |
年轻代下限与上限 | 高 | 与停顿目标互相打架,二者只能保一个 |
humongous:大于 region 一半的对象走专门区域,分配与回收代价高,还容易制造碎片。region 越小,被判为 humongous 的对象越多——这个值不是越大越好,要结合对象大小分布定。
5. ZGC / Shenandoah 的代价
| 维度 | ZGC | Shenandoah | 说明 |
|---|---|---|---|
| 内存开销 | 需要额外堆与转发指针相关结构 | 需要额外堆与转发指针 | 堆预算要预留,不是 1:1 |
| 吞吐损失 | 相对吞吐型收集器有可感损失 | 类似量级 | 屏障指令与并发线程抢 CPU |
| 版本要求 | 分代模式与低停顿特性随 JDK 版本演进,需按实际使用的 JDK 版本核对 | 非全部发行版默认包含 | 升级前先确认发行版 |
| 压缩指针 | 与压缩指针存在互斥关系,需按版本核对 | 同类约束 | 会影响对象布局与内存占用 |
| 适用前提 | 堆足够大 + 停顿是真瓶颈 | 同左 | 小堆上收益接近零,只剩代价 |
小堆(例如数百 MB)上低停顿收集器的收益接近于零,代价照收。低延迟要求不高的内部系统、批处理作业,上 ZGC 属于给吞吐交钱买不需要的指标。
6. 大堆不是免费的:堆规模与暂停时间
经验关系:全堆 STW 型收集器的单次大停顿随堆规模增长;并发型收集器的停顿与堆规模弱相关,但内存放大与吞吐代价随堆增长。 堆扩大同时带来三件事:GC 一次要扫更多存活对象、Full GC 风险窗口更长、容器内存预算更紧。
判断内存问题必须先分清两件事:
| 现象 | 判据 | 处置方向 |
|---|---|---|
| 内存泄漏 | 老年代回收后的最低占用随长时间窗口单调上升 | 堆转储 + 支配树找持有者 |
| GC 压力大 | 老年代回收后能回落,但回收频繁、CPU 被 GC 吃掉 | 降低分配速率或调收集器参数 |
# 分清泄漏与压力:连续采样老年代回收后占用
jcmd<pid> GC.heap_info
jstat -gcutil <pid> 5s
只看"用了多少内存"永远分不清这两者,必须看回收后的基线走势。
7. 容器环境的三处陷阱
# 用容器感知的百分比写法,避免写死绝对值
java -XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=70.0 \
-XX:+UseContainerSupport -jar app.jar
# 核对容器实际限额与生效参数
cat /sys/fs/cgroup/memory.max # cgroup v2
jcmd <pid> VM.flags | tr ' ' '\n' | grep -Ei 'heap|gc|rampercent'
| 陷阱 | 表现 | 规避方式 |
|---|---|---|
-Xmx 与 cgroup 限额脱节 |
进程被 OOM Killer 杀掉,日志里看不到 OOM | 堆 + 元空间 + 线程栈 + 直接内存预留后仍低于限额 |
只写 MaxRAMPercentage 不写初始值 |
堆扩容时机不可控,早期频繁 GC | 起止百分比一起给 |
| CPU 配额影响 GC 线程数 | 高配额机器上参数合理,低配额机器上并行 GC 线程不足 | 显式给 ParallelGCThreads,别让它自动推 |
第三处最隐蔽:容器感知能把 GC 线程数按配额收敛,但配额在共享 CPU 下是弹性的,实际可用算力与配额并不总一致。
8. 选型判断链
① 测分配速率(MB/s)与对象大小分布
└─ 用 JFR 或 GC 日志推导,先有数再谈选型
② 定停顿目标(P99 STW 上限 + 是否影响主路径)
③ 定堆上限(容器限额 - 非堆开销,别按宿主机内存算)
④ 再选收集器:
停顿不是瓶颈 → Parallel(换吞吐)
数 GB 堆 + 停顿目标化 → G1
大堆 + 亚毫秒硬要求 → ZGC(按版本核对可用性)
内存极紧 + 小堆 → Serial
⑤ 用 GC 日志与 JFR 验证,保留回退参数
# 第三步与第五步的取证手段
jcmd <pid> JFR.start name=gc settings=profile duration=120s filename=/tmp/gc.jfr
jcmd <pid> JFR.dump name=gc filename=/tmp/gc.jfr
jfr summary /tmp/gc.jfr
JFR 里看 jdk.GCPhasePause、jdk.ObjectAllocationSample、jdk.GCHeapSummary。GC 日志说停顿有多长,JFR 说停顿是谁造成的。
9. 排错
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 停顿目标设了但没生效 | 目标软性、年轻代已被其他参数固定 | 检查是否显式设了 MaxNewSize / 固定堆,二者冲突 |
| 周期性出现数秒级停顿 | Full GC 或并发周期失败 | 看日志中 Pause Full 与 Concurrent 的先后关系 |
| 频繁 humongous 分配告警 | region 太小或存在大对象批量分配 | 调 G1HeapRegionSize 或改对象结构 |
| 换到低停顿收集器后吞吐下降 | 屏障开销与并发线程争抢 CPU | 用吞吐损失与停顿收益做对比,不能只看单项 |
| GC 线程数远少于预期 | 容器 CPU 配额低于宿主机核数 | 显式指定线程数并按配额调 |
| 内存一直涨但 GC 日志正常 | 堆外内存或直接内存问题 | 单独观测直接内存与线程栈,不属于 GC 范畴 |
10. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 堆只有数百 MB | 不上 ZGC / Shenandoah | 收益接近于零,代价照收 |
| 批处理、离线计算 | Parallel 或 Serial | 停顿不进业务主路径,要的是吞吐 |
| 内部系统、低延迟要求不高 | G1 默认即可 | 目标化停顿够用,别引入额外复杂度 |
| 对象创建速率已经超过回收能力 | 先改对象生命周期与分配模式 | 换收集器只会改变失败形态 |
| 存活集合持续增长 | 先修泄漏 | 收集器无法回收还活着的对象 |
| 需要在 JDK 大版本间迁移 | 按目标版本重新核对参数与默认收集器 | 默认收集器与参数语义随版本变 |
JVM 默认收集器不是跨版本常量:迁移前后用 jcmd VM.flags 核对实际生效值,别沿用旧文档的参数组合。
11. 自测
- 一个服务 P99 延迟抖动,如何用 GC 日志区分是混合回收还是 Full GC 造成的?
MaxGCPauseMillis设成很小的值时,收集器会做什么补偿,副作用是什么?- 老年代回收后占用曲线缓慢单调上升,这属于哪一类问题,下一步动什么?
- 容器里写死
-Xmx与用MaxRAMPercentage,各自的失效条件是什么? - 什么样的系统上 ZGC 的停顿收益接近于零?
- GC 日志显示停顿正常但内存仍上涨,接下来该查什么?