垃圾回收的核心矛盾只有一个:吞吐量 vs 停顿时间。你既想让 GC 少占用 CPU(高吞吐),又想让每次 STW(Stop-The-World)停顿尽可能短(低延迟),但这两者天然冲突。从 Parallel GC 到 G1 再到 ZGC,本质上是 JVM 在这条曲线上不断把"停顿"压低的过程。本文拆解 G1 和 ZGC 的核心机制,以及它们各自适合什么场景。

前置:为什么需要分代和可达性分析

判断对象死活,JVM 用的是可达性分析:从一组 GC Roots(栈上引用、静态变量、JNI 引用等)出发遍历引用链,遍历不到的对象就是垃圾。引用计数法解决不了循环引用,所以主流 JVM 都不用它。

分代假设——“绝大多数对象朝生夕灭,熬过几次 GC 的对象会活很久”——让我们可以把堆分成新生代和老年代,对新生代频繁做小范围回收(Minor GC),对老年代偶尔做大范围回收。这是 G1 之前所有收集器的设计基石。

G1:化整为零的 Region 模型

G1(Garbage First)的革命在于不再有物理上连续的新生代/老年代,而是把堆切成数千个大小相等的 Region(典型 1MB~32MB)。每个 Region 在逻辑上动态扮演 Eden、Survivor、Old 或 Humongous(存放超过半个 Region 的大对象)中的某种角色。

1
[E][O][S][E][H][O][E][O][S][E] ...   每格是一个 Region,角色可变

核心机制有三个:

  1. 可预测停顿模型。G1 维护每个 Region 的"回收价值"(能回收多少垃圾 vs 要花多少时间)。每次回收时,在用户设定的停顿目标 -XX:MaxGCPauseMillis(默认 200ms)内,优先回收价值最高的 Region——这就是 “Garbage First” 的字面意思。它不追求一次清空整代,而是挑性价比最高的部分回收。

  2. RSet(Remembered Set)与跨代引用。要单独回收某个 Region,必须知道"谁引用了我"。G1 给每个 Region 配一个 RSet,记录指向本 Region 的跨 Region 引用。这样回收时不用扫描整堆。代价是 RSet 本身要占用相当可观的内存(可能达堆的百分之几到百分之十几),且维护 RSet 需要写屏障(Write Barrier)——每次引用赋值都插入额外代码更新 RSet。

  3. SATB(Snapshot-At-The-Beginning)。并发标记阶段用户线程还在改引用,SATB 通过写屏障保证"标记开始那一刻存活的对象"不会被漏标,从而让并发标记可行。

G1 的回收分两类:Young GC(回收 Eden + Survivor,STW)和 Mixed GC(回收全部 Young + 部分 Old Region)。注意 G1 仍有一次性的 STW,只是被控制在目标范围内。

关键参数:

1
2
3
4
5
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:InitiatingHeapOccupancyPercent=45 \
-Xmx8g MyApp
# IHOP=45 表示老年代占堆 45% 时启动并发标记周期

ZGC:把停顿压到亚毫秒的染色指针 + 读屏障

G1 把停顿控制在百毫秒级,但 GC 的几个关键阶段(尤其是对象转移/重定位)仍需要 STW,堆越大停顿越难压。ZGC 的目标更激进:无论堆多大(TB 级),停顿都在毫秒甚至亚毫秒级,且停顿时间不随堆增大而增长。它靠两件武器:

1. 染色指针(Colored Pointers)。ZGC 把元数据直接编码进 64 位对象指针的高位若干位(如 Marked0、Marked1、Remapped、Finalizable 等标志)。也就是说,对象的 GC 状态不存在对象头里,而存在指向它的指针里。这让 ZGC 能在指针层面快速判断对象是否已被标记、是否已被重定位。

2. 读屏障(Load Barrier)。与 G1 的写屏障相对,ZGC 在读取对象引用时插入屏障代码。当一个线程读到一个"指向旧地址"的引用时,读屏障会检查染色位,如果发现对象已被搬走,就当场把引用修正到新地址(self-healing),并保证后续读到的都是新地址。这套机制让对象转移可以并发进行,用户线程几乎不用停下来等 GC 搬完所有对象。

正因为标记和重定位都能并发,ZGC 的 STW 只剩下几个极短的"根扫描"瞬间,停顿不再正比于堆大小或存活对象数量。

1
2
# 现代 JVM 中 ZGC 已转正,推荐开启分代模式
java -XX:+UseZGC -XX:+ZGenerational -Xmx16g MyApp

工程权衡:怎么选

维度 G1 ZGC
停顿目标 可配,百毫秒级 亚毫秒~毫秒级
停顿是否随堆增长 基本不随
吞吐量 略低于 G1(屏障开销 + 并发占 CPU)
内存开销 RSet 占用 染色指针 + 转发表
适用堆 几 GB ~ 几十 GB 大堆、超大堆

简单说:追求吞吐、堆中等、能容忍百毫秒停顿 → G1(也是当前多数应用的默认)。对延迟极度敏感、堆很大(比如几十 GB 到 TB) → ZGC。如果你跑的是离线批处理、不在乎单次停顿只在乎总耗时,Parallel GC 反而吞吐更高。

常见误区

  • “ZGC 没有 STW”:错,ZGC 仍有极短 STW,只是不随堆增长。"无停顿"是营销话术。
  • “G1 一定比 Parallel 快”:不一定。G1 为低延迟付出了写屏障和 RSet 维护的吞吐代价,纯吞吐场景 Parallel 可能更优。
  • “调小 MaxGCPauseMillis 就一定停顿短”:设得过激会逼 G1 把每次回收的 Region 数压到很少,导致回收频率飙升、Mixed GC 跟不上,最终触发 Full GC 反而灾难。这是个目标值而非硬保证。
  • “Humongous 对象无所谓”:G1 中超过半个 Region 的大对象直接进 Old,且占用连续 Region,频繁产生大对象会加剧碎片和 Full GC 风险。

小结

GC 的演进是一条沿"吞吐-延迟"曲线不断压低停顿的路线。G1 用 Region + 可预测停顿模型 + 写屏障/RSet,把停顿控制在百毫秒并支持精细化部分回收;ZGC 用染色指针 + 读屏障让标记和重定位并发化,把停顿压到与堆无关的亚毫秒级。没有银弹,选型的本质是问自己:这个系统更怕慢一点点,还是更怕偶尔卡一下。