ZGC的染色指针和读屏障到底在干什么

99%的人只知道ZGC快,不知道为什么快

你一定听过这句话:

"ZGC停顿不到1毫秒,TB级堆也能扛。"

但如果追问一句------它凭什么?

G1在转移对象时必须STW,因为要更新所有引用;Shenandoah用Brooks Pointer做间接层,但每次访问都慢。ZGC既不STW,也不让你每次访问都变慢,它到底耍了什么花招?

答案藏在两个词里:Colored Pointers(染色指针)Load Barriers(读屏障)


一、先搞懂一个致命问题:对象搬家了,引用怎么办?

所有GC都面临同一个难题:标记-整理(Mark-Compact)要移动对象,但应用线程还在访问旧地址。

G1的解法:暂停所有线程(STW),统一更新引用,然后再恢复。代价是停顿时间随堆大小线性增长------堆越大,停越久。

Shenandoah的解法:不直接移动对象,而是加一层间接层。每次访问都要多跳一次,永远慢,但永远不停

ZGC的解法完全不同:

我不暂停你,也不让你每次都慢。我让你"顺便"把引用修了,而且只修一次。

怎么做到的?两步棋。


二、第一步棋:Colored Pointers------把GC状态塞进指针里

ZGC只跑在64位系统上(这是硬性条件)。

64位指针能寻址16EB,但实际应用哪用得了这么多?Linux x86-64实际只用低47位。

ZGC的做法:拿出指针的第42~45位,塞进GC元数据。

复制代码
63                    47  46 45 44 43  42 41              0
┌─────────────────────┬──┬──┬──┬──┬──┬──────────────────┐
│     未使用           │F │R │M1│M0│  │   真实地址         │
└─────────────────────┴──┴──┴──┴──┴──┴──────────────────┘
                        ↑ 元数据位(4 bits)
颜色位 含义
Marked0 当前标记周期,对象已被标记(活跃)
Marked1 上一轮标记周期的遗留标记
Remapped 对象已被重定位到新地址,引用需要修正
Finalizable 对象只能通过finalizer访问

一个指针,既是地址,又是状态机。

更绝的是,ZGC把虚拟地址空间切成了三个视图:

视图 作用
Remapped 默认视图,应用正常读写
M0 标记阶段的"工作视图"
M1 下一轮标记的"备用视图"

同一个物理对象,在三个虚拟地址上都有分身。哪个视图有效,由指针的颜色位决定。

这就是"空间换时间"的极致:不是多拷贝一份数据,而是多映射一份地址。零拷贝,纯虚拟空间操作。


三、第二步棋:Load Barrier------每次读指针,顺便修指针

光有染色指针还不够。应用线程拿到一个Remapped状态的指针,它怎么知道这个对象已经被搬家了?

答案:读屏障。JIT编译器在每一条"从堆中读取对象引用"的指令前,自动插入一小段代码。

注意:不是所有操作都加。只有从堆中读引用才触发:

java 复制代码
Object o = obj.fieldA;  //  触发读屏障(从堆读引用)
Object p = o;             //  不触发(寄存器里传,没从堆读)
o.doSomething();          //  不触发(没读引用)
int i = obj.fieldB;       // 不触发(不是对象引用)

读屏障的逻辑,在不同GC阶段做不同的事

阶段一:并发标记期

读屏障检查指针颜色:

  • 如果是Remapped → 说明这对象还没被标记,把颜色改成Marked0,加入标记队列
  • 如果已经是Marked0/Marked1 → 快路径,啥也不干

关键设计:为什么要两个Marked位(Marked0和Marked1)?

因为标记是并发的。同一对象可能被多个线程反复访问,每次都加入标记队列是纯浪费。有了两个标记位,第二次访问时发现已经标过了,直接跳过。一个引用只需要入队一次。

阶段二:并发转移期

读屏障检查指针颜色:

  • 如果是Marked0/Marked1 → 说明对象已被标记且即将转移,查Forwarding Table(转发表),拿到新地址,把指针原地改成Remapped状态
  • 如果已经是Remapped → 快路径,零开销

这就是"指针自愈":第一次访问触发修正,指针就地更新。后续所有访问全走快路径,零开销。

这跟Shenandoah的Brooks Pointer形成鲜明对比:

ZGC Load Barrier Shenandoah Brooks Pointer
首次访问 慢(走慢路径修正) 慢(多一层间接)
后续访问 快(快路径,零开销) 慢(永远多跳一次)
吞吐量损失 5%~10% 持续损耗

ZGC的哲学:痛一次,爽 forever。Shenandoah的哲学:每次都痛,但不停。


四、把两步棋合起来:一次GC周期的完整剧本

假设堆满了,触发GC:

复制代码
【STW ①】初始标记(~0.1ms)
  扫描根节点,把根直接引用的对象颜色设为 Marked0
  
【并发标记】
  应用线程正常跑,每次从堆读引用 → 读屏障检查
  没标过的?标上 Marked0,入队
  标过的?快路径,秒过
  
【STW ②】最终标记(~0.1ms)
  处理遗留的SATB问题,极短
  
【并发转移】⭐ 核心黑魔法在这里
  GC线程把存活对象复制到新分区
  应用线程读到旧地址 → 读屏障触发:
    1. 查转发表,拿到新地址
    2. 把堆上这个引用原地改成新地址(指针自愈)
    3. 颜色改成 Remapped
  对象搬完了,但引用是"顺便"修的,不需要全局STW
  
【STW ③】清理(~0.1ms)
  清理旧分区,完事

三次STW,每次不到1毫秒,加起来也就0.3ms左右。而且这个数字跟堆大小无关------16TB的堆,也是0.3ms。

这就是ZGC敢喊出"停顿不超过1ms"的底气。


五、ZGC 2.0(JDK 25):黑魔法再升级

如果你还在用JDK 17的ZGC,那你只看到了第一代黑魔法。

JDK 25的ZGC 2.0做了几件狠事:

能力项 ZGC 1.x(JDK 11~24) ZGC 2.0(JDK 25)
最大堆 ≤16TB ≤64TB(实测稳定)
类卸载 部分并发+阶段性STW 全程并发,零STW
99.9%分位停顿 <10ms <1ms(堆≤32GB)
读屏障开销 基线 ↓ 约40%(ARM SVE2/x86 CET硬件加速)

最关键的进化:读屏障引入了硬件辅助优化

x86上利用CET(Control-flow Enforcement Technology),ARM上用SVE2指令集,让颜色位检查从"访存+分支"变成"纯寄存器位运算",延迟直接砍掉四成。

另外,ZGC 2.0默认开启并发类卸载,再也不会因为元空间压力触发Full GC回退了。

启动方式极其简单:

bash 复制代码
java -XX:+UseZGC -Xmx16g MyApp.jar
# 就这一行,全部增强特性默认生效

六、几个你必须知道的坑

说明
必须关压缩指针 -XX:-UseCompressedOops,否则高位被压缩掉,染色失效
只支持64位 32位系统没有足够的位来存颜色,别想了
JNI要守规矩 别用GetPrimitiveArrayCritical,别做直接指针算术,会绕过读屏障读到脏数据
不是银弹 ZGC牺牲5%~10%吞吐量换延迟,高吞吐批处理不如G1

七、一张图看懂全貌

复制代码
        应用线程从堆中读取引用
                 │
                 ▼
        ┌─────────────────┐
        │   Load Barrier   │  ← JIT注入的读屏障
        │   检查颜色位      │
        └────────┬────────┘
                 │
        ┌────────┴────────┐
        ▼                 ▼
   【标记阶段】        【转移阶段】
   颜色=Remapped?     颜色=Marked0/1?
   → 改为Marked0      → 查转发表
   → 入标记队列        → 指针原地修正为新地址
   → 快路径通过        → 颜色改为Remapped
                      → 快路径通过(自愈完成)
                 │
                 ▼
        后续访问全部走快路径
        零开销,零分支,零访存

最后

很多人背ZGC的特性:低延迟、大堆、并发标记整理。

但如果你不理解染色指针为什么能把GC状态塞进地址里,不理解读屏障为什么能"顺便"把引用修了还只修一次------你背的是结论,不是认知。

ZGC真正的黑魔法不是某个技术,而是一种设计哲学:

让应用线程在每次正常访问中,顺便完成GC的工作。不暂停你,不拖累你,痛一次,爽永远。

这才是它快的真正原因。


关注技术号获取更多技术干货 !