《Java 并发编程·性能诊断与事故》13.3 低延迟 GC 下的并发设计

把 GC 从 G1 换成 ZGC 之后 p99 没降,问题往往不在收集器:先用同一负载对照 G1、ZGC 与分代 Shenandoah 的暂停与尾延迟,再给出低延迟下的四条并发设计约束——压分配速率、正确复用对象、避开大对象、处理伪共享,并附症状到参数动作的对照表。

承接的问题

13.2 把 G1 的并发标记拆开之后,一个自然的结论是:只要把停顿压到亚毫秒,尾延迟问题就解决了。于是很多团队直接把 -XX:+UseG1GC 换成 -XX:+UseZGC,重启,压测,然后发现 p99 从 40 毫秒变成 38 毫秒——几乎没有变化。

构造一个场景。libraryd-conc 的热点接口每次请求要读一个 600 KB 的报表缓冲区,压测 QPS 2000,分配速率约 1.2 GB/s。G1 下 p99 有 30–50 毫秒的尖峰,怀疑是 GC;换 ZGC 后尖峰确实没了,但 p99 仍然在 20 毫秒以上,而且 CPU 使用率上升了 15%。回头看日志才发现:600 KB 的对象在 G1 下是 humongous,在 ZGC 下也走页级分配,两者都不是「分配速率」问题的解药。

低延迟 GC 解决的是暂停,不解决分配。这一节先把 JDK 25 上三种 GC 的真实行为摆出来,再给出低延迟场景下的四条设计约束,最后给一张「症状 → 参数/设计动作」的对照表。

本机口径:

Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8

写作时 load average 是 86.62 85.05 72.03。所有数字都是单次或少量重复运行、随调度波动、不作基准;本机的负载会让 G1 的暂停明显偏大,这一点在解读时必须记住。

JDK 25 上三种 GC 的事实

先核实参数层面的事实,不靠记忆。

java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseZGC|UseShenandoahGC|ShenandoahGCMode"
java -XX:+UseZGC -XX:+ZGenerational -version
    ccstr ShenandoahGCMode    = satb      {product} {default}
     bool UseG1GC             = true      {product} {ergonomic}
     bool UseShenandoahGC     = false     {product} {default}
     bool UseZGC              = false     {product} {default}

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

三条结论:

  • 本机默认是 G1。 10 核 32 GB 的 ergonomics 选择,不是 ZGC。
  • ZGC 在 JDK 25 只有分代一种模式。 -XX:+ZGenerational 会被忽略并打印 support was removed in 24.0;非分代 ZGC 在 JDK 24 就被移除了。所以讨论 ZGC 时不用再区分「分代/非分代」。
  • Shenandoah 默认仍是 satb 模式,不是分代。 要用分代必须显式加:
java -XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC \
  -XX:ShenandoahGCMode=generational -version

本机实测这条命令能正常启动。注意分代 Shenandoah 的多数参数在 PrintFlagsFinal 里标的是 {experimental},JDK 25 上它仍是实验特性,生产使用前要在自己的负载上复测。

同一份负载(300 MB 常驻、100 MB/s 分配、-Xmx512m、15 秒)在三种 GC 下的表现:

GC日志里的暂停事件应用侧 max 延迟超过 1 ms 的秒数
G123 次暂停,总 78.2 ms,最大 24.2 ms24.4 ms5 / 14
ZGC(分代)日志按周期报 0.001s / 0.024s,info 级不单列暂停1.0 ms1 / 14
Shenandoah(分代)66 次暂停,总 4.1 ms,最大 0.28 ms0.7 ms0 / 14

ZGC 那一行的暂停数字不能从日志字符串里解析:-Xlog:gc 在 info 级把 ZGC 的周期写成 Minor Collection ... 0.001s、Major Collection ... 0.024s,暂停阶段没有单独的毫秒行。所以这里用应用侧观测到的最大延迟做横向对照。

本机 ZGC 日志里一个周期长这样:GC(24) Minor Collection (High Usage) 466M(91%)->328M(64%) 0.001s,Major Collection 则是 0.024s 量级。Shenandoah 分代的暂停(摘录):

[0.067s][info][gc] GC(0) Pause Init Mark (Young) 0.030ms
[0.074s][info][gc] GC(0) Pause Final Mark (Young) 0.521ms
[0.082s][info][gc] GC(0) Pause Final Update Refs 0.058ms

G1 的对照组则很容易在日志里看到 humongous 的痕迹:

[0.239s][info][gc,start    ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[0.242s][info][gc          ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 246M->231M(512M) 2.298ms
[0.266s][info][gc          ] GC(4) Pause Young (Prepare Mixed) (G1 Humongous Allocation) (Evacuation Failure: Allocation) 510M->291M(512M) 1.969ms

这不是说 ZGC 一定更好,而是说换 GC 改变的是暂停分布,不是分配行为。同一份 300 MB 常驻、100 MB/s 的负载,ZGC 的尾延迟确实从 24 毫秒级降到 1 毫秒级;但如果你的 p99 是被分配速率和大对象推上去的,换 GC 只能把「GC 暂停」这一项从账单上划掉。

约束一:分配速率仍然是第一约束

低延迟 GC 也要回收,只是把回收工作放到并发线程。分配速率决定并发周期的频率,常驻集决定每个周期的工作量。两者相乘就是 GC 线程占用的 CPU。

用 13.2 的 MarkLoadProbe 同样的思路,做一个纯 CPU + 分配的对照负载:4 个线程,每个线程反复「写两个对象字段 + 分配 256 字节数组」,数组写进静态环防止被优化掉。用 ThreadMXBean 核实过分配确实发生:bytesPerOp=272.0,G1 下一次运行 6 秒分配了 279.8 GB,约 44 GB/s。

同一份代码,三种 GC 各跑三次(前两次 8 秒,第三次 6 秒,单位百万次操作/秒):

GC运行 1运行 2运行 3备注
G196.4187.2165.5自身方差接近一倍
ZGC(分代)109.096.177.2中等
Shenandoah(分代)54.647.747.1稳定低一档

必须如实说明:本机 load average 80+,G1 自己三次就差了一倍,这张表不能用来给三种 GC 排名。能说的只有两点:第一,Shenandoah 分代在这台机器上稳定低一档,差得超出噪声,值得在自己的负载上复测;第二,低延迟 GC 的吞吐并不必然低于 G1,ZGC 的最好成绩就高于 G1 的最差成绩。

真正可控的是分配速率本身。一个可用的估算:

并发周期频率 ≈ 分配速率 / (堆大小 × (1 - 存活率))

512 MB 堆、300 MB 存活、100 MB/s 分配,留给新分配的空间只有约 200 MB,所以每 2 秒就要走一个周期——这和 13.2 日志里「两三秒一次」对得上。把分配速率降到 50 MB/s,周期频率减半,标记线程的 CPU 占用也跟着减半。这比调任何 GC 参数都直接。

约束二:对象复用,但不是对象池

分配速率的分子是「每次操作分配多少字节」。低延迟场景下最有效的手段是复用缓冲区,而不是把对象塞进池子。

一个真实的对照:每次请求分配一个 4 KB 的编码缓冲区,和用 ThreadLocal 复用同一个缓冲区。两者都做同样的填充与求和,并用一个条件逃逸(if (buf[0] == 127) RING[...] = buf)防止 JIT 把分配消除掉。

import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
import java.util.Locale;

public final class ReuseProbe {

    static final int BUF = 4096;
    static final ThreadLocal<byte[]> CACHE = ThreadLocal.withInitial(() -> new byte[BUF]);
    static final Object[] RING = new Object[64];
    static volatile long sink;

    static long encode(byte[] buf, int seq) {
        for (int i = 0; i < buf.length; i++) {
            buf[i] = (byte) (seq + i);
        }
        return buf[0] + buf[buf.length - 1];
    }

    static void run(boolean reuse, int threads, int perThread) throws Exception {
        ThreadMXBean bean = (ThreadMXBean) ManagementFactory.getThreadMXBean();
        long[] alloc = new long[threads];
        Thread[] ts = new Thread[threads];
        long t0 = System.nanoTime();
        for (int t = 0; t < threads; t++) {
            final int id = t;
            ts[t] = Thread.ofPlatform().name("enc-" + t).start(() -> {
                long tid = Thread.currentThread().threadId();
                long b0 = bean.getThreadAllocatedBytes(tid);
                long local = 0;
                for (int i = 0; i < perThread; i++) {
                    byte[] buf = reuse ? CACHE.get() : new byte[BUF];
                    local += encode(buf, i + id);
                    if (buf[0] == 127) {
                        RING[(i + id) & 63] = buf;
                    }
                }
                alloc[id] = bean.getThreadAllocatedBytes(tid) - b0;
                sink += local;
            });
        }
        for (Thread th : ts) {
            th.join();
        }
        long elapsed = System.nanoTime() - t0;
        long bytes = 0;
        for (long a : alloc) {
            bytes += a;
        }
        long ops = (long) threads * perThread;
        System.out.printf(Locale.ROOT,
                "reuse=%-5s threads=%d ops=%,d allocBytes=%,d bytesPerOp=%.1f nsPerOp=%.1f%n",
                reuse, threads, ops, bytes, bytes / (double) ops, elapsed / (double) ops);
    }

    public static void main(String[] args) throws Exception {
        run(false, 4, 20_000);
        run(true, 4, 20_000);
        for (int r = 1; r <= 2; r++) {
            run(false, 4, 200_000);
            run(true, 4, 200_000);
        }
    }
}

本机输出(摘录,省略预热的两行):

reuse=false threads=4 ops=800,000 allocBytes=3,289,600,000 bytesPerOp=4112.0 nsPerOp=220.7
reuse=true  threads=4 ops=800,000 allocBytes=16,992 bytesPerOp=0.0 nsPerOp=176.2
reuse=false threads=4 ops=800,000 allocBytes=3,289,600,000 bytesPerOp=4112.0 nsPerOp=218.0
reuse=true  threads=4 ops=800,000 allocBytes=16,992 bytesPerOp=0.0 nsPerOp=175.2

4 KB 缓冲区每次新建是 4112 字节(4096 + 16 字节数组头),复用之后只剩 ThreadLocal 首次初始化的 16 992 字节。单次操作从约 220 ns 降到约 176 ns,快了 1.25 倍——这还是 CPU 很快的 M1,换到分配路径更慢的机器上差距更大。

但不要把这个结论推广成「什么都要池化」。对象池的问题:

  • 池化把对象的生命周期从「请求内」拉长到「进程级」,对象会晋升到老年代,GC 扫描成本从 Eden 的一小段变成常驻集的一部分。
  • 池化需要归还协议,忘记归还就是泄漏,忘记 reset 就是脏数据;多线程共享还要在「加锁」和「每线程一份」之间选,后者其实就是 ThreadLocal。
  • 小对象(几十字节)的分配本来就只是指针加法,池化的查找、校验、归还开销远大于分配本身。

判断标准只有一条:构造昂贵、体积大、可安全重置。4 KB 以上的缓冲区、ByteBuffer、序列化器、正则 Matcher 属于这一类;Order、Line 这种请求内的小 record 不属于。

还有两个坑:

  • ThreadLocal 在虚拟线程下是每线程一份。10 万个虚拟线程各缓存一个 4 KB 缓冲区就是 400 MB,直接把低延迟 GC 的堆预算吃光。虚拟线程场景要么把缓冲区做小,要么用 ScopedValue 传递显式的作用域资源。
  • 复用的缓冲区如果被异步任务持有,会跨越请求边界,必须明确所有权。13.2 提到的写屏障在这里也有含义:共享可变缓冲区会制造跨 region 引用,增加记忆集的重建成本。

约束三:避开大对象和 humongous

G1 的 humongous 判定很明确:对象大小达到 region 的一半就算 humongous。本机 -Xmx512m 的日志里 Heap Region Size: 1M,所以阈值是 512 KB。

实测:一个 600 KB 的 byte[],6 秒内分配 302 336 次;同一段 6 秒日志里 2472 次 Young GC 有 2462 次的原因写着 G1 Humongous Allocation,其中 35 次还带 Evacuation Failure:

[0.239s][info][gc,start    ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)
[0.266s][info][gc          ] GC(4) Pause Young (Prepare Mixed) (G1 Humongous Allocation) (Evacuation Failure: Allocation) 510M->291M(512M) 1.969ms

Evacuation Failure: Allocation 意味着分配速度已经超过回收速度,年轻代没地方放了。JFR 里对应的分配事件也证实它绕过了 TLAB:

jdk.ObjectAllocationOutsideTLAB {
  objectClass = byte[] (classLoader = bootstrap)
  allocationSize = 586.0 kB
  eventThread = "main" (javaThreadId = 3)
  stackTrace = [
    HumongousProbe.main(String[]) line: 12
  ]
}

处理办法按优先级排:

  1. 分块。 600 KB 的报表不要一次读成一个数组,改成 64 KB 的分块流式处理,每块都在 TLAB 里分配,回收成本降到年轻代的一次复制。
  2. 复用。 如果确实需要整块缓冲区,用 ThreadLocal 或显式的作用域对象复用它,避免每次请求都制造一个 humongous region。
  3. 调 region 大小要谨慎。 -XX:G1HeapRegionSize 调大可以让 600 KB 不再是 humongous,但每个 region 的最小粒度也跟着变大,小对象多的堆会浪费空间;本机 512 MB 堆默认 1 MB region,不建议为单个对象类型去改。
  4. 换 GC 不是办法。 ZGC 没有 G1 意义上的 humongous region,但大对象同样走页级分配,分配和清零成本不会消失。关于 ZGC 的页大小与阈值,本节按实现说明,本机没有用日志单独验证。

另外,-XX:SoftMaxHeapSize 常被当成「给 GC 的软上限」。JDK 文档的口径是它只被 G1 使用;本机在 ZGC 和 Shenandoah 下也能看到这个 manageable 参数,但不要指望它约束 ZGC 的堆增长——它不是操作系统层面的内存上限,容器里该设的仍然是 -Xmx 和 MaxRAMPercentage。这条是文档口径,本机未做对照实验。

约束四:缓存行与伪共享

停顿压到亚毫秒之后,CPU 缓存未命中会变成新的主导项。最典型的是伪共享:两个线程分别更新两个逻辑上独立的变量,但它们在同一个 64 字节缓存行里,每次写都会让对方的缓存行失效。

一个最小对照:Unpadded 里两个 volatile long 相邻,Padded 在它们之间和之后各填 7 个 long,保证落在不同缓存行。4 个线程,两个写 a、两个写 b,各 2000 万次。

import java.util.Locale;

public final class SharingProbe {

    static final class Unpadded {
        volatile long a;
        volatile long b;
    }

    static final class Padded {
        volatile long a;
        long p1, p2, p3, p4, p5, p6, p7;
        volatile long b;
        long q1, q2, q3, q4, q5, q6, q7;
    }

    static long run(boolean padded, int threads, long iterations) throws Exception {
        Object target = padded ? new Padded() : new Unpadded();
        Thread[] ts = new Thread[threads];
        long t0 = System.nanoTime();
        for (int i = 0; i < threads; i++) {
            final int id = i;
            ts[i] = Thread.ofPlatform().name("sharer-" + i).start(() -> {
                for (long k = 0; k < iterations; k++) {
                    if (padded) {
                        Padded p = (Padded) target;
                        if ((id & 1) == 0) { p.a++; } else { p.b++; }
                    } else {
                        Unpadded p = (Unpadded) target;
                        if ((id & 1) == 0) { p.a++; } else { p.b++; }
                    }
                }
            });
        }
        for (Thread t : ts) {
            t.join();
        }
        return System.nanoTime() - t0;
    }

    public static void main(String[] args) throws Exception {
        int threads = 4;
        long iters = 20_000_000L;
        run(true, threads, 1_000_000L);
        run(false, threads, 1_000_000L);
        for (int round = 1; round <= 3; round++) {
            long unpadded = run(false, threads, iters);
            long padded = run(true, threads, iters);
            System.out.printf(Locale.ROOT,
                    "round=%d threads=%d iters=%d unpaddedMs=%.1f paddedMs=%.1f speedup=%.2fx%n",
                    round, threads, iters, unpadded / 1e6, padded / 1e6, unpadded / (double) padded);
        }
    }
}

本机输出:

round=1 threads=4 iters=20000000 unpaddedMs=1563.5 paddedMs=920.7 speedup=1.70x
round=2 threads=4 iters=20000000 unpaddedMs=1807.5 paddedMs=1332.2 speedup=1.36x
round=3 threads=4 iters=20000000 unpaddedMs=2073.3 paddedMs=1338.2 speedup=1.55x

1.36–1.70 倍的差距,在 4 个线程、纯写场景下已经很明显。生产上更常见的版本是「每个线程一个计数器,最后汇总」:LongAdder 的 Cell 用 @Contended 做了填充,就是这个道理;自己写分片计数器时如果忘了填充,效果会打对折。

什么时候不要做填充:

  • 单线程或读多写少的字段,填充只是浪费内存。
  • 对象本身小、数量大时,填充会让每个对象膨胀几十字节,堆占用和缓存压力反而上升。@Contended 只该用在「确认存在争用的热点字段」上,并且要先用 perf 或 JFR 的 jdk.JavaMonitorEnter 证明争用存在。

症状 → 参数/设计动作

症状先看什么证据参数或设计动作
p99 高,但 GC 日志单次停顿 < 1 msJFR jdk.ZAllocationStall、应用排队/锁等待不是 GC 停顿问题,回去看第 1、5、12 章
ZGC 下出现分配停顿jdk.ZAllocationStall、堆使用率加堆、降分配速率,别只调 ConcGCThreads
G1 单次 20–50 ms 停顿G1 Humongous Allocation、Evacuation Failure拆大对象、复用缓冲区;最后才考虑 G1HeapRegionSize
并发周期两三秒一次Concurrent Mark Cycle 墙钟与间隔降分配速率;堆确实小时再考虑加堆
换 ZGC 后 CPU 上升容器 CPU limit、ConcGCThreads并发线程在抢核,减标记线程或加配额
吞吐上不去、CPU 也不满分配速率、伪共享、锁先用 13.1/13.2 的采集定位,再改数据结构
堆没满却 OOMSoftMaxHeapSize、大对象碎片设 -Xmx/MaxRAMPercentage,检查 humongous
虚拟线程下堆暴涨ThreadLocal 数量 × 缓冲区大小缩小缓冲、改用 ScopedValue、限制并发

表里最容易被忽略的是第一行:换低延迟 GC 之前,先确认 p99 是 GC 造成的。G1 的日志、JFR 的 jdk.GCPhasePause、应用侧的排队时间,三样对不上就不要动 GC 参数。

在 libraryd-conc 里落地

conc-ops 的低延迟基线只做三件事:

  1. 给每个接口定分配预算。 用 13.1 的 getThreadAllocatedBytes 采样,超过基线 1.5 倍告警。预算比 GC 参数更早发现问题。
  2. 把大对象和线程本地资源列成清单。 超过 256 KB 的 byte[]、String、集合逐个确认能否分块或复用;所有 ThreadLocal 缓冲区登记大小和生命周期,虚拟线程场景单独审计——10 万虚拟线程乘以 4 KB 就是 400 MB。
  3. GC 选型写进容量文档。 选 G1、ZGC 还是 Shenandoah,要写清楚依据是「暂停 SLA」还是「吞吐优先」,并附上对照数据。没有数据的选型,下一次事故就会回到原点。

参数上的最小基线(容器 2–4 核、堆 1–2 GB、要求 p99 < 50 ms):

java -XX:+UseZGC -Xmx2g -XX:ConcGCThreads=1 \
  -Xlog:gc*:file=gc.log:uptime,level,tags \
  -jar libraryd-conc.jar

ConcGCThreads=1 是因为容器只有 2–4 核,标记线程再多就是和业务抢。如果沿用 G1,才需要 -XX:SoftMaxHeapSize=1536m 这样的软目标;换到 ZGC 之后要把这类 G1 专用参数删掉——留着不生效,只会误导下一个人。

常见错误与边界

  • 以为换了 ZGC 就不用管分配。 低延迟 GC 把暂停压下去,但并发周期仍由分配速率驱动。
  • 把 Shenandoah 默认当成分代。 JDK 25 默认 ShenandoahGCMode=satb,分代要显式开启且仍是实验特性。
  • 还在写 -XX:+ZGenerational。 JDK 24 起已被移除,只会打印 Ignoring option。
  • 把对象池当万能药。 小对象池化是负收益;只对构造昂贵、体积大、可重置的对象做复用。
  • 在虚拟线程里用 ThreadLocal 缓存大缓冲区。 每线程一份,线程数一上来就是内存灾难。
  • 为了消掉 humongous 去调大 region。 会让小对象占用的最小粒度变大,得不偿失。
  • 把 SoftMaxHeapSize 当成容器内存上限。 它是 G1 的软目标,不是 cgroup 限制。
  • 看到 1.5 倍伪共享差距就给所有字段填充,或用一次压测结果选 GC。 填充会膨胀对象,只在证明有争用的热点上做;GC 选型要在目标硬件上跑多轮并看分位数。

小结

低延迟 GC 改变的是暂停分布:同一份 300 MB 常驻、100 MB/s 分配的负载,G1 的应用侧最大延迟约 24 毫秒,ZGC 约 1.0 毫秒,分代 Shenandoah 约 0.7 毫秒;但三者的分配路径成本、大对象处理、缓存行为并没有消失。四条约束按优先级排:分配速率决定并发周期频率,是最靠前也最可控的一项;对象复用要把「复用缓冲区」和「对象池」分开,前者几乎总是对的,后者只对少数大对象成立;大对象要么分块要么复用,换 GC 不解决问题;停顿压下去之后,伪共享这类 CPU 级问题才会显形,本机 1.36–1.70 倍的差距说明它不是理论问题。

JDK 25 的参数事实要记牢:默认 G1;ZGC 只剩分代,ZGenerational 已被移除;Shenandoah 默认 satb,分代需显式开启且为实验特性。下一节离开 JVM,进入容器:availableProcessors() 在 CPU 配额下返回什么、线程池默认值会怎么被它带偏。

阅读导航:上一节 13.2 并发标记与业务线程 | 下一节 14.1 availableProcessors 与 CPU 配额

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收