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的工作。不暂停你,不拖累你,痛一次,爽永远。
这才是它快的真正原因。
关注技术号获取更多技术干货 !
