xpbob.site

输入关键词开始检索

    ↑↓ 选择↵ 打开esc 关闭检索标题、正文与标签

    都叫“分代”,ZGC 和 G1 根本不是一回事:实测停顿差 1300 倍

    先给你看一组反直觉的实测数据:同样跑一套基准,分代 ZGC 的 GC 停顿次数是 G1 的 6 倍,但平均停顿只有 G1 的 1/1300,最大停顿只有 G1 的 1/2500。 停顿次数多了 6 倍,停顿反而短了 1300 倍?这怎么可能。答案藏在一句话里——你以为“分代”是同一个意思,其实 ZGC 的“分代”和 G1 的“分代”,从头到尾就是两码事:G1 分代是为了“缩短 STW”,ZGC 分代是为了“降低并发回收的成本”——少占 CPU、少 Stall、省内存,连提升吞吐也是通过“少占 CPU”间接实现的,不是靠缩短停顿。


    一、一个反直觉的现象

    先看两组实测(4 核 / 7.4G 内存云主机,JDK 25,Renaissance 基准,6 个 workload,分别用 2G 堆和 5G 堆跑):

    2G 堆:

    指标G1分代 ZGC
    GC 停顿次数789 次4634 次
    平均停顿12.13 ms0.009 ms
    最大停顿76.1 ms0.03 ms

    5G 堆:

    指标G1分代 ZGC
    GC 停顿次数450 次2487 次
    平均停顿14.38 ms0.008 ms
    最大停顿116.8 ms0.03 ms

    两个点特别炸:

    1. 分代 ZGC 停顿次数是 G1 的 6 倍,但平均停顿只有 G1 的 1/1300,最大停顿只有 G1 的 1/2500。
    2. 堆从 2G 涨到 5G,G1 最大停顿从 76ms 涨到 117ms(涨 53%),分代 ZGC 最大停顿保持 0.03ms 纹丝不动——ZGC 的停顿真的不随堆增大。

    为什么停顿次数更多、反而停顿更短?因为分代 ZGC 把一次“几十毫秒的大停顿”,拆成了“成千上万次零点零几毫秒的小停顿”——它的年轻代回收,绝大部分工作在并发完成,真正的“暂停应用”只在几个同步点发生,每次不到 0.01ms。

    而这就是分代 ZGC 和其他分代 GC 最本质的区别。下面从头讲。


    二、最大的误区:你以为“分代”是一个意思

    很多人看到“分代 ZGC”,会想当然地认为:哦,ZGC 也跟 G1 一样,把堆分成年轻代、老年代,年轻代频繁收呗。

    恰恰相反。 两者分代的动机就完全不同:

    • G1 分代,是为了缩短 STW。 G1 的年轻代回收本身是要暂停应用的(Stop The World,简称 STW),但年轻代小,暂停着收集它很快,所以停顿短。换句话说,G1 是在“STW 不可避免”的前提下,靠“把堆切成小块、只暂停着收其中一小块”来把停顿压下来。

    • 分代 ZGC 分代,是为了降低并发回收的成本。 ZGC 的停顿本来就亚毫秒了,它根本不缺停顿。它分代要解决的是三个并列的一阶目标(JEP 439 原文):提升吞吐、降低 Allocation Stall(分配停顿)、降低内存占用。年轻代对象“朝生暮死”,并发收年轻代能快速腾空间(降 Stall)、少扫老年代那些不会死的对象(降 CPU 占用 → 提升吞吐)、不需要那么大的堆 headroom(降内存占用)。

    一句话:G1 分代是为了“缩短 STW”,ZGC 分代是为了“降低并发回收的成本”——减少 Stall、省 CPU 从而提升吞吐、省内存。 动机不同,导致下面三个实现维度全都不同。


    三、核心区别:四个维度,处处不同

    区别 1:物理分代 vs 逻辑分代

    G1 是物理分代。 堆被物理划分成一个个 Region,一部分标记为 Young(又分 Eden/Survivor),一部分标记为 Old。对象晋升老年代,是物理拷贝到 Old Region 去。

    分代 ZGC 是逻辑分代。 JEP 439 原文用的词是 “logical generations”(逻辑分代)。堆物理上还是统一的一片 ZPage 内存,年轻代和老年代不是物理隔离的区域,而是靠对象年龄(age)和元数据来区分。对象晋升老年代,本质是“年龄到了、逻辑上划为老年代”,不一定伴随物理搬迁。

    一句话:G1 的年轻代是“一块物理区域”,ZGC 的年轻代是“一个逻辑概念”。

    区别 2:STW 回收 vs 并发回收(最关键)

    这是两者最本质的区别。

    G1 的年轻代回收是 STW 的。 Young GC 会暂停所有应用线程,把存活对象从 Eden 拷贝到 Survivor/Old。日志长这样:

    GC(1) Pause Young (Normal) (G1 Evacuation Pause) 296M->57M(2048M) 33.945ms

    一次 33.9ms 的完整 STW,应用线程全程卡住。

    分代 ZGC 的年轻代回收是并发的。 看实测日志里的一次年轻代回收(Y: 表示 Young collection):

    GC(0) Y: Pause Mark Start (Major) 0.009ms   ← STW,仅 0.009ms
    GC(0) Y: Concurrent Mark 6.920ms             ← 并发标记,不暂停
    GC(0) Y: Pause Mark End 0.008ms              ← STW,仅 0.008ms
    GC(0) Y: Concurrent Relocate 2.338ms         ← 并发重定位,不暂停

    一次完整的年轻代回收,STW 只有开头结尾两个同步点,加起来 0.017ms;中间的标记和重定位全部和应用线程并发执行。

    传统分代 GC 靠“STW 收集小年轻代”来缩短停顿;分代 ZGC 靠“并发收集年轻代”来做到亚毫秒停顿。这是两个完全相反的思路。

    区别 3:怎么记录跨代引用

    分代 GC 都要面对一个经典难题:老年代对象可能引用年轻代对象,收集年轻代时不能漏掉这些引用。解法完全不同:

    • G1:卡表(Card Table)+ 写屏障。 把堆按 512 字节划成“卡”,应用写引用时把对应卡标记为“脏”,年轻代回收时只扫脏卡对应的老年代区域。还维护 RSet 精确记录引用关系。
    • 分代 ZGC:写屏障 + 双缓冲记忆集。 利用染色指针的元数据位,高效判断“这次写是否产生了跨代引用”,记录到记忆集。记忆集采用双缓冲设计——应用线程写一个 buffer,GC 线程读另一个,避免加锁。

    区别 4:晋升机制不同

    • G1:物理晋升。 对象熬够年龄(默认 15 次)后,被物理拷贝到 Old Region,伴随真实内存复制。
    • 分代 ZGC:逻辑晋升。 年龄到阈值后,在下次回收时逻辑划入老年代,不一定要物理搬迁——本质是“改元数据、改年龄”。

    四、实现上的几处精妙改造

    分代 ZGC 围绕染色指针和屏障做了几处关键改造(来自 JEP 439),这几处是理解它为什么能“亚毫秒 + 省内存 + 省 CPU”的关键。

    1. 去掉多映射内存

    非分代 ZGC 有个著名设计:把同一块物理堆内存,映射到 3 个虚拟地址(对应染色指针的 3 种状态),这样 load barrier 判断指针状态时省一次判断。代价是 ps 看到的进程内存是实际堆的 3 倍,容易让人误判内存泄漏。

    分代 ZGC 去掉了多映射,改用 barrier 里的显式判断。好处:ps 显示的就是真实堆大小,不再有 3 倍错觉;染色指针能塞更多元数据位。

    2. 染色指针布局反转(最精妙的一处)

    非分代 ZGC 把元数据放在高位、对象地址放低位;分代 ZGC 反过来——元数据放低位、对象地址放高位。

    为什么这么改?因为元数据放低位后,x64 上一条 shift 指令就能同时完成“检查指针是否需要处理”和“剥掉元数据位”两个动作,极大简化了 load barrier 的 fast path。

    3. 标记职责从 load barrier 移到 store barrier

    非分代 ZGC 只有读屏障,它要做两件事:修正过期指针、标记对象存活。分代 ZGC 新增了写屏障,并把“标记存活”这件工作从读屏障移到了写屏障。

    为什么?因为读远多于写,读屏障执行频率远高于写屏障。把标记工作挪走,读屏障的 fast path 就能做得更轻更快。写屏障用 SATB(快照标记)算法保证不漏标。


    五、实测:吞吐和停顿,你只能选一个

    到这里,你以为分代 ZGC 全面碾压 G1?不是。 实测数据很诚实——在 4 核 + 2G/5G 堆这套配置下,分代 ZGC 的吞吐稳定比 G1 慢约 15.7%,而且堆从 2G 涨到 5G,差距没有缩小。

    原因很直接:

    1. 并发 GC 抢占 CPU:ZGC 的标记、重定位都是并发线程在做,4 核机器上 GC 线程和业务线程抢 CPU,业务跑慢了。
    2. 机器核数太少:大厂线上 16 核、32 核起步,ZGC 并发线程有充足 CPU;我的 4 核机器本来就紧张,CPU 抢占代价被放大。

    所以真相是:ZGC 用“慢一点”换来了“不卡顿”。 吞吐 vs 停顿,是要做取舍的。停顿才是 ZGC 的主场——平均 0.009ms vs 12ms,最大 0.03ms vs 117ms,而且停顿不随堆增大。


    六、大厂数据印证:大堆 + 多核才是 ZGC 的天下

    小堆实测无法覆盖大堆场景,补上大厂公开数据(转转、京东):

    转转(JDK 21 分代 ZGC,8 倍峰值流量压测):

    指标非分代 ZGC分代 ZGC
    Allocation Stall 次数63894(降 85%)
    QPS737842(+15%)
    TP994473ms1967ms(降 2.5s)

    京东(分代 ZGC vs G1):

    指标G1分代 ZGC
    平均停顿20ms(8G 堆)<1ms(与堆大小无关)
    内存占用需预留空间应对碎片降低约 30%

    大厂数据印证了分代 ZGC 的核心价值:在真正的大堆、高分配率生产场景下,分代 ZGC 能大幅降低 Allocation Stall,同时吞吐不降反升。


    七、一句话判断:什么时候用 ZGC,什么时候用 G1

    综合实测 + 公开数据,适用边界很清楚:

    适合分代 ZGC:

    • 堆很大(16G 以上,越大越有优势);
    • 对停顿极度敏感(P99 延迟要求苛刻:交易、风控、实时服务);
    • 分配速率高、易触发 Allocation Stall;
    • 机器核数充足(16 核起步);
    • 希望“零调优”——ZGC 几乎不需要手工调参。

    更适合 G1:

    • 中小堆(<16G);
    • 对吞吐敏感、对停顿不极端的场景;
    • 4 核以内的小机器(并发线程抢 CPU 的代价更明显)。

    一句话:要吞吐、中小堆,选 G1;要低延迟、大堆,选分代 ZGC。


    写在最后

    这篇文章最想让你记住的,其实不是数据,而是一个认知:

    同名 ≠ 同义。 同样叫“分代”,G1 的分代是“把堆切成物理小块、STW 收其中一块”(为了缩短 STW),ZGC 的分代是“在逻辑上区分年龄、并发地收”(为了降低并发回收的成本——少 Stall、少占 CPU 从而提升吞吐、省内存)。名字一样,机制完全相反。

    下次有人跟你说“XX 也用了分代 GC”,先别急着套用你对 G1 的理解——问两句:它这个“分代”,是物理分代还是逻辑分代?是为了缩短 STW,还是为了降低并发回收的成本? 这两句话一问,你就能看穿市面上大部分 GC 科普的含混之处。

    分代 ZGC 不是 G1 的“平替”,而是给“超大堆 + 多核 + 极致低延迟”场景设计的另一条路线。4 核小机器、CPU 密集型,选 G1;TB 级大堆、延迟敏感,选分代 ZGC。


    测试环境:4 核 / 7.4G 云主机,Temurin 25.0.4,Renaissance 0.16.1 | 核心结论:分代 ZGC 的年轻代回收是并发的,G1 是 STW 的,停顿差 1300 倍

    标签
    xpbob · Java 疑难问题诊断工程师
    10 年 Java 开发经验,专注线上疑难问题诊断:JVM 内存与 GC、CPU 高频与采样分析、eBPF 系统级观测。