ZGC(Z Garbage Collector)是 JDK 11 引入的低延迟 GC,目标是任意堆大小下暂停时间 < 10ms。核心创新是染色指针(Colored Pointers):将 GC 元数据直接编码进对象指针,通过**负载屏障(Load Barrier)**在读取指针时动态修正,实现几乎完全并发的标记和压缩。
相关文章:三色标记与写屏障 · 分代 GC · G1 GC(Region-based)
目录
| 章节 | 说明 |
|---|---|
| 核心挑战:并发压缩 | 为什么其他 GC 做不到并发移动对象 |
| 染色指针 | 4 个元数据位的含义 |
| 负载屏障 | 读指针时的修正逻辑 |
| ZGC 的完整流程 | 3 次极短 STW + 全程并发 |
| 完整伪代码 | 标记 / 重定位 / 负载屏障 |
| 执行追踪 | 一次完整 ZGC 循环 |
| 异常场景 | 指针位耗尽 / 并发 Allocation Stall |
| ZGC vs G1 vs Shenandoah | 技术路线对比 |
核心挑战:并发压缩
所有低延迟 GC 都要解决的根本问题:
GC 在移动对象的同时,用户程序也在读写这些对象
如果 GC 把对象从地址 A 移到地址 B:
用户程序读 A → 读到旧地址 → 用旧指针访问 → 数据错误或崩溃
传统解法(G1 / CMS):
STW(暂停用户程序)期间才移动对象
→ 安全,但暂停时间随存活对象数线性增长
ZGC 的解法:
利用染色指针 + 负载屏障:
"当用户程序读到旧指针时,负载屏障自动修正为新地址"
→ 用户程序无感知,GC 和用户程序真正并发运行
染色指针
64-bit 指针的空间借用
x86-64 架构的虚拟地址空间:
理论上 64 bit,但实际 CPU 只使用低 47 bit(128 TB 用户空间)
高 17 bit 和次高 4 bit 是冗余的(OS 不使用)
ZGC 借用 4 个 bit(第 42~45 bit)存储元数据:
63 46 45 44 43 42 41 0
├─────────┤──┤──┤──┤──┤──────────────┤
│ 未使用 │fin│rel│M1 │M0 │ 对象地址 │
↑ ↑ ↑ ↑
FinalizableMark
Remapped
Marked1
Marked0
Marked0 / Marked1:标记位,两位交替使用(GC 周期间轮换)
Remapped:已重映射(对象已移动到新地址,指针已更新)
Finalizable:对象只有 Finalizer 可达(待 Finalize)
为什么需要两个标记位(M0 和 M1)?
GC 周期 N:使用 Marked0 表示"存活"
GC 周期 N+1:使用 Marked1 表示"存活"
交替使用,避免清除标记的代价(只需要更换"当前有效位"的定义)
对比三色标记:
传统 GC:白色/灰色/黑色 存在对象头(需要 STW 重置)
ZGC:颜色存在指针中(更换有效位,O(1) 全局重置)
负载屏障
负载屏障在读取对象指针时执行(G1 写屏障在写时执行,ZGC 是读时):
# 编译器在每次 obj.field 读取处插入:
# (只对堆引用插入,不对原始类型、null 等插入)
def load_barrier(obj, field_offset) -> pointer:
ptr = raw_load(obj, field_offset) # 从内存直接读指针
if ptr is null:
return null
if ptr.color == EXPECTED_COLOR: # 颜色正确(本 GC 周期的有效颜色)
return ptr # 快路径,几乎无开销
# 慢路径:颜色不对,需要修正
return slow_path_fix(ptr)
def slow_path_fix(ptr):
# 情况1:标记阶段,指针未被标记
if not ptr.is_marked():
gray_queue.push(ptr) # 加入标记队列(帮助 GC 标记)
return mark(ptr) # 返回标记后的指针
# 情况2:重定位阶段,指针未被更新(对象已移动,但指针还指旧地址)
if not ptr.is_remapped():
new_ptr = forwarding_table.lookup(ptr) # 查转发表
cas_update(obj, field_offset, ptr, new_ptr) # 原子更新指针
return new_ptr
return ptr
负载屏障的代价:
每次读堆引用都执行检查(但快路径极短,约 2~3 条额外指令)
G1 对比(写屏障):
写比读少,写屏障影响的代码路径更少
但 ZGC 负载屏障通过颜色检查快速跳过慢路径,实际开销可控
测量:ZGC 负载屏障带来约 10~15% 的吞吐量损失(vs G1 约 5~10%)
ZGC 的完整流程
ZGC 的 GC 周期(大部分工作并发,3 次极短 STW):
Phase 1: Pause Mark Start(STW,~1ms)
标记所有 GC Roots(线程栈、JNI 引用等)
颜色:切换"当前标记颜色"(Marked0 ↔ Marked1 交替)
Phase 2: Concurrent Mark(并发,~秒级)
遍历所有存活对象,将指针染为"当前标记颜色"
负载屏障帮助:用户程序读到未标记的指针时,会帮助 GC 标记
→ GC 和用户程序协作完成标记
Phase 3: Pause Mark End(STW,~1ms)
处理 GC 线程的残余工作(少量 SATB 缓冲区)
确保所有存活对象都被标记
Phase 4: Concurrent Prepare for Relocation(并发)
分析各 ZPage 的存活率
选择要回收的 ZPage(与 G1 类似,选垃圾最多的)
为选中的 ZPage 构建转发表(Forwarding Table)
Phase 5: Pause Relocate Start(STW,~1ms)
标记 GC Roots 对应的 ZPage 开始重定位
(只是"开始"的标记,实际复制在并发阶段完成)
Phase 6: Concurrent Relocate(并发)
GC 线程将存活对象从旧 ZPage 复制到新 ZPage
转发表记录旧地址 → 新地址的映射
同时:用户程序通过负载屏障的慢路径自助更新指针
Phase 7: Concurrent Remap(并发,下一周期的一部分)
扫描所有堆引用,将剩余旧指针更新为新地址
清除转发表
完成后,当前 ZPage 可以完全回收
完整伪代码
ZPage 和转发表
class ZPage:
start: int # 页起始地址
end: int # 页结束地址
live_map: bitset # 哪些对象存活(并发标记时维护)
class ForwardingTable:
# 哈希表:旧地址 → 新地址
def lookup(old_addr) -> new_addr: ...
def insert(old_addr, new_addr): ...
Concurrent Relocate(核心)
def concurrent_relocate(zpages_to_relocate):
for zpage in zpages_to_relocate:
new_zpage = allocate_new_zpage()
ft = ForwardingTable()
forwarding_tables[zpage.start] = ft
for obj in zpage.live_objects():
# 复制对象到新页
new_addr = new_zpage.allocate(obj.size())
copy(obj, new_addr)
ft.insert(obj.address, new_addr)
# 在旧地址安装转发指针(用于负载屏障查询)
obj.install_forwarding_ptr(new_addr)
# 旧 ZPage 标记为"可重用"(但转发表还在)
zpage.release()
负载屏障慢路径(完整版)
def slow_path_fix(ptr, obj, field_offset):
# 提取真实地址(去掉颜色位)
real_addr = ptr.strip_color_bits()
# 查询转发表(对象是否已被移动?)
ft = forwarding_tables.get(page_of(real_addr))
if ft is not None:
new_addr = ft.lookup(real_addr)
if new_addr is not None:
# 对象已移动,修复指针
new_ptr = new_addr | CURRENT_COLOR_BITS
# CAS 原子更新:避免并发重复修复
cas_update(obj, field_offset, ptr, new_ptr)
return new_ptr
# 对象未被移动,只需染色
new_ptr = real_addr | CURRENT_COLOR_BITS
cas_update(obj, field_offset, ptr, new_ptr)
return new_ptr
执行追踪
32GB 堆,1000 个 ZPage(32MB/page),目标暂停 < 10ms:
T=0ms: Pause Mark Start(STW=1ms)
标记 GC Roots,切换标记颜色从 M0 → M1
T=1ms ~ T=800ms: Concurrent Mark(并发约 800ms)
GC 线程遍历对象图,将存活对象的指针标记为 M1
用户程序读到 M0 指针 → 负载屏障慢路径 → 帮助标记为 M1
进度:99% 的对象被 GC 线程标记,1% 靠负载屏障
T=800ms: Pause Mark End(STW=1ms)
处理残余标记任务
T=801ms ~ T=1000ms: Concurrent Prepare(并发)
分析:50 个 ZPage 死亡率 > 80%(选出待回收集)
为这 50 个 ZPage 构建转发表
T=1000ms: Pause Relocate Start(STW=1ms)
标记 Root 对应的 ZPage 开始重定位
T=1001ms ~ T=1500ms: Concurrent Relocate(并发)
GC:将 50 个 ZPage 的存活对象复制到新 ZPage(约 1.6GB 数据移动)
用户程序:每次读到旧指针,负载屏障自动修正为新地址
用户无感知!
T=1500ms: 全程 STW 总计 = 1+1+1 = 3ms ✅(远低于 10ms 目标)
异常场景
指针颜色位耗尽(64-bit 限制)
ZGC 借用 64-bit 指针的第 42~45 位(4 个 bit)
→ 最大可寻址空间 = 2^42 = 4 TB
如果堆 > 4 TB:无法使用 ZGC(颜色位空间不够)
JDK 16+ 改进(Generational ZGC):
使用页级别的映射替代指针着色
不再受 4 TB 限制
Allocation Stall(分配阻塞)
场景:并发 GC 速度 < 对象分配速度
→ Free ZPage 耗尽 → 新对象无法分配
→ 用户线程被迫等待 GC 释放空间
检测:GC 日志中 "Allocation Stall" 事件
解法:
增大堆(给 GC 更多缓冲空间)
降低 ZAllocationSpikeTolerance(提前触发 GC)
降低分配速率(减少短命大对象)
ZGC vs G1 vs Shenandoah
| 维度 | G1 | ZGC | Shenandoah |
|---|---|---|---|
| STW 暂停 | 可控(目标 200ms) | < 10ms | < 10ms |
| 吞吐量 | 高 | 中(~10% 损失) | 中(~15% 损失) |
| 内存开销 | RSet(5~10%) | 转发表(较小) | 转发指针(1 word/obj) |
| 并发压缩 | ❌ STW 复制 | ✅ 并发重定位 | ✅ 并发疏散 |
| 原理 | 写屏障 | 染色指针 + 读屏障 | Brooks 转发指针 + 读屏障 |
| 适用场景 | 通用,大堆 | 超低延迟,TB 级堆 | 低延迟,中等堆 |
| JDK 版本 | 9+ 默认 | 15+ 正式 | 15+ 正式 |
参考资料
- Liden, P. & Karlsson, B. (2018). The Z Garbage Collector - An Introduction
- JEP 333: ZGC - https://openjdk.org/jeps/333
- JEP 376: ZGC Concurrent Thread-Stack Processing - https://openjdk.org/jeps/376
评论 (0)
发表评论