面向 P6-P7+ 工程师 问题推导型 · 长文 约 9,000 字 信息截止 2026-08

高并发场景下 GC 停顿优化实战:
大厂低延迟服务的 GC 选型策略

这篇文章不是"JVM 参数大全",而是给已经在生产环境被 GC 停顿折磨过的工程师,一套完整的推导框架: 停顿从哪里来、G1/ZGC/Shenandoah 各自的技术边界在哪里、 什么场景下换 ZGC 是正确的、什么场景下换了反而更糟,以及如何用可观测数据驱动选型决策而非凭直觉。

主线风格:问题推导 45% + 体系架构 35% + 框架 20% 版本假设:JDK 17 / 21 LTS;G1 / ZGC(含分代 ZGC)/ Shenandoah 证据等级:官方文档优先;场景数字标注为合成示例;标注推断
问题现场 · 复合场景

1停顿的代价:当 GC 遇上低延迟 SLA

复合场景 · 综合多个高并发服务调优案例的典型对话,非指代单一具体事件 COMPOSITE SCENARIO

大促前夕,某电商平台的风控服务 P99 延迟突然从稳定的 18ms 飙升到接近 600ms, 但 CPU 利用率不高,数据库连接池没有告警,线程数正常。值班工程师的第一反应是 "是不是依赖服务抖动",排查了一圈上游,问题不在那里。

最终打开 GC 日志,才看到真正的凶手:每隔 8–15 秒,一次 G1 Mixed GC 的 STW 停顿 高达 320–480ms,而且发生频率还在随流量上升而增加。这个停顿时间远超 -XX:MaxGCPauseMillis=200 设定的目标,但 JVM 并没有报错—— 它只是在日志里悄悄记录了"GC Pause Goal Exceeded"。

更棘手的是,同时还存在第二个问题:切换到 ZGC 后停顿确实降下去了, 但整体吞吐量下降了约 12%,服务的平均响应时间反而从 6ms 上升到了 9ms—— 这让团队陷入困惑:是 ZGC 本身有问题,还是他们的用法有问题?

这两个问题叠加在一起,正好覆盖了 GC 选型中最核心的矛盾: 低延迟与高吞吐是对立的工程目标,没有一个收集器可以同时最优化两者。 本文从这个矛盾出发,逐层推导每种收集器的技术边界。

上述场景折射出一个普遍困惑:工程师往往把 "GC 调优"理解为调参, 但实际上调参只是最后一步——在此之前,必须先回答三个问题: 停顿时间究竟从哪里来?目标收集器能否从机制上消灭这类停顿? 以及在你的工作负载下,这个收集器的代价是否可以接受? 许多团队在没有回答这三个问题的情况下盲目切换 GC 算法, 结果是解决了一个问题、引入了另一个问题,甚至让情况更糟。 本文试图构建一套可以回答这三个问题的推导框架,而不是提供一个"直接用这个参数"的配方。

版本说明:本文所有讨论基于 JDK 17 LTS 和 JDK 21 LTS。 JDK 17 中 G1 是默认 GC,ZGC 和 Shenandoah 均已生产可用; JDK 21 引入了分代 ZGC(Generational ZGC,实验特性,需显式开启), 是 ZGC 演进路线上的重要里程碑,在内存开销和吞吐量两个维度都有显著改进。 CMS 在 JDK 14 已移除,本文不再讨论。

已核验事实

JDK 官方文档将 GC 目标定义为三个互相制约的维度: 最大停顿时间(Max Pause Time)吞吐量(Throughput,应用线程占总运行时间的比例)堆占用(Footprint)。这三者构成一个不可逃脱的三角约束—— 任何收集器设计都只能在三角内取权衡点,无法三者同时最优化。 ZGC 以降低约 5–15% 吞吐量(取决于写屏障/读屏障开销和工作负载特征) 换取亚毫秒级停顿,是一个典型的权衡选择,而非"免费午餐"。 来源:JDK 17 GC Tuning Guide, Oracle

理解这个三角,是所有后续判断的前提。接下来我们先从停顿本身入手, 弄清楚 JVM 在执行 GC 时,哪些操作必须停世界(Stop-The-World),哪些可以并发, 以及为什么即使是"并发"收集器也不能完全消灭 STW。

问题推导 · 根因拆解

2GC 停顿的解剖:三条根因链路

很多工程师把 GC 停顿简单理解为"在做垃圾回收",但实际上停顿的来源远比这复杂。 要优化停顿,必须先把停顿拆分到足够细的粒度,因为不同来源的停顿 对应完全不同的优化手段

2.1 安全点同步:被低估的隐性停顿

JVM 在执行 STW 操作之前,必须等所有正在运行的应用线程到达一个 安全点(Safe Point)。安全点是字节码执行流中 JIT 编译器预先插入的检查位, 线程在此处响应 JVM 的停止请求。整个等待过程称为到达安全点的时间(TTSP, Time To Safe Point)

TTSP 是一个极容易被忽略的停顿来源。在 JDK 早期版本中,它完全不出现在 GC 日志里; 即使在现代 JDK 中,也需要显式打开 -Xlog:safepoint 才能看到。 在某些场景下,TTSP 会占整个"GC 停顿"时间的 50% 以上: 一个正在执行长计数循环(loop without safe point)或 JNI 调用的线程, 可以阻塞整个 JVM 数百毫秒等待它到达安全点, 而这段时间在普通 GC 日志中表现为"GC 停顿时间很长,但 GC 自身工作量很小"的矛盾现象。

2.2 并发标记中断:Remark 与 Final Mark

现代低停顿收集器的核心思路是让大量 GC 工作与应用线程并发进行, 但并发标记存在一个根本性的问题:应用线程在标记过程中会修改对象引用关系, 这可能导致标记遗漏(漏标)。为了保证正确性,所有并发收集器都必须在某个时刻 暂停所有应用线程,重新处理并发期间发生的引用变更——这就是 G1 的 Remark 阶段(使用 SATB 算法)和 ZGC/Shenandoah 的 Pause Mark End 阶段。

这类停顿的时长与并发期间引用变更的速度(即写操作的密集程度)密切相关。 写操作越密集、并发标记阶段越长,需要重处理的增量越大,Remark 停顿越长。 这也是为什么写密集型工作负载(如内存数据库、日志聚合服务) 比读密集型工作负载更难控制 GC 停顿。

2.3 疏散停顿:对象必须在 STW 内移动

回收内存的本质是移动对象(压缩堆碎片)并更新所有指向这些对象的引用。 G1 的疏散(Evacuation)阶段是完全 STW 的:在停顿期间,GC 线程将 Collection Set 中的存活对象复制到新区域,并更新所有引用。 这个阶段的停顿时长由两个因素决定:存活对象的体积需要扫描的 Remembered Set 的规模

ZGC 和 Shenandoah 的革命性创新正在于此——它们实现了 并发疏散(Concurrent Relocation), 允许对象在应用线程继续运行的同时被移动。代价是需要一种机制 让应用线程在访问被移动的对象时能透明地找到新地址—— ZGC 用读屏障(Load Barrier),Shenandoah 用 Brooks 转发指针。 这两种机制会给每次对象读操作增加一个小的固定开销, 这正是它们吞吐量低于 G1 的主要来源之一。

图 1 · G1 / ZGC / Shenandoah 停顿时间轴对比
自制示意图 · 非基准测试数据
一次完整 GC 周期内的时间分布(示意,非线性比例) STW 停顿 并发阶段(应用继续运行) 清理 / 引用处理 G1 GC (Young + Mixed) Initial Mark 并发标记 Concurrent Mark Remark (SATB) Clean up 并发清理 STW Evacuation 疏散(主要停顿来源) 典型 GC 周期时长(50ms–几秒不等,取决于堆大小与存活对象) ZGC (JDK 17+) P1 <1ms 并发标记 + 引用处理 Concurrent Mark P2 <1ms 并发重定位准备 Conc Relocate Prep P3 <1ms 并发重定位(核心创新) Concurrent Relocation Shenandoah (JDK 17+) Init Mark 并发标记 Concurrent Mark Final Mark 并发疏散(Brooks 指针) Concurrent Evacuation Final Upd 并发更新 引用 P1/P2/P3 = ZGC 三次极短 STW;Shenandoah 疏散阶段为并发(紫色),引入 Brooks Pointer 转发开销 G1 的 Evacuation(红色大块)是主要停顿来源,ZGC/Shenandoah 将其转移到并发阶段 核心差异:G1 的 STW 块宽度正比于"存活对象体积 × Remembered Set 大小";ZGC/Shenandoah 的 STW 只扫描 GC Roots,与堆大小近似无关 这解释了为什么 ZGC 在超大堆(100GB+)下停顿优势远大于小堆(4GB 以下)场景
读图方式:横轴代表时间流逝(非线性比例),红色块为 STW 停顿,绿色/紫色块为并发阶段(应用继续运行)。 G1 的核心停顿来自右侧大红块(Evacuation),其时长随 Collection Set 规模线性增长; ZGC 将三次停顿(P1/P2/P3)控制在毫秒以内,并发重定位时应用线程通过读屏障透明访问被移动对象; Shenandoah 的疏散也是并发的(紫色),但代价是 Brooks Pointer 的写操作额外开销。

理解了这三条根因链路,就能理解为什么针对不同停顿来源需要不同的应对策略: 对于 TTSP 引起的停顿,换收集器没用,应该检查 JIT 编译行为和长循环; 对于 Remark 停顿,应该关注并发期间的写操作密度,考虑降低对象变更速率; 对于疏散停顿,G1 的调优思路是"减小 Collection Set、控制 To-space 充裕度", 而 ZGC 和 Shenandoah 的思路是"让疏散本身并发化,彻底绕开这个问题"。 三条链路对应三种完全不同的优化方向,混淆它们是很多调优尝试失败的根本原因。

体系架构 · G1 收集器

3G1:区域化分代与 Mixed GC 的失败窗口

G1(Garbage First)自 JDK 9 成为默认收集器,其核心设计思路是 把堆拆分成大小相等的 Region,然后优先回收垃圾最多的 Region—— "Garbage First"名字由此而来。与传统分代收集器(固定新生代/老年代边界)不同, G1 的 Region 可以在逻辑上动态扮演 Eden、Survivor、Old 或 Humongous(巨型对象)等角色。 这个设计的优势是:GC 可以在每次停顿内选择一个合适规模的 Region 子集(Collection Set), 从而更可预测地控制停顿时长。

图 2 · G1 堆内存区域模型与收集阶段
自制示意图
JVM 堆(逻辑划分为等大 Region,每个 Region 1MB–32MB) E Eden E Eden E Eden S Survivor O Old O Old O Old H · H Humongous(大对象,>0.5×Region) F Free F Free E Eden S Survivor O Old O Old O★ 高垃圾率候选 O Old E Eden S Survivor F Free H · H Humongous Remembered Set (RSet) 记录跨 Region 的引用,GC 时扫描 Young GC(STW):回收所有 Eden + Survivor 存活对象晋升到 Survivor 或 Old Region 触发频率高,每次停顿受 GC 线程数和存活量控制 Mixed GC(STW):Young + 部分 Old 在老年代超过 IHOP 后触发并发标记 优先选择垃圾比例最高的 Old Region 回收 Full GC 危险区 — Evacuation Failure 当 To-space 耗尽导致疏散失败时,G1 退化为单线程或并行 Full GC,停顿可达数秒——这是 G1 的核心风险窗口 Eden Survivor Old Humongous Free 高垃圾率候选(优先入 CSet)
读图方式:堆被划分为等大的 Region(逻辑角色可动态变化)。Young GC 回收所有 Eden/Survivor(左下蓝框), Mixed GC 在此基础上增加若干垃圾率高的 Old Region(右下紫框)。 Humongous 对象占用连续 Region,无法被 Mixed GC 高效回收,是 G1 调优的常见痛点。 Full GC 危险区(红色虚线)表示疏散失败的退化路径——应通过调整 IHOP 和 Mixed GC 频率预防。

3.1 三级 GC 与触发条件

G1 存在三个层级的 GC,它们的触发条件和停顿特征截然不同,工程师必须分清楚:

GC 类型触发条件STW 特征主要停顿来源
Young GC Eden Region 用满 完全 STW,通常几十至数百 ms 存活对象疏散体积 + RSet 扫描
Mixed GC 并发标记完成后,老年代占用超过 G1MixedGCLiveThresholdPercent(默认 85%)的 Region 被选入 STW,通常比 Young GC 稍长 Old Region 的 RSet 扫描代价更高
Full GC 疏散失败(Evacuation Failure)或 Humongous 分配失败 完全 STW,单线程或并行,停顿可达数秒 整堆压缩,与堆大小正比

3.2 IHOP 与并发标记周期

G1 通过初始堆占用百分比(IHOP,Initiating Heap Occupancy Percent) 来决定何时启动并发标记周期。默认值为 45%,意味着当老年代占用超过堆的 45% 时, JVM 开始并发标记以评估哪些 Old Region 可以纳入下一轮 Mixed GC。 JDK 9 以后 G1 会自动调整 IHOP(Adaptive IHOP),但在对象分配速率变化大的场景下, 自动调整可能滞后,导致 Mixed GC 频率不足,老年代快速膨胀,最终触发 Full GC。 一个典型的错误配置模式是:将 -XX:MaxGCPauseMillis 设置得过于激进(比如 20ms), 导致 G1 每次只能回收极少量 Old Region,老年代净增速超过回收速度, 一段时间后老年代膨胀至无法通过 Mixed GC 消化,退化为 Full GC。 这种现象的诊断特征是:短时间内大量 Mixed GC 后跟一次极长停顿的 Full GC。

-XX:G1HeapRegionSize 是另一个影响停顿的关键参数。Region 越大, Humongous 阈值(0.5×Region)越高,但每个 Region 的 RSet 条目也可能更多。 对于堆小于 8GB 的服务,通常不需要手动设置;超大堆场景推荐从 8MB 或 16MB 开始试验。

常见陷阱 · G1

误将 MaxGCPauseMillis 当成 SLA 保证。 这个参数是 G1 的目标而非上限—— 当可用的 Free Region 不足以完成疏散时,G1 无法满足目标, 日志中出现 GC pause (G1 Evacuation Pause) ... GC pause goal exceeded, 实际停顿可能是目标值的 3–10 倍。更危险的是,这类超标不会抛异常、不会告警, 只能在 GC 日志中看到,线上往往被其他告警掩盖。 正确的保护机制是:监控 jvm_gc_pause_seconds_max 指标, 超过 SLA 阈值的 2 倍就触发告警,而不是等到服务超时才发现。

3.3 G1 与 ZGC 的失败窗口:机制层面的根本差异

所谓"失败窗口",是指收集器在正常工作边界之外被迫退化的条件。 理解两者的失败窗口,比理解它们的正常工作流程更重要—— 因为线上事故几乎都发生在失败窗口附近。

G1 的失败窗口由两个相互叠加的压力触发:其一是老年代回收速度跟不上晋升速度。 Young GC 每次都会将 Eden 存活对象晋升到 Survivor 或 Old Region, 而 Mixed GC 的节奏受到并发标记周期的约束——并发标记必须先完成,才能评估哪些 Old Region 值得回收。 当流量突发导致对象晋升速率急剧上升,而 Mixed GC 节奏来不及跟上时, 老年代填满、Free Region 耗尽,疏散失败。 其二是 Remembered Set(RSet)的规模反噬:RSet 记录所有从 Old Region 到 Eden/Survivor 的跨代引用, 混合收集时必须完整扫描入选 Old Region 的 RSet。 当老年代对象间引用密度高时(例如大型缓存服务),RSet 条目数可达数千万, 单次 Mixed GC 的扫描时间随老年代规模线性增长,形成恶性循环: 停顿时间超过目标 → G1 减小下次 Collection Set → 老年代净回收量下降 → 下次压力更大。

ZGC 的失败窗口则完全不同,它的名称叫做Allocation Stall(分配停滞)。 由于 ZGC 的所有回收工作都是并发进行的,当应用线程的对象分配速率持续超过 GC 线程的并发回收速率时, 可用内存耗尽,应用线程被迫挂起等待 GC 腾出空间——这等价于一次不受控的 STW。 触发条件通常是:堆使用率长时间维持在 75% 以上, 或者流量突发(比如定时任务触发大量对象创建)超出了 GC 线程的追赶能力。 与 G1 不同,ZGC 不存在"RSet 越来越大"这类渐进式劣化,但在 Allocation Stall 发生的瞬间, 整个 JVM 的停顿时间可以与 G1 Full GC 相当,而且在日志中表现为 Allocation Stall,而非常规 GC 停顿,容易被监控遗漏。

两种失败窗口的应对策略差异同样根本:G1 的应对是"提前启动并发标记、增加 Reserve", 即尽量在失败窗口到来之前完成老年代压缩;ZGC 的应对是"扩大堆或增加并发 GC 线程", 即从源头扩大吞吐裕量。误用对方的应对手段——比如在 ZGC 上调 IHOP、 或在 G1 上增加 ConcGCThreads——对解决问题没有帮助,有时反而增加 CPU 开销。

体系架构 · ZGC

4ZGC:染色指针与并发重定位的代价模型

ZGC(Z Garbage Collector)自 JDK 15 进入生产可用状态,其设计目标非常明确: 在任意堆大小下(从几百 MB 到数 TB),将 STW 停顿时间控制在 10ms 以内。 实践中,在堆大小 4GB–100GB 范围内,ZGC 的 STW 停顿通常在 1–5ms 区间, 远优于 G1 在同等堆大小下的 50–500ms 停顿。

4.1 染色指针:让指针携带 GC 元数据

ZGC 最核心的创新是染色指针(Colored Pointers)技术。 在 64 位地址空间中,实际的物理地址只需要 48 位(或更少), ZGC 利用地址中的高位比特来存储 GC 元数据标志位(在 JDK 17 中使用 Marked0、Marked1、Remapped、Finalizable 四个标志位)。 当 GC 线程移动一个对象时,它不需要立刻更新所有指向该对象的引用—— 只需要在页表级别重新映射虚拟地址,并把引用中的标志位标记为"已重定位"。 下次应用线程访问这个"过期"引用时,读屏障(Load Barrier) 检测到标志位状态,触发修复(Barrier Slow Path),找到对象的新地址并更新引用。

这个机制的关键含义是:ZGC 的 STW 阶段只需要扫描 GC Roots(线程栈、静态变量、JNI 全局引用等), 不需要扫描整个堆或 Remembered Set。GC Roots 的数量通常在数万到数十万的数量级, 与堆大小无关,这就是为什么 ZGC 的停顿时间对堆大小几乎不敏感的根本原因。

已核验事实 · ZGC 停顿上界

ZGC 的三次 STW 停顿(Pause Mark Start、Pause Mark End、Pause Relocate Start) 的时长与当前 GC Roots 数量和应用线程数近似线性, 与堆大小或活跃对象数量无关。根据 JDK 官方文档及多个公开的性能测试报告(包括 SPECjbb 2015 及各厂商的白皮书), 在 16 核以上的现代服务器上,ZGC 的单次 STW 停顿通常低于 5ms, 在 GC Roots 规模不超常(活跃线程少于 500 条)时通常低于 1ms。 注意:这是 STW 停顿部分,不包括并发阶段的 CPU 开销。 来源:JDK 17 ZGC Documentation, Oracle

4.2 读屏障的代价

读屏障不是免费的。每次从堆中读取对象引用时,JIT 编译器生成的代码都需要 检查引用的颜色标志位,并在必要时走 Slow Path 修复引用。 在引用读操作频繁的工作负载(如遍历大型对象图、频繁访问集合元素)中, 读屏障的开销会积累为可观的 CPU 成本,表现为整体吞吐量下降 5–15%(合成基准数字, 实际场景差异很大,取决于读/写比例和对象图特征)。

这正是前面场景中,切换到 ZGC 后吞吐量下降 12%、平均延迟略微升高的原因—— 不是 ZGC 有 bug,而是这个服务的工作负载特征使读屏障开销比较显著。 对于读多写少、对象生命周期长的工作负载,ZGC 的读屏障代价更高; 对于对象分配速率高、短命对象多的服务,ZGC 的优势更明显。

4.3 分代 ZGC(JDK 21 实验特性)

非分代 ZGC 的主要缺点是:它不区分新生代和老年代,每次 GC 周期都需要处理整个堆, 在分配速率高的场景下内存回收效率较低,需要配置较大的预留内存(通常需要多配置 50%–100% 的堆内存以避免 OutOfMemory)。JDK 21 引入的 分代 ZGC(-XX:+UseZGC -XX:+ZGenerational 通过引入新生代(Young Generation)和老年代(Old Generation)的概念, 让 ZGC 能更频繁地回收短命对象,在相同堆大小下显著提高内存利用率, 同时维持亚毫秒停顿的核心特性。

需要注意:分代 ZGC 在 JDK 21 中是实验性(Experimental)特性, 不建议在没有充分测试的情况下直接上生产。在 JDK 23+ 中其状态预计变为正式支持; 本文建议在 JDK 21 环境下进行充分的压测和 GC 日志分析后再决策。

ZGC 优势场景

  • P99/P999 延迟有硬性约束(如实时竞价、支付风控)
  • 堆大于 8GB,G1 的 Mixed GC 停顿已无法接受
  • 对象分配速率高,短命对象居多(受益于高吞吐并发标记)
  • 延迟一致性比极致吞吐更重要

ZGC 不适合的场景

  • 堆小于 4GB 且吞吐量优先(G1 在此场景下 GC 开销比例更低)
  • 内存极度受限,无法承受额外 10–20% 的内存预留
  • 读操作极为密集的对象图遍历场景(读屏障积累开销大)
  • 使用大量 JNI 的场景(GC Roots 扫描时间增加)
体系架构 · Shenandoah

5Shenandoah:Brooks 指针与并发压缩的另一条路径

Shenandoah GC 由 Red Hat 贡献给 OpenJDK,自 JDK 12 进入实验阶段,JDK 15 正式进入 OpenJDK 主线, Oracle JDK 中默认不包含(Oracle JDK 不发布 Shenandoah)。 它的设计目标与 ZGC 接近——亚毫秒级停顿——但采用了完全不同的技术路径。

5.1 Brooks 转发指针

Shenandoah 通过Brooks 指针实现并发疏散。每个对象头前有一个额外的 转发指针(Forwarding Pointer)字段,通常指向对象自身(正常状态), 当对象被 GC 移动后,原地址的转发指针指向新地址。 应用线程和 GC 线程之间通过比较-交换(CAS)原语来协调对转发指针的修改。

与 ZGC 的读屏障不同,Shenandoah 使用写屏障(JDK 13 之前的 IU 模式还有读屏障) 来维护对象图的一致性。写屏障在每次写操作时检查目标地址是否被移动, 如果是则通过转发指针找到新地址再写入。每个对象额外消耗 8 字节(一个指针大小)的空间。

5.2 IU 模式(JDK 13+)

从 JDK 13 开始,Shenandoah 引入了 -XX:ShenandoahGCMode=iu(Incremental Update 模式, 现为默认),相比早期的 SATB 模式减少了读屏障,进一步降低了吞吐量损失。 IU 模式下 Shenandoah 的吞吐量损失与 ZGC 相近,但停顿时间略高于 ZGC, 因为 Final Mark(确定存活集合)阶段的工作量稍多。

5.3 与 ZGC 的主要差异

维度ShenandoahZGC(非分代)ZGC(分代,JDK 21)
并发疏散机制Brooks 转发指针(堆内)染色指针 + 读屏障(指针颜色)同 ZGC
内存额外开销每对象 +8 字节(Brooks 指针)无对象级额外字段无对象级额外字段
读屏障IU 模式基本消除全量读屏障全量读屏障
Oracle JDK 包含否(仅 OpenJDK)是(JDK 21+)
超大堆(>100GB)有测试报告,不如 ZGC 稳定设计目标场景改进了内存利用率
典型停顿(合成参考)1–10ms0.5–5ms0.5–3ms(新生代 GC 更快)

停顿数字为综合公开测试报告的合成参考值,非基准测试结论,实际值因工作负载、堆大小、硬件差异显著。

推断性结论(来自架构分析,非实测数据)

从技术设计角度推断:在使用 Oracle JDK 的企业场景中, ZGC 是低延迟选型的首选候选(Oracle JDK 不发布 Shenandoah); 在坚持使用 OpenJDK、且对象体积较小(Brooks 指针 +8 字节的比例影响较小)的场景中, Shenandoah 是值得测试的备选。两者的实际性能差异在多数服务端工作负载中 不会显著——选型更关键的决策点是: 团队是否有能力解读和调试各自 GC 日志格式,以及生产环境的 JDK 版本策略, 而非理论性能数字。

验证层 · 场景对照

6七种高并发场景的 GC 选型矩阵

生产注意 · 场景对照表免责声明

下表中的"推荐 GC"和"停顿参考"不是基准测试结论,也不是官方推荐。 它们来源于对 GC 机制的工程推导,以及对多个公开分享案例(包括各厂商技术博客、JVM 峰会演讲) 的综合归纳,性能数字为合成示例。不同服务的对象分配模式、存活率、读写比例可能导致 与下表判断完全不同的结论。任何选型决策都应当在目标工作负载下进行压测验证, 并通过 GC 日志分析(而非凭直觉)做最终判断。

场景 典型特征 推荐起点 关键理由 常见误选
实时风控 / 竞价 P99 < 20ms 硬约束;堆 4–16GB;对象分配速率中等 ZGC(JDK 17+) STW 停顿 <5ms,满足 SLA;堆大小适中,读屏障开销可控 G1 在 Mixed GC 时可能超过 P99 目标
API 网关 / 高吞吐代理 QPS 高;对象生命周期极短;P99 要求宽松(<100ms);堆 2–4GB G1(默认参数调优) 短命对象多,G1 Young GC 高效;堆小场景 ZGC 读屏障相对开销更大 盲目切 ZGC,吞吐量下降 10%+ 但停顿改善不明显
订单/库存服务 读写混合;堆 6–12GB;业务高峰有突发流量;有长生命周期对象 G1(调优 IHOP)或 ZGC 压测对比 G1 通过调整 IHOP 可控制 Mixed GC 频率;若 P99 超标再考虑 ZGC 未调 IHOP 就下结论"G1 不行";或切 ZGC 后不测吞吐
大数据计算节点 批量处理;堆 64GB+;吞吐优先;延迟容忍度高 G1(-XX:+UseG1GC 吞吐优先场景,G1 整体 CPU 开销低于 ZGC;Full GC 间隔长可接受 对计算作业用 ZGC,牺牲 CPU 换取不必要的低停顿
实时流处理(Flink/Storm 算子) 状态对象大;分配速率可预测;P99 有约束;堆 8–32GB ZGC 或 Shenandoah(OpenJDK) 大堆 + 低停顿组合,ZGC/Shenandoah 对堆大小不敏感的停顿特性有价值 G1 在大堆场景的 Remark 停顿可能影响 watermark 精度
微服务 Sidecar / Agent 内存极度受限(<512MB);轻量级工作负载 G1 或 Serial GC ZGC/Shenandoah 在小堆下额外内存开销比例高,得不偿失 小堆场景盲目用 ZGC,额外内存占用触发 OOM
搜索服务(倒排索引内存映射) 堆中有大量长生命周期 byte[];读操作密集;堆 16–64GB ZGC(需验证读屏障影响) 大堆低停顿组合是 ZGC 强项;但 byte[] 密集读场景读屏障开销需实测 未测试直接切换,因读屏障开销导致吞吐下降超预期
框架训练 · 决策树

7GC 选型决策树:四个问题定位起点

决策树不代替压测,但它能帮助你快速排除明显不合适的选项, 把精力集中到真正值得实测的候选上。以下决策树基于上文的机制分析, 用四个关键问题完成初步过滤:

图 3 · GC 选型决策树(从工作负载特征到候选收集器)
自制示意图 · 结论需压测验证
START 分析服务 GC 停顿与业务需求 Q1: 堆大小 < 4GB ? G1 优先 ZGC 开销比 例高于收益 Q2: 延迟 SLA P99 < 50ms ? ZGC 首选 压测验证吞吐 是否可接受 Q3: 吞吐优先 吞吐量 > 95% ? G1 调优 调 IHOP 和 Region Size Q4: JDK 版本 使用 Oracle JDK ? ZGC 平衡延迟与 吞吐 ZGC 或 Shenandoah OpenJDK 可压测两者,按实测结果选 JDK 21 优先测试分代 ZGC 注:所有决策分支都应通过 生产级压测最终确认; 此树仅为初步过滤工具
读图方式:从顶部 START 开始,依次回答四个菱形问题节点(黄色边框)。 左侧绿色箭头("是")通向 G1 优化路径;右侧和向下路径通向低延迟收集器。 堆大小是第一个过滤器——小堆下 ZGC/Shenandoah 的相对开销通常高于收益; P99 SLA 是第二个过滤器——有硬性延迟约束时 ZGC 几乎是唯一选择。 决策树输出的是"应该重点压测的候选",不是最终结论。
验证层 · 调优实战

8调优实战:从 GC 日志到参数体系

GC 调优的正确顺序是:先观测,再分析,最后调参。 跳过观测直接改参数是导致调优失败的最常见原因—— 因为不同的停顿来源需要完全不同的应对手段,而这些来源在不打开足够日志的情况下是不可见的。

8.1 必开的 JVM 日志参数

JDK 9+ 的统一日志系统(-Xlog)取代了老的 -XX:+PrintGCDetails 等参数体系。 生产环境推荐的最小日志配置:

日志标签用途开销
-Xlog:gc*:file=gc.log:time,uptime,pid:filecount=5,filesize=50m详细 GC 日志(含阶段分解)滚动写文件低,推荐生产开启
-Xlog:safepoint:file=safepoint.log:time,uptime安全点停顿日志,排查 TTSP 问题的关键
-Xlog:gc+heap=debug每次 GC 后的堆区域详细分布(G1 调优必备)中,日志量较大
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/OOM 自动 dump,事后分析仅在 OOM 时触发

8.2 G1 核心调优参数体系

G1 的参数调优有一个优先级顺序:首先控制停顿目标 → 然后调整 IHOP 和 Mixed GC 频率 → 最后处理 Humongous 分配。三者顺序颠倒会导致治标不治本。

参数默认值调优方向
-XX:MaxGCPauseMillis 200ms 低延迟服务降到 50–100ms;GC 会通过减小 Collection Set 来尽量满足目标,但不保证上限
-XX:InitiatingHeapOccupancyPercent 45%(自适应) 若频繁出现 Full GC,降低 IHOP(如 35–40%)使并发标记更早开始;写密集场景可进一步降低
-XX:G1HeapRegionSize 自动(1–32MB) 堆大于 64GB 时手动设置 16MB 或 32MB;Humongous 对象大小超过 0.5×RegionSize 时也需调整
-XX:G1MixedGCCountTarget 8 增大该值使单次 Mixed GC 回收的 Old Region 更少(每次停顿更短),代价是需要更多轮次完成老年代回收
-XX:G1ReservePercent 10% 出现 Evacuation Failure 时增大(15–20%),确保 To-space 充足;代价是可用堆减少
-XX:ParallelGCThreads 约 CPU 核数的 5/8 STW 阶段 GC 线程数;容器环境需根据 cgroup CPU 配额设置,防止超调

8.3 ZGC 核心参数

ZGC 的设计理念是"尽可能不需要手动调参",大多数场景下只需要正确设置堆大小相关参数。

参数说明
-XX:+UseZGC 启用 ZGC(JDK 17 及以后版本)
-XX:+ZGenerational(JDK 21+) 启用分代 ZGC(实验特性),配合 -XX:+UseZGC 使用
-Xmx / -Xms ZGC 不推荐使用固定堆;建议 -Xms 设为 -Xmx 的 50–70%,为并发 GC 留出操作空间
-XX:SoftMaxHeapSize ZGC 软性堆上限,GC 会尽量控制在此以内;可设为 -Xmx 的 90%,预留弹性
-XX:ConcGCThreads 并发 GC 线程数;默认值通常合理,仅在 CPU 资源严重受限时需要降低

8.4 GC 日志的三个关键诊断信号

拿到 GC 日志后,按以下优先级检查三类信号,能快速定位 80% 以上的停顿异常:

  • 信号 1:Evacuation Failure(G1) — 日志中出现 to-space exhaustedEvacuation Failure,意味着 Free Region 耗尽,G1 被迫停止疏散并升级为 Full GC。 短期应急:增大 G1ReservePercent;根治:降低 IHOP,增加堆大小,或减少 Humongous 分配。
  • 信号 2:Safepoint 停顿异常safepoint.logApplication timeTotal time for which application threads were stopped 的差值远大于实际 GC 工作时间,说明 TTSP 是主要停顿来源。排查方向: 长计数循环(JVM flag -XX:+UseCountedLoopSafepoints 在 JDK 11+ 已默认开启)、 JNI 调用、Object.wait() 前的偏向锁撤销。
  • 信号 3:ZGC 并发失败(Allocation Stall) — ZGC 日志中出现 Allocation Stall,表示 GC 速度跟不上分配速率,应用线程被迫等待 GC 完成。 这通常意味着堆太小,或 ConcGCThreads 不足,需要扩大堆或增加并发线程。
生产经验 · GC 调优优先级

在实际调优中,影响最大的往往不是 GC 算法选择,而是对象分配行为的优化: 减少大对象(Humongous)分配、避免在热路径中创建大量短命字符串和集合、 合理使用对象池(注意对象池在并发场景下的锁争用风险)。 这类改造通常能将 GC 停顿降低 30–60%,而换 GC 算法往往只能改善 20–30%, 且前者不引入新的代价(如读屏障开销),从投入产出比来看应该优先执行。 来源:基于多个公开性能调优案例的归纳,非对照实验结论。

8.5 复合场景数字对照:一次典型的低延迟服务调优路径

以下数字为合成示例(非真实单一案例,基于多个调优实践的典型值综合构造), 仅用于说明调优路径上各阶段的量级变化关系,不代表任何特定服务的实测结论。 场景假设:某金融类 API 服务,堆 12GB,JDK 17,G1 默认配置,QPS 约 8000。

指标 G1 默认配置 G1 调参后(降低 IHOP + 调 Reserve) 切换 ZGC 后
P99 延迟 约 380ms(Mixed GC 驱动) 约 120ms(Mixed GC 变短) 约 22ms(STW <3ms)
P999 延迟 约 1.2s(Full GC 偶发) 约 280ms 约 45ms
Mixed GC P99 停顿 约 320ms 约 90ms 不适用(ZGC 无 Mixed GC)
GC CPU 占用(均值) 约 4% 约 5%(并发标记更频繁) 约 11%(读屏障 + 并发 GC 线程)
服务吞吐量(相对值) 100% 约 99% 约 89%(读屏障开销在该服务较显著)
Full GC 频率 约 1 次/2 小时 未出现(2 周观测期) 未出现

以上数据为合成示例,仅展示典型量级关系,非任何真实服务的基准测试结论。ZGC 吞吐损失在该假设场景较大,是因为该场景有大量对象读操作;不同业务特征下数字可能完全不同。

这个对照揭示了一个反直觉的结论:G1 调参后的 P999 延迟(280ms)虽然仍不理想, 但吞吐量几乎无损;而切换 ZGC 后虽然 P999 大幅改善,但吞吐下降 11% 在高 QPS 场景下 意味着需要额外扩容约 12% 的实例来维持同等处理能力,带来可观的成本增量。 这个权衡的正确答案取决于业务对 P999 延迟的实际 SLA 要求和可接受的成本预算—— 两者都合理,但必须是有意识的选择而非未经量化的直觉决定。

问题推导 · 反模式

9五个让 GC 选型出错的认知陷阱

GC 选型领域存在若干根深蒂固的误判模式,在架构评审和调优讨论中反复出现。 逐一拆解这些陷阱,比介绍参数本身更有实际价值。

陷阱一:把"默认参数下的基准测试"当作生产参考

大多数网络上流传的"ZGC vs G1 对比测试"使用的是 SPECjbb 或 Renaissance 等合成基准, 或者是配置好的实验环境——这些场景的对象分配模式、引用密度、读写比例 与真实业务代码差异巨大。更重要的是,基准测试通常不反映内存压力下的行为( 堆接近满时 GC 行为会显著变化)。在做选型决策前,务必用生产流量回放或真实压测, 在接近生产的堆配置和流量模式下运行至少 30 分钟以上再得出结论。

陷阱二:堆越大越好

增大堆可以减少 GC 频率,这在直觉上是正确的——但超过某个拐点后, 对于 G1 而言,更大的堆意味着更大的 Remembered Set 规模和更长的 Mixed GC 停顿; 对于 ZGC 而言,更大的堆意味着并发标记和重定位的工作量更大, 虽然不影响 STW 时间,但会增加并发 GC 的 CPU 持续占用,进而影响整体吞吐。 合理的堆大小通常是在 GC 频率不造成明显吞吐损失的前提下尽量小, 而非无限增大。

陷阱三:GC 停顿 = 服务延迟

GC 停顿只是服务延迟来源之一。在很多实际案例中,将服务从 G1 切换到 ZGC 后, P99 延迟的改善幅度远小于 GC 停顿的改善幅度——因为 P99 延迟还包含 线程调度延迟、TCP 缓冲区、数据库查询、外部 HTTP 调用等非 GC 因素。 如果 GC 停顿占 P99 延迟的比例低于 20%,换 GC 算法对 P99 的改善将非常有限, 优化性价比不高。量化 GC 停顿在总延迟中的占比,是做选型决策前的必要前置分析。

陷阱四:忽略 NUMA 架构对 GC 的影响

在高核数 NUMA(Non-Uniform Memory Access)服务器上,如果 JVM 没有开启 NUMA 感知 (-XX:+UseNUMA),GC 线程在跨 NUMA 节点扫描 Region 时会产生大量远端内存访问, 导致 GC 停顿时间大幅上升。这在 32 核以上的单机部署场景中尤为明显, 但容器化部署(通常绑核)和 cgroup CPU 限制环境下的影响可能较小。

陷阱五:把 ZGC/Shenandoah 视为"免维护"

低停顿收集器并不意味着不需要运维关注。ZGC 的 Allocation Stall、 Shenandoah 的 Degenerated GC 都是并发 GC 退化的信号, 一旦出现意味着 GC 速度已经无法追上分配速率,如果不及时处理(增大堆或降低分配速率), 最终同样会触发 Full GC 或 OOM。低停顿收集器只是将"正常情况下的停顿"降低了, 并没有消除"极端情况下的停顿"。需要在 APM/Prometheus 中专门为这些退化信号配置告警。

常见陷阱 · 生产切换风险

在不了解工作负载特征的情况下,直接在生产环境切换 GC 算法是高风险操作。 即使压测结论良好,生产流量的访问模式往往比压测更复杂(长尾请求、慢 SQL 阶段、定时任务突发)。 推荐的切换路径:灰度 5–10% 流量 → 观察 2–3 天的 P99 趋势和 GC 日志 → 确认无退化后扩大至 50% → 再观察大促或业务高峰期 → 全量切换。 全量切换后保留快速回退机制(JVM 参数可通过配置中心动态下发),避免无法回退的局面。

沉淀 · 检查表与延伸阅读

10GC 选型沉淀:决策检查表与参考资料

10.1 GC 选型与上线前检查表

检查项验收标准责任环节
确认停顿来源 已开启 -Xlog:gc*-Xlog:safepoint,通过日志区分 GC 停顿与 TTSP 停顿 选型前分析
GC 停顿占比评估 量化 GC 停顿在 P99 延迟中的占比;若低于 20% 则优先优化对象分配而非换 GC 选型前分析
堆大小合理性 GC 频率不超过 1 次/10s(参考值),且堆占用稳定不持续增长(无内存泄漏) 压测验证
Humongous 对象排查 确认无大对象(>0.5×G1RegionSize)在热路径中频繁分配;可通过 GC 日志 Humongous 标签识别 代码审查 + GC 日志
压测验证(同配置) 在与生产相同的 JDK 版本、堆大小、CPU 配额下,用生产级流量模型压测 >30min 性能测试
ZGC/Shenandoah 退化告警 配置 Allocation Stall(ZGC)或 Degenerated GC(Shenandoah)告警 监控配置
Full GC 告警 任意 GC 算法下,Full GC 出现即触发告警;G1 下 Evacuation Failure 单独告警 监控配置
容器 CPU 配额与 GC 线程 确认 ParallelGCThreadsConcGCThreads 未超出容器 CPU 配额(防止 CPU Throttling 放大 GC 停顿) 部署配置
灰度切换路径 准备好快速回退机制(JVM 参数模板、镜像分离或动态配置下发);灰度比例从 5% 起 发布流程
NUMA 感知(高核服务器) 32 核以上单机部署,确认是否开启 -XX:+UseNUMA;容器绑核场景可忽略 部署配置
对象分配热点分析 通过 async-profiler(-e alloc 模式)识别 Top 5 分配热点;确认 Humongous 分配不在业务热路径中;分配量超过 1GB/s 的热点须评估是否可池化或延迟初始化 选型前优化 / 代码审查
切换后吞吐基线验收 GC 算法切换后,在等量流量下 TPS 与切换前偏差须在团队预算范围内(建议提前约定可接受的吞吐损失上限,如 ≤10%);偏差超标须分析是读屏障、并发 GC CPU 还是其他原因导致 压测验证 / 上线评审
Mixed GC / ZGC 停顿趋势监控 上线后连续观察 48 小时,确认 GC 停顿 P99 无渐进式上升趋势(上升趋势通常意味着老年代回收速率跟不上晋升速率,是 Full GC 或 Allocation Stall 的前兆);Prometheus 中配置停顿 P99 环比告警 发布后监控

10.2 三个收集器的核心权衡一览

维度G1(默认)ZGCShenandoah
STW 停顿上限(参考)几十至几百 ms(受堆大小影响)<10ms(与堆大小近似无关)<10ms(与堆大小近似无关)
吞吐损失最低(无并发读写屏障)5–15%(读屏障)5–10%(写屏障为主)
内存额外开销Remembered Set(约 5–20%)需额外 10–20% 预留每对象 +8 字节
超大堆适应性差(停顿随堆增长)强(停顿与堆无关)强(停顿与堆近似无关)
Oracle JDK 支持否(仅 OpenJDK)
调优复杂度中(IHOP/Region Size 等参数)低(堆大小为主)
成熟度最高(JDK 9+ 默认)高(JDK 15 生产可用)中(Oracle JDK 不含)

停顿、吞吐数字均为综合参考值,非基准测试结论,实际取决于工作负载、堆大小和硬件。

10.3 延伸阅读与参考资料

  1. BOOK 周志明,《深入理解 Java 虚拟机(第3版)》,机械工业出版社,2019 —— 第3章(垃圾收集器与内存分配策略)是理解 G1/ZGC 机制的中文最佳起点; CMS 相关章节在 JDK 14+ 已过时,但对理解停顿根因仍有价值
  2. DOC JDK 17 Garbage Collection Tuning Guide —— Oracle 官方,涵盖 G1/ZGC/Parallel/Serial 的参数详解与适用场景说明
  3. DOC JDK 21 Garbage Collection Tuning Guide —— 新增分代 ZGC 相关章节,适合计划升级 JDK 21 的团队参考
  4. DOC JEP 439: Generational ZGC —— 分代 ZGC 的设计动机、架构变化与已知限制;JDK 21 进入实验阶段的原始提案
  5. DOC Shenandoah GC OpenJDK Wiki —— Shenandoah 官方文档,包含 IU 模式说明、参数指南和已知问题跟踪
  6. PAPER Tene, G., Iyengar, B., Wolf, M. (2011). "C4: The Continuously Concurrent Compacting Collector." ACM SIGPLAN Notices, 46(11), 79–88 —— 理解无停顿 GC 的理论边界,对于深入理解 ZGC/Shenandoah 的并发疏散机制有重要参考价值
  7. BLOG Per Lidén (ZGC 主要作者), inside.java: ZGC — The Z Garbage Collector —— ZGC 设计者对核心机制的权威解释,是理解染色指针和读屏障的第一手资料