《Java 虚拟机原理与性能调优》6.3 ZGC 与低延迟取舍:染色指针、读屏障与停顿

ZGC 的停顿为什么和堆大小基本无关?染色指针与读屏障到底做了什么?本节用本机 10 秒 G1 与 ZGC 对照实验拆开停顿、MMU、Allocation Stall 与 RSS,并说明换收集器前要算清的三笔账,读完能判断什么时候该换、换了要付什么代价。

本节目标:理解 ZGC 用染色指针和读屏障换来了什么,能读懂它的停顿、MMU 与 Allocation Stall,并判断什么时候值得从 G1 换到 ZGC。
适用版本:JDK 25(Temurin 25.0.5),本机 Apple M1 Pro / 10 核 / 32 GB / macOS 26.3。ZGC 自 JDK 15 转正,JDK 21 引入分代(当时需 -XX:+ZGenerational),JDK 23 起分代成为默认,JDK 24 移除了该选项。

承接的问题

6.2 里 G1 的停顿是这样的(OldLoad 10 250,-Xms512m -Xmx512m):p50 0.542 ms,p99 0.973 ms,但最大一次 Young GC 达到 31.027 ms。对 libraryd 的借还书接口来说,p99 很好看,可每天总有几个请求撞上那 30 ms。

如果 SLA 写的是「p99.9 < 10 ms」,G1 调参数很难保证:停顿再短也要复制存活对象,存活对象越多、region 越碎,单次停顿就越长。ZGC 的思路不是「优化复制」,而是「把复制挪到应用线程之外」,让停顿只做标记开始、标记结束、重定位开始这几个握手动作。

问题也随之而来:它靠什么保证应用线程在对象移动时还能读到正确地址?代价是什么?本节用本机同一份负载做对照,把数字和机制对上。

染色指针:把状态放进指针里

ZGC 的堆对象会被移动,移动后所有指向它的引用都得更新。传统做法是停下来改引用,或者用转发指针(forwarding pointer)占住对象头。ZGC 选择把状态放进引用本身:一个 64 位引用里,除了对象地址,还留了几位作为颜色标记,用来表示这个引用当前处于标记、重映射还是可终结状态。

这样做的直接好处是:判断「这个引用是不是过期」不需要访问对象头,只看指针的高位即可。对象被移动后,旧引用仍然指向旧地址,但它的颜色位会告诉读屏障「这是旧视图」。

ZGC 在实现上依赖虚拟内存的多重映射:同一段物理内存被映射到多个虚拟地址视图,不同颜色的指针指向不同视图。这样带颜色的指针不需要先做掩码运算就能直接解引用,代价是虚拟地址空间和页表的额外开销。

需要强调:染色指针解决的是「怎么知道引用过期」,不解决「谁去修」。修引用这件事落在读屏障上。

读屏障:每次读引用都要过一道检查

ZGC 在每次读取引用类型字段时插入一段检查代码,这就是读屏障(load barrier)。伪代码可以写成:

读取引用 r
if (r 的颜色表示需要处理) {
    慢路径:修正 r(可能触发重映射),返回新地址
}
返回 r

快路径只是一次测试和跳转,慢路径才会真正修复指针。修复后的引用会被写回原字段(self-healing),所以同一个字段通常只需要修一次。

这就是 ZGC 吞吐低于 G1 的主要原因:应用线程每读一个引用都要多付一点检查成本,而 G1 只在 GC 时扫描和复制。这个成本在引用密集的代码里会累积,在数组、字符串和集合操作多的服务里尤其明显。

也正因为读屏障在应用线程里执行,ZGC 才能把「移动对象」和「更新引用」都做成并发的:应用线程自己会在读到旧引用时顺手修好,不需要 Stop-The-World 去遍历整个堆。

并发标记与重定位:停顿只剩三个阶段

ZGC 的日志前缀 Y: 和 O: 分别表示年轻代和老年代。JDK 25 默认就是分代 ZGC,不需要额外开关。一次完整周期里,真正停应用线程的只有三类动作:

[0.082s][info][gc,phases   ] GC(0) Y: Pause Mark Start (Major) 0.013ms
[0.085s][info][gc,phases   ] GC(0) Y: Pause Mark End 0.009ms
[8.730s][info][gc,phases   ] GC(1099) y: Pause Relocate Start 0.001ms

标记、重定位、引用修正都在并发阶段完成。停顿只负责「开始标记」「标记结束的握手」「开始重定位」这些必须一致的瞬间。

Minor 与 Major:两代各自的周期

JDK 25 的分代 ZGC 里,Y: 表示年轻代、O: 表示老年代。日志里能看到两种周期名,括号里是触发原因:

[0.108s][info][gc] GC(0) Major Collection (Warmup)
[0.161s][info][gc] GC(3) Minor Collection (Allocation Rate)
[1.127s][info][gc] GC(134) Minor Collection (Allocation Stall)

Warmup 是启动预热,Allocation Rate 是分配速率达到阈值,Allocation Stall 是已经等不及了。Minor 只处理年轻代,Major 处理整个堆。分代的意义是把短命对象的回收限制在年轻代,避免每次都标记整个堆——这也是 JDK 21 引入分代 ZGC 后吞吐改善的主要原因。

对调优来说,Minor Collection (Allocation Stall) 这个原因最值得警惕:它说明回收速度已经跟不上分配速度,下一步要么加堆,要么降低分配速率,而不是继续调停顿参数。

同参数对照实验:OldLoad 10 250,-Xms512m -Xmx512m,G1 与 ZGC 各跑一次,单次运行:

指标G1ZGC
吞吐(rounds,越高越好)9562190343
停顿次数16713859
停顿总时长813.0 ms24.0 ms
p50 停顿0.542 ms0.006 ms
p99 停顿0.973 ms0.016 ms
最大停顿31.027 ms1.775 ms
常驻内存(RSS 中位数)545 MiB563 MiB

几点必须如实说明:

  • 停顿总时长占 10 秒墙钟的比例,G1 约 8.1%,ZGC 约 0.24%;
  • ZGC 的 3859 次停顿里,绝大多数是 Mark Start、Mark End、Relocate Start,单次在 0.001–0.03 ms 量级;
  • ZGC 的最大停顿 1.775 ms 是一次 Pause Mark Start 的离群值,不是稳态;即便如此,它仍是 G1 最大停顿的约 1/17;
  • 吞吐是单次运行的 rounds,ZGC 少约 5.5%,不能当成严谨 benchmark,但方向和「读屏障有成本」一致;
  • RSS 是运行期每 0.2 秒采样一次的中位数,ZGC 多约 18 MiB,小堆下差距不大,堆越大元数据和映射开销越明显。

MMU:比最大停顿更能说明体验

ZGC 会打印 MMU(Minimum Mutator Utilization,最小应用线程利用率):

[10.039s][info][gc,mmu] GC(1262) y: MMU: 2ms/11.3%, 5ms/64.5%, 10ms/82.3%, 20ms/91.0%, 50ms/96.2%, 100ms/97.8%

读法是「在 2 ms 的观察窗口里,应用线程至少只跑了 11.3% 的时间」。这个 11.3% 不是随便来的:同一份日志里最长的一次停顿是 1.775 ms,(2 − 1.775) / 2 ≈ 11.25%,正好对上。5 ms 窗口的 64.5% 也接近 (5 − 1.775) / 5 = 64.5%。

这就是 MMU 的用处:最大停顿只告诉你「最坏的一次」,MMU 告诉你「在多大的时间窗口里,应用实际还剩多少时间」。对延迟敏感的服务,MMU 比平均停顿更有解释力。

Allocation Stall:停顿之外的第二类卡顿

ZGC 的停顿很短,但应用线程仍可能被分配速率拖住:

[1.125s][info][gc] Allocation Stall (main) 1.622ms
[1.127s][info][gc] GC(134) Minor Collection (Allocation Stall)
[2.303s][info][gc] Allocation Stall (main) 2.753ms

这次 10 秒运行里出现了 4 次 Allocation Stall:1.622 ms、2.753 ms、8.725 ms、1.756 ms。最长的一次 8.725 ms,比同一份日志里最长的 GC 停顿(1.775 ms)还大 5 倍。原因不是 GC 停顿,而是应用线程分配太快,ZGC 来不及在后台回收出空闲页,分配线程只能等。

所以「ZGC 停顿小于 1 ms」和「应用不会被 GC 卡住」是两件事。前者看 gc,phases,后者要看 Allocation Stall 和 MMU。堆给得太小、分配速率太高时,ZGC 一样会让请求变慢,只是卡在分配而不是停顿上。对本例来说,真正的尾延迟来源就是这次 8.725 ms 的分配停顿,而不是任何一次 GC 暂停。

和 G1 的取舍:把停顿换成吞吐与内存

ZGC 的初始化日志已经把代价写在明面上(-Xms256m -Xmx256m):

[0.016s][info][gc,init] Reserved Space Size: 4G
[0.016s][info][gc,init] Min Capacity: 256M
[0.016s][info][gc,init] Initial Capacity: 256M
[0.016s][info][gc,init] Max Capacity: 256M
[0.016s][info][gc,init] Page Size Medium: Range [2M, 8M]
[0.016s][info][gc,init] Uncommit: Implicitly Disabled (-Xms equals -Xmx)
[0.016s][info][gc,init] GC Workers for Old Generation: 2 (dynamic)
[0.016s][info][gc     ] Using The Z Garbage Collector

Reserved Space Size: 4G 是虚拟地址预留,不是物理内存;256 MB 的堆也预留 4 GB 地址空间。Uncommit 那一行更值得注意:-Xms 等于 -Xmx 时,ZGC 直接禁用内存归还。

默认值是 ZUncommit=true、ZUncommitDelay=300(秒)。也就是说,只有 -Xms 小于 -Xmx 时,ZGC 才会在空闲 5 分钟后把不用的内存还给操作系统。这对容器里「按 request 计费、希望空闲时降下去」的部署有意义;对固定堆的服务则无关。

选型可以压成一张表:

维度G1ZGC
停顿量级毫秒到几十毫秒,随存活对象增长亚毫秒级,与堆大小基本无关
吞吐较高低几个百分点(读屏障成本)
内存较省多一份指针元数据与映射开销
CPU较少并发线程更多,核少时更明显
适用通用服务、批处理大堆、低延迟、SLA 严格

判断顺序应该是:先确认 SLO 是否真的要求 p99.9 在 10 ms 以内;如果 G1 的 p99 已经达标,只是偶发最大值难看,优先查那几次最大停顿对应的分配行为和存活对象,而不是直接换收集器。ZGC 不是「更快的 G1」,它是「用吞吐和内存买停顿」。

在 libraryd 里落地

libraryd 的借还书接口在高峰期 p99 约 4 ms,但每天有一两次 30 ms 的尾延迟。要不要换 ZGC,不能凭感觉,得先量。

第一步,把 GC 日志开全。注意 -Xlog:gc 在 ZGC 下不会打印停顿行,必须带上 gc+phases:

java -Xms4g -Xmx4g -XX:+UseZGC \
  "-Xlog:gc,gc+phases=info,gc+mmu=info,gc+alloc=info:file=/var/log/libraryd/gc.log:time,uptime,level,tags:filecount=5,filesize=50m" \
  -jar libraryd.jar

第二步,在应用侧记录请求延迟,用同一套口径比较换收集器前后的 p99/p99.9。下面这个类只保留最近 N 次采样,按需计算分位数,开销可控:

package com.plume.library.app;

import java.util.Arrays;

public final class SloWindow {

    private final long[] samples;
    private int next;
    private int size;

    public SloWindow(int capacity) {
        this.samples = new long[capacity];
    }

    public synchronized void record(long nanos) {
        samples[next] = nanos;
        next = (next + 1) % samples.length;
        if (size < samples.length) {
            size++;
        }
    }

    public synchronized long percentile(double p) {
        if (size == 0) {
            return 0L;
        }
        long[] copy = Arrays.copyOf(samples, size);
        Arrays.sort(copy);
        int index = (int) Math.ceil(p * size) - 1;
        return copy[Math.max(0, Math.min(size - 1, index))];
    }

    public synchronized int size() {
        return size;
    }
}

javac --release 25 可编译。把它挂在接口出口记录耗时,换收集器前后各跑一个完整业务日,比较 percentile(0.99) 和 percentile(0.999),比看 GC 日志的平均停顿更接近用户感受。

第三步,换 ZGC 时不要顺手把堆调大。ZGC 的停顿与堆大小基本无关,把 -Xmx 从 4g 抬到 16g 不会让停顿更好,只会让内存更贵、并发标记要扫描的对象更多。堆大小仍应按存活对象和分配速率来定。

第四步,容器里如果希望空闲时归还内存,用 -Xms 小于 -Xmx,并确认 ZUncommit=true、ZUncommitDelay 符合预期;固定堆则不用管这一项,因为 ZGC 会直接禁用归还。

常见错误与边界

错误一:还在加 -XX:+ZGenerational。 JDK 25 会打印警告并忽略:

OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0

分代 ZGC 在 JDK 21 引入时不是默认,JDK 23 起才成为默认形态,JDK 24 移除了这个选项。看到旧文章里的 -XX:+ZGenerational,直接删掉。

错误二:用 -Xlog:gc 看 ZGC 停顿。 ZGC 的停顿行在 gc,phases 标签下,只开 gc 只能看到 Major Collection/Minor Collection 的周期汇总,会误以为「ZGC 没有停顿日志」。6.1 已经踩过这个坑。

错误三:把 ZGC 当成万能低延迟。 上面的 Allocation Stall 说明,分配速率超过回收速率时,应用线程照样会等几毫秒到十几毫秒。堆太小、对象分配太猛,换 ZGC 也救不了。

错误四:以为 ZGC 一定更省内存。 同一份负载下 ZGC 的 RSS 比 G1 高约 18 MiB(545 对 563 MiB,单次采样中位数)。堆越大,指针元数据、页表和多重映射的固定开销越明显。

错误五:核数很少的容器里硬上 ZGC。 初始化日志里 GC Workers for Old Generation: 2 (dynamic) 是因为本机只给了 2 个并发 worker;ZGC 的并发标记和重定位需要 CPU,1–2 核的容器里它可能既没降低停顿,又拖低吞吐。

边界:ZGC 不修复内存泄漏。 它只是把回收做得更并发。如果老年代持续增长,先查引用链和缓存,别指望换收集器。

边界:-Xms 等于 -Xmx 时 Uncommit 失效。 这是设计行为,不是 bug;容器内存曲线不下降时先看这一行。

小结

ZGC 的机制可以压成三句话:

  • 染色指针把「引用是否过期」写进指针本身,读的时候不用访问对象头;
  • 读屏障在每次读引用时检查并顺手修复,换来并发移动对象的能力,代价是吞吐;
  • 停顿只剩 Mark Start、Mark End、Relocate Start 三个握手,所以与堆大小基本无关。

本机对照的数字也说明取舍是真实的:ZGC 把最大停顿从 31.027 ms 压到 1.775 ms,把停顿总时长从 813 ms 压到 24 ms,代价是吞吐少约 5.5%、RSS 多约 18 MiB,并且仍然出现了最长 8.725 ms 的 Allocation Stall。停顿变短了,但「应用线程完全不被 GC 影响」并没有实现。

选不选 ZGC,最终不是技术偏好,而是 SLO 和成本的计算:停顿预算够不够、CPU 和内存的余量付不付得起、尾延迟是否真的影响业务。下一节离开 GC,进入堆转储与对象支配分析,看看内存问题该怎么定位。

阅读导航:上一节:6.2 G1 调优:Region、Humongous 与 Mixed GC · 下一节:7.1 堆转储与支配树:从 hprof 到保留大小 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发