3043 字
约 10 分钟
16
S05-A03 · GC 选型决策:吞吐、延迟与堆规模的三角约束
2024-12-31
2026-11-09

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. 自测

  1. 一个服务 P99 延迟抖动,如何用 GC 日志区分是混合回收还是 Full GC 造成的?
  2. MaxGCPauseMillis 设成很小的值时,收集器会做什么补偿,副作用是什么?
  3. 老年代回收后占用曲线缓慢单调上升,这属于哪一类问题,下一步动什么?
  4. 容器里写死 -Xmx 与用 MaxRAMPercentage,各自的失效条件是什么?
  5. 什么样的系统上 ZGC 的停顿收益接近于零?
  6. GC 日志显示停顿正常但内存仍上涨,接下来该查什么?
S05-A03 · GC 选型决策:吞吐、延迟与堆规模的三角约束
https://www.clxhxhhr.top/posts/6056/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。