6.2 GC停顿与收集器选型


文档摘要

6.2 GC 停顿与收集器选型 本节摘要:P99 毛刺的元凶常常是 GC 长停顿。本节从一次接口毛刺复盘讲起,学会读 GC 日志判断停顿根因(哪种 GC、多长、为什么),理清分代收集器与 G1/ZGC 的设计取舍,掌握对象分配层面的"少产垃圾"优化,避免"调参玄学"。 事故现场:每 40 秒一次的 P99 尖刺 支付回调接口的 P99 曲线上每隔约 40 秒出现一次尖刺,从 80ms 跳到 1.2s。机器 CPU、下游依赖全部正常。打开 GC 日志( ),规律一目了然: Young GC 只有 23ms 无伤大雅,Full GC 950ms 才是尖刺本尊——STW(stop-the-world)期间所有请求冻结,落在窗口内的请求 P99 就被抬上去。

6.2 GC 停顿与收集器选型

本节摘要:P99 毛刺的元凶常常是 GC 长停顿。本节从一次接口毛刺复盘讲起,学会读 GC 日志判断停顿根因(哪种 GC、多长、为什么),理清分代收集器与 G1/ZGC 的设计取舍,掌握对象分配层面的"少产垃圾"优化,避免"调参玄学"。

事故现场:每 40 秒一次的 P99 尖刺

支付回调接口的 P99 曲线上每隔约 40 秒出现一次尖刺,从 80ms 跳到 1.2s。机器 CPU、下游依赖全部正常。打开 GC 日志(-Xlog:gc*),规律一目了然:

[gc] GC(412) Pause Young (Normal) 2G->260M(4G) 23ms [gc] GC(413) Pause Full (Ergonomics) 3G->800M(4G) 950ms

Young GC 只有 23ms 无伤大雅,Full GC 950ms 才是尖刺本尊——STW(stop-the-world)期间所有请求冻结,落在窗口内的请求 P99 就被抬上去。触发原因是 Ergonomics:老年代占用逼近阈值,JVM 自动发起 Full GC。为什么老年代涨这么快?进一步看对象晋升:回调处理里每请求创建约 2MB 的临时报表对象(第 4 章的字符串拼接同款问题),Young 区放不下直接晋升老年代(大对象规则),死对象全堆在老年代等 Full GC 收尸。

这个案例说明 GC 调优的典型层次:表象(尖刺)→ 直接原因(Full GC 长)→ 结构原因(垃圾分配模式)。只对着收集器参数使劲,第三层不解决,参数怎么调都是按下葫芦浮起瓢。

读 GC 日志:三个必看字段

  • 类型与频率:Young / Mixed / Full 各自的间隔,Full GC 频率是头号健康指标
  • 停顿时长:Pause 后的毫秒数,与 P99 尖刺对时间轴
  • 前后堆量2G->260M 表示回收效果;回收后如果每次都比上次高(谷底抬升),就是第 2/6.1 章讲过的泄漏曲线

JDK 8 用 -XX:+PrintGCDetails,JDK 9+ 统一为 -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m。日志之外,jstat -gcutil pid 1000 一秒一个快照,看各代占用与 GC 次数的时间趋势,适合没有历史日志的现场。

收集器演化:三代设计哲学

分代收集器(Serial / Parallel / CMS,JDK 8 默认 Parallel):按分代假说组织(第 6.1 节),Parallel 吞吐优先、STW 长而稀疏,适合批处理;CMS 以并发标记换短停顿,但标记-清除留碎片,碎片满了一样退化成 Full GC(还被 JDK 14 正式移除)。

G1(JDK 9+ 默认):把堆切成等大的 Region(1-32MB),不再物理隔离新老年代,而是逻辑标记哪些 Region 扮演什么角色。回收按收益排序收集 Region(Garbage First 名字的由来),并且尊重用户给的停顿目标 -XX:MaxGCPauseMillis=200——G1 会估算每个 Region 回收要多久,优先挑"垃圾占比高、回收快"的做。大对象(超过 Region 一半)走专门的 Humongous 区域,大对象多时 G1 表现劣化,这解释了为什么"换成 G1 反而更慢"的案例十有八九是分配了海量几 MB 的临时对象。

ZGC / Shenandoah(JDK 15+ / 21 转正):着色指针 + 读屏障实现并发整理,停顿与堆大小解耦,亚毫秒到几毫秒。代价是吞吐损 5-15%、需要更多内存做转发。适用:堆超大(几十 GB 以上)、延迟极度敏感(交易、实时风控)。

收集器演化时间线与停顿量级

收集器演化时间线与停顿量级

优化的正确顺序

GC 优化容易滑向玄学,守住顺序就稳:

  1. 少产垃圾(最优先):第 4 章的循环拼接、不必要的对象拷贝、日志拼串,都会变成 GC 的账单。分配速率(MB/s)降一半,GC 频率就降一半,任何参数都换不来这个效果
  2. 让垃圾死得年轻:对象在 Young 区死掉成本最低,活到老年代就是 Full GC 的种子。避免"大对象 + 短命"的组合(大对象直接进老年代)
  3. 调结构参数:堆大小、新生代比例(-Xmn 或 G1 让 JVM 自适应)、G1 的 Region 大小与停顿目标
  4. 换收集器:前三级做完再动。JDK 17 服务默认 G1 已是稳妥起点,延迟敏感再评估 ZGC

回到事故案例的修复:报表对象改为流式生成(单次请求内存 2MB→200KB),Young 区装得下、就地回收,晋升消失,Full GC 从 40 秒一次降到基本不出现——没动一个 JVM 参数

⚠️ 常见坑:把 -XX:MaxGCPauseMillis 设成 10ms 以为立竿见影。停顿目标收紧的代价是每次回收的 Region 更少、频率更高、吞吐下降,且目标本身低于收集器能力时 JVM 只能尽力而为——数字不等于承诺。

💡 关键直觉:GC 调优调的是"垃圾的产生与死亡节奏",不是玄学参数。看懂分配速率与晋升曲线,比背十个参数组合有用。

防坑清单

  • 生产常开 GC 日志滚动输出,P99 尖刺先对时间轴找 GC 事件
  • 监控看四条线:Young/Full 频率、停顿时长、回收后谷底、分配速率
  • 大而短命的对象是公敌,流式与分页消灭它们
  • 换收集器前先做分配优化与参数复盘,G1 大对象场景要查 Humongous
  • 停顿目标理解成"预算"不是"保证"

本节要点回顾

  • 读日志三看:类型频率、停顿时长、回收后谷底趋势
  • 三代哲学:分代吞吐 → Region 化可控停顿 → 并发整理停顿解耦
  • G1 要点:Region + 收益排序 + 停顿预算,大对象是软肋
  • 优化顺序:少产垃圾 > 死得年轻 > 参数 > 换收集器
  • 事故修复:多数 GC 问题的正解在应用代码,不在 JVM 参数

下一节进入类加载的世界:同一个类被加载两次会怎样。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U