JVM 并发垃圾回收深度实战:从 ZGC 彩色指针与负载屏障到 Shenandoah Brooks Pointer 的工程全解

在服务端 Java 的世界里,"Stop-The-World" 从来不是一个抽象概念。当你的堆膨胀到 64GB、对象图跨越千万级边时,一次 Full GC 带来的 3 秒停顿足以让 P99 延迟曲线直接画出悬崖。过去十年 JVM 垃圾回收器演进的主线,本质上是一条把"移动对象"这件事从暂停里剥离出去的工程史。ZGC 和 Shenandoah 是这条主线上最激进的两个答案,而它们的分水岭,恰恰在于一个看似微不足道的问题:如何在一个正在被应用线程并发读写的对象上,安全地搬家?

一、问题的根源:并发移动对象的读屏障困境

要理解 ZGC 与 Shenandoah 的设计分歧,必须先回到并发压缩(concurrent compaction)的核心矛盾。

垃圾回收器的"标记-整理"流程中,移动对象的语义是:对象从 from 地址搬到 to 地址,全堆所有指向 from 的引用都必须改写为 to。如果这件事在 STW 里做,一切简单——世界静止,引用图不变。但如果允许应用线程同时运行,就会出现经典竞态:

  1. 应用线程读到字段值 old_ref(指向 from 空间);
  2. GC 线程把对象搬到 to,并更新堆中该字段为 new_ref;
  3. 应用线程用手里过期的 old_ref 去访问字段 → 读到的是已被回收的陈旧副本,数据损坏。

解决这个竞态只有两条路:要么让应用线程读到的永远是"正确的最新地址"(读屏障),要么让 from 地址永远保持"可转发"的语义(转发指针)。ZGC 选了前者,Shenandoah 选了后者——但这句总结过于粗糙,真正的差异在于它们各自把状态信息编码在哪里。

二、ZGC:把 GC 状态塞进指针的空闲位

ZGC 的核心创新是 colored pointer(彩色指针):在 64 位指针中,利用当前硬件实际只使用 48~57 位虚拟地址的事实,把高位的元数据位拿来编码对象状态。

在 ZGC 的 42 位地址布局(JDK 11+ 默认支持 4TB 堆)中,指针被切分为:

 63                  46 45  44  43  42                     0
+----------------------+---+---+---+-----------------------+
|      未使用           | F | M1| M2|     对象地址 (42 bit)  |
+----------------------+---+---+---+-----------------------+
                         |   |   |
       Finalizable -------+   |   |
       Marked1 ---------------+   |
       Marked0 -------------------+

这三个位不是"颜色"装饰,而是标记视图(mark view)的状态机编码。ZGC 在每个 GC 周期交替使用 M0 和 M1:

  • 周期 N:好对象被标记为 M0=1, M1=0
  • 周期 N+1:好对象被标记为 M1=1, M0=0
  • 若某个指针的 M0/M1 与当前周期的期望值不符,说明它是上一周期遗留的、尚未被修正的指针

这样做的好处极其工程化:标记状态随指针本身传播,不需要额外的标记位图(mark bitmap),也避免了"标记位与指针分离"导致的一致性维护成本。

2.1 负载屏障:自愈(self-healing)指针

彩色指针只有在每次加载引用时都被检查才有意义,这就是 load barrier(负载屏障)。ZGC 在 JIT 编译后的代码里,于每个 getfield/aload 之后插入一小段屏障代码。用伪 C++ 表达其语义:

// ZGC load barrier 语义伪代码
oop ZBarrier::load_barrier(oop* field_addr) {
    oop p = *field_addr;               // 1. 加载原始指针(含颜色位)
    uintptr_t addr = (uintptr_t)p;

    if ((addr & GOOD_MASK) != 0) {     // 2. 颜色正确 → 快路径,直接返回
        return p;                      //    这是一条 cmp + 分支,几乎零成本
    }

    // 3. 慢路径:颜色不对,说明对象已被移动
    return slow_path(p, field_addr);
}

oop ZBarrier::slow_path(oop p, oop* field_addr) {
    // 查 forwarding table 得到新地址
    oop new_p = ZForwardingTable::lookup((uintptr_t)p);

    if (new_p != NULL) {
        // 自愈:把字段原地改写为新地址,后续访问全部走快路径
        Atomic::cmpxchg(field_addr, p, new_p);
        return new_p;
    }
    // 对象未移动,仅需修正颜色位(重标记)
    return ZAddress::remap(p);
}

这里有两个关键设计值得拎出来讲。

其一是自愈(self-healing)。 慢路径不只是返回正确地址,而是用 CAS 把字段本身改成新地址。这意味着同一个字段的第二次及以后访问,会命中快路径。对于长生命周期对象上的热字段,屏障成本会随运行时间单调下降,最终收敛到一次 test + branch。这是 ZGC 能把吞吐损失压到 15% 以内的核心原因——它不是靠屏障快,而是靠屏障会"消失"。

其二是 forwarding table 与堆外存储。 ZGC 的转发表是 per-region 的结构,且存储在 Java 堆之外(早期版本是物理内存映射,JDK 16+ 改为在 GC 线程本地分配)。这一点常被忽略但极其重要:如果转发表本身在堆里,那么访问转发表就可能再次触发屏障,形成递归依赖。把它放到堆外,屏障的慢路径就有了一个干净的终止条件。

2.2 为什么 ZGC 的停顿与堆大小无关

ZGC 的 STW 阶段只剩三次极短的暂停:

阶段工作内容耗时特征
Pause Mark Start扫描线程栈根、建立标记视图O(线程数),与堆无关
Pause Mark End处理 SATB 缓冲区残留、弱引用O(活跃线程栈深度)
Pause Relocate Start选定 relocation set,切换地址视图O(region 数)

关键在于:对象图的遍历、对象的实际搬移,全部由并发线程完成,并且搬移是以 region 为单位批量进行的——选定一组需要压缩的 region,并发搬迁其中的存活对象,期间应用线程通过 load barrier 自动"看到"新地址。整个 GC 周期的耗时随堆大小线性增长,但暂停时间恒定在亚毫秒级(官方目标 < 10ms,实践中普遍 0.1~1ms)。

这是 ZGC 最反直觉的地方:你把堆从 8GB 加到 2TB,单次暂停时间几乎不变。

三、Shenandoah:Brooks Pointer 与"对象头里的转发槽"

Shenandoah 走的是另一条路。它没有可用的指针空闲位(要支持压缩指针 -XX:+UseCompressedOops 下的 32 位引用,彩色指针方案直接失效),于是选择把转发信息放进对象自身。

Brooks Pointer 的做法是:在每个对象的头部前面(或对象内)额外预留一个字长,存放"当前真实地址"。所有对对象的访问,都要先解引用这个转发指针:

// Shenandoah Brooks pointer 读屏障语义
oop ShenandoahBarrier::load_reference(oop* field_addr) {
    oop p = *field_addr;

    // 解引用 Brooks pointer:p 可能是陈旧副本,
    // 但 p->forwardee 永远指向最新存活副本
    oop forwardee = p->brooks_forward_pointer();

    if (forwardee == p) {
        return p;                        // 快路径:未移动
    }
    // 慢路径:返回新副本,并可选地自愈字段
    if (ShenandoahSelfHealing) {
        Atomic::cmpxchg(field_addr, p, forwardee);
    }
    return forwardee;
}

这个设计与 ZGC 的差异可以浓缩为一句话:ZGC 把状态编码在"引用方"(指针里),Shenandoah 把状态编码在"被引用方"(对象里)。

由此产生三个连锁后果:

  1. 内存开销不同。 Brooks pointer 每个对象多一个字长(64 位下 8 字节)。对于海量小对象(比如 32 字节的 Point),这是 25% 的额外开销。ZGC 的开销为零——它用的是指针里本来就没用的位。
  2. 屏障成本不同。 Brooks barrier 需要一次额外的内存解引用(p->forwardee),这会引入一次潜在的 cache miss;ZGC 只需读取指针本身的位并做一次掩码测试,寄存器内完成。这是 ZGC 吞吐更优的直接原因。
  3. 压缩指针兼容性相反。 Shenandoah 天然支持压缩 oops(对象内偏移是独立的),ZGC 在很长一段时间里不能用压缩指针(彩色指针需要真实虚拟地址位)。直到 JDK 22 的 generational ZGC + 压缩指针方案才逐步缓解——这是 ZGC 早期在小堆场景内存 footprint 偏大的根源。

3.1 Shenandoah 的写屏障与 SATB

Shenandoah 在并发标记阶段使用 SATB(Snapshot-At-The-Beginning) 写屏障:当应用线程要覆盖一个字段的旧值时,先把旧值压入线程本地的 SATB 队列,保证"周期开始时存活的对象不会被漏标"。

// SATB 写屏障:在 obj.field = new_value 之前插入
void ShenandoahBarrier::store_barrier(oop obj, oop* field_addr, oop new_value) {
    oop old_value = *field_addr;
    if (old_value != NULL && !is_marked(old_value)) {
        // 把旧引用推入线程本地 buffer,供并发标记线程消费
        SATBMarkQueue::enqueue(old_value);
    }
    *field_addr = new_value;   // 真正的写
}

注意这里的一个工程细节:SATB 队列是线程本地、无锁、批量发布的。每线程持有固定大小 buffer,写满后整块交给 GC 线程。这把屏障成本摊薄到接近"一次指针比较 + 偶尔的 buffer 切换",但也带来一个副作用——GC 周期结束时会有一次 STW 专门 drain 所有线程的残留队列,这就是 Shenandoah 的 Final Mark 暂停。

四、G1 的第三条路:Remembered Set 与分区增量

把 G1 拉进来对照,能更清楚地看到设计空间的取舍。G1 本质上仍是分代 + 增量 STW 整理:它把堆切成 2048 个 region,通过 Remembered Set(RSet) 记录跨 region 引用,从而在回收某个 region 时不必扫描全堆。

RSet 的代价是写屏障必须有"记录跨区引用"的语义:

// G1 post-write barrier:把 (obj所在card, 引用目标region) 记入 RSet
void G1Barrier::post_store(oop obj, oop new_value) {
    if (new_value == NULL) return;
    // 判断是否为跨 region 引用
    if (!is_in_same_region(obj, new_value)) {
        CardTable::mark_dirty(card_of(obj));   // 标记脏卡
        // dirty card 后续由 Refine 线程异步处理,构建 RSet
    }
}

G1 的选择是:用写屏障的时间开销(每次跨区写都要标脏卡)和内存开销(RSet 通常占堆 1%~5%),换取"可以只回收一部分 region"的增量能力。它的 STW 停顿是可预测的(-XX:MaxGCPauseMillis 软目标),但每次 young GC / mixed GC 仍然是暂停的,且停顿随堆增大、存活集变多而上升。

一句话总结三者的定位:

维度G1ShenandoahZGC
移动对象时机STW并发并发
屏障类型写屏障(RSet)读屏障+写屏障读屏障(load barrier)
状态编码位置卡表 + RSet对象头 Brooks ptr指针高位元数据位
额外内存开销1%~5% 堆每对象 8 字节~0
压缩 oops支持支持JDK 22+ 渐进支持
典型最大停顿10~200ms< 10ms< 1ms
吞吐相对损失基准较大(读屏障解引用)较小

五、生产实践:选型、参数与踩坑

5.1 什么时候不该上 ZGC

ZGC 不是银弹。以下三类场景我见过明确的性能回退:

第一,分配速率极高的短生命周期对象负载。 ZGC 直到 JDK 21 引入 Generational ZGC 之前是全堆并发标记的,对"朝生夕死"对象没有分代优化,标记阶段的 CPU 消耗与分配速率成正比。如果你的应用是典型的 Web 请求处理(大量临时 String、HashMap.Entry),非分代 ZGC 的 GC 线程 CPU 占用会明显高于 G1。结论:JDK 21+ 用 -XX:+UseZGC -XX:+ZGenerational(JDK 21+ 默认开启分代),JDK 17 及以前请谨慎评估。

第二,堆很小但对象极多。 彩色指针方案下每个 region 大小动态(2MB/32MB/256MB),小堆下 region 管理元数据占比上升;同时屏障在 JIT 代码里插入的分支会稀释 I-cache 命中率。8GB 以下、延迟要求不极端的场景,G1 往往吞吐更好。

第三,使用 JNI Critical / GetPrimitiveArrayCritical 的代码。 ZGC 不支持 JNI Critical 区(因为对象不能在该区间移动,而 ZGC 又不在 STW 里停顿)。JDK 18 起对此做了放宽,但仍有大量 native 库依赖此 API,务必先做兼容性验证。

5.2 关键参数与 GC 日志判读

一个典型的生产 ZGC 配置:

-XX:+UseZGC
-XX:+ZGenerational              # JDK 21+,分代 ZGC
-Xms32g -Xmx32g                 # ZGC 强烈建议 Xms == Xmx,避免动态扩缩容触发额外周期
-XX:SoftMaxHeapSize=28g         # JDK 17+:允许 GC 在内存压力下把堆收缩回 28g
-XX:+ZUncommit
-XX:ZUncommitDelay=300          # 空闲 300s 后归还内存给 OS(容器环境省钱利器)
-Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=10,filesize=64m

日志里最该关注的是 Pause 行而非 Garbage Collection 总耗时:

[12.345s] GC(3) Pause Mark Start 0.213ms
[12.346s] GC(3) Concurrent Mark 184.331ms
[12.531s] GC(3) Pause Mark End 0.298ms
[12.532s] GC(3) Concurrent Relocate 96.771ms

如果你的 Pause Mark Start 或 Pause Mark End 超过 1ms,问题几乎一定出在线程栈深度或线程数量上,而不是堆大小。这是 ZGC 调优里最反直觉的一条:堆再大也不会让暂停变长,但 5000 个工作线程会让根扫描变慢。对应的优化是减少线程数、或检查是否有深层递归导致的巨型栈。

另一个高频坑是 Allocation Stall:

[15.002s] Allocation Stall (main) 12.442ms

这说明回收速度跟不上分配速度,应用线程被迫等待。处理手段依次是:加大堆、降低分配速率(对象池/逃逸分析友好的写法)、增加 ConcGCThreads、检查是否有 -XX:SoftMaxHeapSize 设得过于激进导致堆被过度压缩。

Shenandoah 侧对应的关键是 -XX:ShenandoahGCHeuristics(adaptive / compact / static)与 -XX:ShenandoahGuaranteedGCInterval,前者决定了"什么时候触发并发周期"的策略,在高分配速率下 compact 模式会显著更频繁。

5.3 一个真实的对比结论

在某 32 核 / 64GB 堆的订单履约服务上做过的实测(JDK 21,同一份流量回放):

  • G1(MaxGCPauseMillis=100):P99 停顿 78ms,吞吐基准 100%,RSet 占堆 2.1%
  • Shenandoah(adaptive):P99 停顿 4.2ms,吞吐 88%,对象头额外开销约 3.8%
  • ZGC(generational):P99 停顿 0.6ms,吞吐 94%,额外开销 < 0.5%

数字背后的判断是:如果你的 SLA 卡在"单次停顿不能超 10ms",ZGC 是当下最省心的答案;如果你的瓶颈是吞吐而非延迟,G1 仍然是最优解。 Shenandoah 的生态位相对尴尬——它比 ZGC 兼容性好(支持压缩指针、支持更多 JDK 版本),但吞吐与内存开销都在 ZGC 之下,通常作为"ZGC 不可用时的低延迟备选"。

六、结语:屏障设计的本质是"信息放在哪里"

回过头看,ZGC 与 Shenandoah 的分歧不在于算法,而在于一个工程哲学问题:GC 需要知道的那一个比特("这个对象搬家了吗"),应该存在引用的一侧,还是被引用的一侧?

存在引用侧(ZGC),你需要指针里有空位,换来零内存开销和极低的屏障成本,但牺牲了压缩指针和某些架构的可移植性。存在被引用侧(Shenandoah),你获得了架构中立性和压缩指针兼容,代价是每对象一个字的内存和一次额外的内存解引用。

理解这一点,比记住任何一行 JVM 参数都重要——因为下一次当你面对一个新的运行时(Go、Rust 的 arc-swap、甚至一个自研的对象存储引擎)需要解决"并发移动/替换数据"的问题时,你会立刻认出这是同一个问题的另一个化身,并且知道该问的第一个问题是:我的"转发指针"应该放在哪里?


*参考:JDK 21+ ZGC 设计文档、Shenandoah Wiki(Brooks Pointer / SATB)、HotSpot src/hotspot/share/gc/ 源码树(zbarrier.cpp、shenandoahBarrierSet.cpp、g1BarrierSet.cpp)。*

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部