G1 与 ZGC:低停顿的代价,以及两种收集器各自怎么退化
同一个负载下,ZGC 的最长停顿不到 0.1ms,G1 约 7ms,Parallel 约 67ms。但把长期存活数据提高到堆的 83% 之后,ZGC 的停顿指标仍然漂亮,业务线程却被「分配停顿」累计卡了 1.5 秒,而这些时间不会出现在任何停顿统计里;G1 则在存活数据随机死亡时退化出 12 次 Full GC。选收集器,要看它在什么条件下退化、退化成什么样。
本文在 JDK 25.0.4 上用同一段负载对比 G1、分代 ZGC 和 Parallel,并复现 G1 退化到 Full GC、ZGC 分配停顿,以及 System.gc() 在不同设置下的行为。堆大小与 GC 频率的关系、线上排查的步骤,见 JVM 调优与线上排查。
一、先说结论
- 先说清楚收集器和 JDK 版本。「Eden 满了触发 Young GC、老年代满了触发 Full GC」只是 Parallel 这类收集器的粗略描述,放到 G1 和 ZGC 上并不成立。
- G1 是默认选择:停顿可控、吞吐好、资源开销适中。实测最长停顿约 7ms,暂停目标
MaxGCPauseMillis是软目标,不是保证。 - ZGC 用更多内存和 CPU 换停顿:实测最长停顿不到 0.1ms、尾延迟最低,吞吐比 G1 低约 5%;余量紧张时 CPU 会明显升高。
- Full GC 在 G1 里是退化路径:常规节奏是 Young 回收、并发标记、Mixed 回收。实测同样的替换速率下,存活数据按先后顺序死亡时没有 Full GC,随机死亡时 20 秒退化出 12 次整堆压缩,最长 80ms。
- ZGC 也会退化,只是方式不同:堆余量不足时它不会长时间全局停顿,而是让分配内存的线程等待。监控 ZGC 必须看分配停顿,而不只是停顿时间。
二、先校准前提:哪个 JDK、哪个收集器
| 收集器 | 现状 | 堆的组织 | 停顿特点 |
|---|---|---|---|
| G1 | JDK 9 起的默认收集器 | 等大的 Region,逻辑上分代 | 按暂停目标选择回收哪些 Region |
| ZGC | JDK 21 引入分代模式,JDK 23 起默认分代,JDK 24 移除了不分代模式 | Region 大小可变,分代 | 绝大部分工作并发完成,停顿通常在 1ms 以内 |
| Parallel | 仍然可用 | 连续的年轻代和老年代 | 停顿时间随堆和存活对象增长,吞吐优先 |
| CMS | JDK 14 已移除 | — | 不再作为比较对象 |
另外两条过时的说法:永久代在 JDK 8 已被元空间取代;System.gc() 只是一个请求,实际行为取决于收集器和 JVM 参数(见第六节)。
三、同一负载下的对比
负载:堆固定 2GB,约 800MB 长期存活数据,按先后顺序持续替换其中最老的一部分(像滑动窗口或按时间淘汰的缓存),制造老年代垃圾;两个业务线程高速分配短命对象。另有一个探针线程每次睡 1ms,记录它实际多等了多久,用来衡量应用真正感受到的停顿。每种收集器在 4 核的容器里预热 3 秒、测量 20 秒,跑两轮。
| 收集器 | 业务吞吐 | 进程 CPU | 停顿次数 | 停顿合计 | 最长停顿 | 探针 p99 | 探针 p99.9 |
|---|---|---|---|---|---|---|---|
| G1 | 3,341 万~3,600 万次/秒 | 2.35~2.36 核 | 283~303 | 0.96~0.99 秒 | 6.1~7.1 ms | 3.8~4.1 ms | 5.3~5.5 ms |
| 分代 ZGC | 3,331 万~3,402 万次/秒 | 2.37~2.39 核 | 1,033~1,101 | 9~10 ms | 0.03~0.06 ms | 0.57~0.58 ms | 0.62~0.63 ms |
| Parallel | 3,691 万~3,793 万次/秒 | 2.10~2.11 核 | 333~343 | 1.39~1.48 秒 | 66~69 ms | 3.1~3.2 ms | 3.8~3.9 ms |
几点观察:
- ZGC 的停顿是另一个数量级:一千多次停顿合计约 10ms,单次都不到 0.1ms,探针的 p99.9 只有 0.6ms。代价是吞吐比 G1 低约 5%,因为标记、重定位这些工作都在和应用线程抢 CPU。
- Parallel 吞吐最高、CPU 最省,但最长停顿约 67ms:老年代满了就做一次全程停顿的 Full GC,20 秒 7 次。适合批处理这类只关心总耗时的任务。
- G1 在中间:停顿合计比 Parallel 少,单次停顿控制在 10ms 以内,没有 Full GC。
统计停顿时要注意:G1 的 Remark 和 Cleanup 停顿记在名为 G1 Concurrent GC 的 GarbageCollectorMXBean 下(实测 114 次,不在 G1 Young Generation 里);ZGC 的 ZGC Minor Cycles、ZGC Major Cycles 统计的是并发周期的时长,应用在这段时间里照常运行,真正的停顿在 ZGC Minor Pauses、ZGC Major Pauses 下。实测一次运行里 ZGC Minor Cycles 累计 7,577ms,ZGC Minor Pauses 只有 8ms。直接把所有 MXBean 的耗时加起来,会得到完全错误的结论。
四、G1 的一个回收周期
从 GC 日志统计一轮 20 秒运行中各类停顿的次数:
| 停顿 | 次数 | 最长 | 做什么 |
|---|---|---|---|
Pause Young (Normal) | 51 | 5.0 ms | 年轻代满了,复制存活对象 |
Pause Young (Concurrent Start) | 50 | 5.2 ms | 一次 Young 回收,顺带启动并发标记 |
Pause Remark | 50 | 1.2 ms | 结束标记 |
Pause Cleanup | 51 | 0.07 ms | 统计各 Region 的垃圾比例 |
Pause Young (Prepare Mixed) | 51 | 6.1 ms | 为接下来的 Mixed 回收做准备 |
Pause Young (Mixed) | 50 | 6.0 ms | 年轻代加上一部分垃圾最多的老年代 Region |
其中 58 次 Young 回收的日志带着 Evacuation Failure: Allocation:复制时一度找不到空闲 Region。这次它们都没有退化成 Full GC,但这是堆余量偏紧的信号。
堆占用达到阈值(InitiatingHeapOccupancyPercent,G1 会自适应调整)时,G1 在一次 Young 回收中启动并发标记。标记和应用线程并行,结束后,G1 按照暂停目标,每次挑一部分回收收益最高的老年代 Region,放进 Mixed 回收里一起处理。它从不一次扫完整个老年代,这是它能控制停顿的原因。
4.1 什么时候退化成 Full GC
同样 800MB 长期存活数据、同样的替换速率,只把「按先后顺序替换」改成「随机替换任意一块」,20 秒内出现了 12 次 Full GC。日志里前后几行是:
Pause Young (Mixed) (G1 Evacuation Pause) (Evacuation Failure: Allocation) 1942M->1978M(2048M) 18.704ms
Pause Young (Mixed) (G1 Evacuation Pause) (Evacuation Failure: Allocation) 2042M->2045M(2048M) 10.011ms
Pause Full (G1 Compaction Pause) 2045M->790M(2048M) 77.972ms过程是:并发标记和 Mixed 回收清理老年代的速度,没跟上垃圾产生的速度,堆被占满。复制存活对象时找不到空闲 Region(Evacuation Failure),G1 只能停下所有线程,对整个堆做一次压缩。这一轮最长停顿 80ms,吞吐从约 3,500 万次/秒降到 2,671 万次/秒,探针 p99.9 从 5ms 涨到 21ms。同样的随机替换,ZGC 没有 Full GC,也没有分配停顿。
为什么死亡方式影响这么大,只能推断:按先后顺序替换时,最老的一批对象一起死掉,整个 Region 很快变成垃圾,回收几乎不用复制;随机替换时每个老年代 Region 都只死一小部分,回收一个 Region 要复制大量仍然存活的对象,速度跟不上。实验没有直接测量复制量,这一点没有证明。作为对照,把存活数据提高到 1,700MB(堆的 83%)、仍按顺序替换,G1 没有出现 Full GC。
常见的退化原因:
| 原因 | 日志里的迹象 | 方向 |
|---|---|---|
| 堆余量不足,回收跟不上分配 | Evacuation Failure、Pause Full (G1 Compaction Pause) | 加堆,或降低存活数据量 |
| 大对象(超过 Region 一半) | G1 Humongous Allocation | 减少大数组,或调大 G1HeapRegionSize |
显式调用 System.gc() | Pause Full (System.gc()) | 见第六节 |
| 元空间耗尽 | Metadata GC Threshold | 排查类加载泄漏 |
| 内存泄漏 | Full GC 之后占用仍然持续上涨 | 堆转储分析,加堆只会推迟事故 |
看到 Full GC,先从日志里找到它的原因,而不是去调暂停目标。
五、ZGC 的退化:分配停顿
把长期存活数据提高到 1,700MB(堆的 83%),ZGC 的停顿指标没有变化:最长 0.05ms,合计 45ms。但 GC 日志里出现了 777 次:
Allocation Stall (worker-0) 0.486ms
Allocation Stall (worker-1) 0.722msZGC 的回收是并发的,前提是回收速度跟得上分配速度。跟不上时,申请内存的线程只能等待回收释放出空间。这次运行中,分配停顿合计 1.5 秒,中位数 1.5ms,最长 9.0ms。业务吞吐从约 3,370 万次/秒降到 3,023 万次/秒,CPU 从 2.4 核升到 3.2 核。
分配停顿只影响正在分配内存的线程,所以探针线程(几乎不分配)的延迟依然很低。但真实的请求处理线程都在分配对象,它们感受到的尾延迟会明显上升。只看停顿时间监控 ZGC,会完全错过这类问题。需要同时看:
- GC 日志中的
Allocation Stall; - JFR 事件
jdk.ZAllocationStall; - 进程 CPU 使用率:ZGC 的并发线程和业务线程共享 CPU,CPU 本来就紧张的服务,更容易出现回收跟不上的情况。
六、System.gc() 到底做了什么
一个有约 400MB 存活数据的进程调用一次 System.gc():
| 设置 | GC 日志 | 全局停顿 | 调用返回耗时 |
|---|---|---|---|
| G1 默认 | Pause Full (System.gc()) | 1 次,32.6 ms | 32.8 ms |
G1 + -XX:+ExplicitGCInvokesConcurrent | Pause Young (Concurrent Start) (System.gc()) | 3 次,合计 9.8 ms | 76.4 ms |
G1 + -XX:+DisableExplicitGC | 无 | 0 | 0.0 ms |
| ZGC | Major Collection (System.gc()) | 8 次,合计 0.1 ms | 108.5 ms |
G1 默认会做一次 Full GC。ExplicitGCInvokesConcurrent 把它变成一个并发周期:其他线程总共只停了约 10ms(一次 Young 回收加上 Remark 和 Cleanup),但调用 System.gc() 的线程要一直等到周期结束。DisableExplicitGC 则直接忽略调用。
不建议全局打开 DisableExplicitGC:有些库依赖 System.gc() 回收直接内存(DirectByteBuffer),禁用后可能导致直接内存溢出。更稳妥的做法是 ExplicitGCInvokesConcurrent,同时找出是谁在调用它。
七、怎么选
| 场景 | 选择 | 需要关注 |
|---|---|---|
| 大多数在线服务 | G1(默认) | Mixed 回收是否及时、有没有 Full GC、大对象 |
| 尾延迟要求很严格,或堆很大(几十 GB 以上) | 分代 ZGC | 留足堆余量和 CPU,监控分配停顿 |
| 批处理、离线计算,只关心总耗时 | Parallel | 能接受较长的单次停顿 |
选择要用同一份业务流量压测,比较吞吐、p99 与 p99.9、CPU、堆余量,以及在高负载下的退化方式。只比较一次最短的停顿没有意义。
八、常见误区
- 「老年代满了就触发 Full GC」:在 G1 里,老年代主要靠并发标记加 Mixed 回收清理,Full GC 是退化路径。
- 「
MaxGCPauseMillis设成 50 就不会超过 50ms」:它是软目标,G1 会尽力调整年轻代大小,但不保证达到。 - 「ZGC 没有停顿,所以不会影响延迟」:余量不足时,分配停顿会直接卡住业务线程。
- 「把 GC MXBean 的耗时加起来就是停顿时间」:ZGC 的
Cycles是并发时长,G1 的 Remark 和 Cleanup 在G1 Concurrent GC下。 - 「
System.gc()一定会触发 Full GC」:取决于收集器和参数,ZGC 下是一次并发的 Major 回收。
小结
G1、ZGC 和 Parallel 不是「谁更先进」的关系,而是在停顿、吞吐、CPU 和内存之间做了不同的取舍。低停顿收集器依赖额外的 CPU 和堆余量来和应用并行工作,余量不够时,G1 退化成 Full GC,ZGC 退化成分配停顿。评估收集器时,要把负载推到接近真实峰值,观察它怎么退化,并确认监控能看到这种退化。
配套实验
- codesphere-labs/java/gc-collectors:同一负载下 G1、分代 ZGC、Parallel 的对比,存活数据的替换方式与堆余量对 G1、ZGC 退化的影响,
GarbageCollectorMXBean的统计口径,System.gc()的四种行为(验证记录)
参考资料