Skip to content

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、哪个收集器 ​

收集器现状堆的组织停顿特点
G1JDK 9 起的默认收集器等大的 Region,逻辑上分代按暂停目标选择回收哪些 Region
ZGCJDK 21 引入分代模式,JDK 23 起默认分代,JDK 24 移除了不分代模式Region 大小可变,分代绝大部分工作并发完成,停顿通常在 1ms 以内
Parallel仍然可用连续的年轻代和老年代停顿时间随堆和存活对象增长,吞吐优先
CMSJDK 14 已移除—不再作为比较对象

另外两条过时的说法:永久代在 JDK 8 已被元空间取代;System.gc() 只是一个请求,实际行为取决于收集器和 JVM 参数(见第六节)。

三、同一负载下的对比 ​

负载:堆固定 2GB,约 800MB 长期存活数据,按先后顺序持续替换其中最老的一部分(像滑动窗口或按时间淘汰的缓存),制造老年代垃圾;两个业务线程高速分配短命对象。另有一个探针线程每次睡 1ms,记录它实际多等了多久,用来衡量应用真正感受到的停顿。每种收集器在 4 核的容器里预热 3 秒、测量 20 秒,跑两轮。

收集器业务吞吐进程 CPU停顿次数停顿合计最长停顿探针 p99探针 p99.9
G13,341 万~3,600 万次/秒2.35~2.36 核283~3030.96~0.99 秒6.1~7.1 ms3.8~4.1 ms5.3~5.5 ms
分代 ZGC3,331 万~3,402 万次/秒2.37~2.39 核1,033~1,1019~10 ms0.03~0.06 ms0.57~0.58 ms0.62~0.63 ms
Parallel3,691 万~3,793 万次/秒2.10~2.11 核333~3431.39~1.48 秒66~69 ms3.1~3.2 ms3.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 的一个回收周期 ​

Young (Normal)51 次 · 最长 5msYoung (Concurrent Start)50 次 · 最长 5ms并发标记与应用线程并行Remark / Cleanup各约 50 次 · 最长 1.2msYoung (Prepare Mixed)51 次 · 最长 6msYoung (Mixed)年轻代 + 部分老年代 Region回到常规节奏Pause Full整堆压缩 · 最长 80msEvacuation Failure:没有空闲 Region 可复制JDK 25、2GB 堆、800MB 长期存活数据、20 秒:按先后顺序替换时 0 次 Full GC,随机替换时 12 次
图 1 · G1 的常规节奏是 Young 回收加并发标记,标记完成后用几次 Mixed 回收顺带清理老年代;只有当复制存活对象找不到空闲 Region 时,才退化成整堆压缩的 Full GC

从 GC 日志统计一轮 20 秒运行中各类停顿的次数:

停顿次数最长做什么
Pause Young (Normal)515.0 ms年轻代满了,复制存活对象
Pause Young (Concurrent Start)505.2 ms一次 Young 回收,顺带启动并发标记
Pause Remark501.2 ms结束标记
Pause Cleanup510.07 ms统计各 Region 的垃圾比例
Pause Young (Prepare Mixed)516.1 ms为接下来的 Mixed 回收做准备
Pause Young (Mixed)506.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。日志里前后几行是:

text
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 的退化:分配停顿 ​

G1:Full GC(800MB、随机替换)12 次 Pause Full,最长停顿 80ms所有应用线程一起停吞吐约 3,500 万 → 2,671 万次/秒(约 -25%)停顿指标里看得到ZGC:分配停顿(1,700MB、堆的 83%)停顿最长 0.05ms,但 777 次分配停顿,最长 9ms只有正在分配内存的线程被卡住吞吐约 3,370 万 → 3,023 万次/秒,CPU 3.2 核停顿指标里看不到监控 ZGC 时,除了停顿时间,还要看 GC 日志中的 Allocation Stall 或 JFR 事件 jdk.ZAllocationStall低停顿收集器用额外的内存和 CPU 换停顿,余量不够时优势就消失;G1 还要看存活数据怎样死亡
图 2 · 两种收集器在不同条件下退化:长期存活数据随机死亡时,G1 退化成全局停顿的 Full GC;堆余量不足时,ZGC 的停顿指标依然正常,但分配内存的线程被迫等待,这部分不会出现在停顿统计里

把长期存活数据提高到 1,700MB(堆的 83%),ZGC 的停顿指标没有变化:最长 0.05ms,合计 45ms。但 GC 日志里出现了 777 次:

text
Allocation Stall (worker-0) 0.486ms
Allocation Stall (worker-1) 0.722ms

ZGC 的回收是并发的,前提是回收速度跟得上分配速度。跟不上时,申请内存的线程只能等待回收释放出空间。这次运行中,分配停顿合计 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 ms32.8 ms
G1 + -XX:+ExplicitGCInvokesConcurrentPause Young (Concurrent Start) (System.gc())3 次,合计 9.8 ms76.4 ms
G1 + -XX:+DisableExplicitGC无00.0 ms
ZGCMajor Collection (System.gc())8 次,合计 0.1 ms108.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() 的四种行为(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权