ZGC:着色指针 + 读屏障 = 停顿 <1ms
一句话结论(30s)
ZGC 的本质是把 GC 元数据编码进指针高 4 位的”着色指针”,再配合读屏障把对象转移从 STW 变成并发,因为指针自带 Remapped/Marked 状态,应用线程读到已移动对象时可自行修正引用(自愈)。关键设计是读屏障自愈 + Marked0/Marked1 交替使用免重置,实现停顿 <1ms 且不随堆增长。权衡在于指针让出 4 位、实际寻址降到约 8TB,且每次读引用都要过一次屏障。
核心原理(2min)
主流程:ZGC 把 64 位指针高 4 位用于 GC 元数据(Finalizable/Remapped/Marked0/Marked1),应用线程读对象引用时读屏障检查这些位——对象已移动则 remap 修正,处于标记阶段则标记并可能转移,同时把修正后的地址写回字段(自愈)。关键机制:读屏障自愈让 GC 线程批量转移对象时应用线程无需 STW,Marked0/Marked1 交替避免每轮重置标记位;代价是寻址能力从 64 位降到约 43 位(8TB)。
底层深入(5-10min)
ZGC 的设计目标
G1 的停顿随堆大小线性增长(200ms @ 几十 GB 堆)。ZGC 的目标:停顿时间不随堆大小增长,始终 <1ms。
着色指针(Colored Pointers)
ZGC 把 GC 元数据编码到 64 位指针的高 4 位:
64 位指针布局:
[4 bit GC metadata][未使用][46 bit 实际地址]
4 bit metadata:
Finalizable - 对象有 finalize 方法
Remapped - 引用已指向新位置
Marked0 - 标记状态 0
Marked1 - 标记状态 1(交替使用,无需重置标记)
💭 思考:为什么标记位要准备 Marked0 / Marked1 两个,而不是一个位每轮清零?——标记是每轮 GC 都要做的,如果只有一个标记位,每轮结束都得遍历整个堆把所有对象的标记位清零,堆越大这笔”重置”越贵。准备两个位轮流用,本轮用 Marked0、下轮用 Marked1,直接换位就相当于”清空”,完全省掉全堆重置。这正是 ZGC 追求”停顿不随堆增长”的一个缩影——连标记重置这种 O(堆) 的活都要绕开。
这里先问一句:ZGC 为什么非要把 GC 元数据塞进指针,而不是像 G1 那样写在对象头里? 因为 ZGC 要做”并发转移”——对象在应用线程还在跑的时候就被搬走,如果状态只放在对象头里,应用线程拿到一个旧地址时无从判断”这个对象是否已被搬走、新家在哪”。把状态编码进指针,应用线程每次读引用时看一眼指针自己带的位,就能当场发现”这个对象移动过”并修正,这才让”无 STW 的并发转移”成为可能。想通这一点,再看下面的读屏障就顺了。
代价:实际寻址能力从 64 位降到 ~46 位(64TB 地址空间)。Linux 默认只给用户态 47 位地址空间,ZGC 再占 4 位 → 实际可用约 43 位(8TB),对绝大多数应用够用。
💭 思考:为什么 ZGC 愿意让出 4 位寻址空间,把 64 位降到约 8TB?——要从它想换到什么反推:让出这 4 位,换来”指针自带 GC 状态”,再配合读屏障自愈,才能把对象转移从 STW 变成并发,最终拿到”停顿 <1ms 且不随堆增长”。而 8TB 对绝大多数应用早已够用,G1 在几十 GB 堆上就 200ms 停顿的真实痛点却天天可见。用一个几乎用不满的资源,去换一个高频痛点,这笔买卖显然划算;反过来,若死守 64 位寻址,就只能像 G1 那样 STW 转移,停顿随堆线性增长。
当前主线源码(JDK 21+ 分代 ZGC)的真实位布局——定义在 zAddress.hpp。分代 ZGC 把元数据位从经典设计的高位挪到了指针低位,并扩展为 16 位(RRRRMMmmFFrr0000),逐字摘录如下:
// Layout of metadata bits in colored pointer / zpointer.
//
// A zpointer is a combination of the address bits (heap base bit + offset)
// and two low-order metadata bytes, with the following layout:
//
// RRRRMMmmFFrr0000
// **** : Used by load barrier
// ********** : Used by mark barrier
// ************ : Used by store barrier
// **** : Reserved bits
constexpr uintptr_t z_pointer_mask(size_t shift, size_t bits) {
return (((uintptr_t)1 << bits) - 1) << shift;
}
constexpr uintptr_t z_pointer_bit(size_t shift, size_t offset) {
return (uintptr_t)1 << (shift + offset);
}
// Reserved bits
const size_t ZPointerReservedShift = 0;
const size_t ZPointerReservedBits = 4;
const uintptr_t ZPointerReservedMask = z_pointer_mask(ZPointerReservedShift, ZPointerReservedBits);
const uintptr_t ZPointerReserved0 = z_pointer_bit(ZPointerReservedShift, 0);
const uintptr_t ZPointerReserved1 = z_pointer_bit(ZPointerReservedShift, 1);
const uintptr_t ZPointerReserved2 = z_pointer_bit(ZPointerReservedShift, 2);
const uintptr_t ZPointerReserved3 = z_pointer_bit(ZPointerReservedShift, 3);
// Remembered set bits
const size_t ZPointerRememberedShift = ZPointerReservedShift + ZPointerReservedBits;
const size_t ZPointerRememberedBits = 2;
const uintptr_t ZPointerRememberedMask = z_pointer_mask(ZPointerRememberedShift, ZPointerRememberedBits);
const uintptr_t ZPointerRemembered0 = z_pointer_bit(ZPointerRememberedShift, 0);
const uintptr_t ZPointerRemembered1 = z_pointer_bit(ZPointerRememberedShift, 1);
// Marked bits
const size_t ZPointerMarkedShift = ZPointerRememberedShift + ZPointerRememberedBits;
const size_t ZPointerMarkedBits = 6;
const uintptr_t ZPointerMarkedMask = z_pointer_mask(ZPointerMarkedShift, ZPointerMarkedBits);
const uintptr_t ZPointerFinalizable0 = z_pointer_bit(ZPointerMarkedShift, 0);
const uintptr_t ZPointerFinalizable1 = z_pointer_bit(ZPointerMarkedShift, 1);
const uintptr_t ZPointerMarkedYoung0 = z_pointer_bit(ZPointerMarkedShift, 2);
const uintptr_t ZPointerMarkedYoung1 = z_pointer_bit(ZPointerMarkedShift, 3);
const uintptr_t ZPointerMarkedOld0 = z_pointer_bit(ZPointerMarkedShift, 4);
const uintptr_t ZPointerMarkedOld1 = z_pointer_bit(ZPointerMarkedShift, 5);
// Remapped bits
const size_t ZPointerRemappedShift = ZPointerMarkedShift + ZPointerMarkedBits;
const size_t ZPointerRemappedBits = 4;
const uintptr_t ZPointerRemappedMask = z_pointer_mask(ZPointerRemappedShift, ZPointerRemappedBits);
const uintptr_t ZPointerRemapped00 = z_pointer_bit(ZPointerRemappedShift, 0);
const uintptr_t ZPointerRemapped01 = z_pointer_bit(ZPointerRemappedShift, 1);
const uintptr_t ZPointerRemapped10 = z_pointer_bit(ZPointerRemappedShift, 2);
const uintptr_t ZPointerRemapped11 = z_pointer_bit(ZPointerRemappedShift, 3);
// Barrier metadata masks
const uintptr_t ZPointerLoadMetadataMask = ZPointerRemappedMask;
const uintptr_t ZPointerMarkMetadataMask = ZPointerLoadMetadataMask | ZPointerMarkedMask;
const uintptr_t ZPointerStoreMetadataMask = ZPointerMarkMetadataMask | ZPointerRememberedMask;
const uintptr_t ZPointerAllMetadataMask = ZPointerStoreMetadataMask;
// Colored oop
enum class zpointer : uintptr_t { null = 0 };
// Uncolored oop - safe to dereference
enum class zaddress : uintptr_t { null = 0 };
分析:这里最关键的是 zpointer(带颜色的 oop)和 zaddress(去掉颜色、可安全解引用的真实地址)被定义为两个不同的枚举类型,从类型系统上就杜绝了”拿带着 GC 位指针直接解引用”。每个元数据位都用 z_pointer_mask(shift, bits) 按”位移 + 位宽”计算,位段之间靠 shift 首尾相接、互不重叠,所以同一根指针里能同时容纳 Reserved/Remembered/Marked(含 Finalizable、MarkedYoung、MarkedOld)/Remapped 多组状态。经典 ZGC 把 4 个元数据位放在高位,分代 ZGC 为了配合 x86 上单条移位指令把颜色挪到低位并扩到 16 位——但”指针本身携带 GC 状态”这一核心思想完全一致。
读屏障(Load Barrier)
应用线程读对象引用时,读屏障自动检查指针的 GC 元数据:
// ZGC 读屏障伪码
Object* load_barrier(Object** field) {
Object* ptr = *field;
if (ptr & REMAPPED_MASK) // 对象已移动
return remap(ptr); // 修正为正确的地址
if (ptr & MARKED_MASK) // 对象在标记阶段
return mark_and_relocate(ptr); // 标记 + 可能移动
return ptr; // 正常引用
}
真实实现分布在两个文件:zBarrierSet.inline.hpp 负责按引用强度(强/弱/虚)分发,zBarrier.inline.hpp 负责快路径判断 + 慢路径 relocate/remap + 自愈写回。逐字摘录如下:
// zBarrierSet.inline.hpp —— 读屏障入口,按引用强度分发
template <DecoratorSet decorators, typename BarrierSetT>
inline zaddress ZBarrierSet::AccessBarrier<decorators, BarrierSetT>::load_barrier(zpointer* p, zpointer o) {
verify_decorators_absent<ON_UNKNOWN_OOP_REF>();
if (HasDecorator<decorators, AS_NO_KEEPALIVE>::value) {
if (HasDecorator<decorators, ON_STRONG_OOP_REF>::value) {
// Load barriers on strong oop refs don't keep objects alive
return ZBarrierSet::load_barrier_on_oop_field_preloaded(p, o);
} else if (HasDecorator<decorators, ON_WEAK_OOP_REF>::value) {
return ZBarrierSet::no_keep_alive_load_barrier_on_weak_oop_field_preloaded(p, o);
} else {
assert((HasDecorator<decorators, ON_PHANTOM_OOP_REF>::value), "Must be");
return ZBarrierSet::no_keep_alive_load_barrier_on_phantom_oop_field_preloaded(p, o);
}
} else {
if (HasDecorator<decorators, ON_STRONG_OOP_REF>::value) {
return ZBarrierSet::load_barrier_on_oop_field_preloaded(p, o);
} else if (HasDecorator<decorators, ON_WEAK_OOP_REF>::value) {
return ZBarrierSet::load_barrier_on_weak_oop_field_preloaded(p, o);
} else {
assert((HasDecorator<decorators, ON_PHANTOM_OOP_REF>::value), "Must be");
return ZBarrierSet::load_barrier_on_phantom_oop_field_preloaded(p, o);
}
}
}
// zBarrier.inline.hpp —— 强引用读屏障:快路径 + 慢路径 + 自愈
inline zaddress ZBarrier::load_barrier_on_oop_field(volatile zpointer* p) {
const zpointer o = load_atomic(p);
return load_barrier_on_oop_field_preloaded(p, o);
}
inline zaddress ZBarrier::load_barrier_on_oop_field_preloaded(volatile zpointer* p, zpointer o) {
auto slow_path = [](zaddress addr) -> zaddress {
return addr;
};
return barrier(is_load_good_or_null_fast_path, slow_path, color_load_good, p, o);
}
template <typename ZBarrierSlowPath>
inline zaddress ZBarrier::barrier(ZBarrierFastPath fast_path, ZBarrierSlowPath slow_path, ZBarrierColor color, volatile zpointer* p, zpointer o, bool allow_null) {
z_verify_safepoints_are_blocked();
// Fast path
if (fast_path(o)) {
return ZPointer::uncolor(o);
}
// Make load good
const zaddress load_good_addr = make_load_good(o);
// Slow path
const zaddress good_addr = slow_path(load_good_addr);
// Self heal
if (p != nullptr) {
// Color
const zpointer good_ptr = color(good_addr, o);
assert(!is_null(good_ptr), "Always block raw null");
self_heal(fast_path, p, o, good_ptr, allow_null);
}
return good_addr;
}
inline zaddress ZBarrier::make_load_good(zpointer o) {
if (is_null_any(o)) {
return zaddress::null;
}
if (ZPointer::is_load_good_or_null(o)) {
return ZPointer::uncolor(o);
}
return relocate_or_remap(ZPointer::uncolor_unsafe(o), remap_generation(o));
}
inline void ZBarrier::self_heal(ZBarrierFastPath fast_path, volatile zpointer* p, zpointer ptr, zpointer heal_ptr, bool allow_null) {
if (!allow_null && is_null_assert_load_good(heal_ptr) && !is_null_any(ptr)) {
// Never heal with null since it interacts badly with reference processing.
// A mutator clearing an oop would be similar to calling Reference.clear(),
// which would make the reference non-discoverable or silently dropped
// by the reference processor.
return;
}
assert_is_valid(ptr);
assert_is_valid(heal_ptr);
assert(!fast_path(ptr), "Invalid self heal");
assert(fast_path(heal_ptr), "Invalid self heal");
assert(ZPointer::is_remapped(heal_ptr), "invariant");
for (;;) {
assert_transition_monotonicity(ptr, heal_ptr);
// Heal
const zpointer prev_ptr = AtomicAccess::cmpxchg(p, ptr, heal_ptr, memory_order_relaxed);
if (prev_ptr == ptr) {
// Success
return;
}
if (fast_path(prev_ptr)) {
// Must not self heal
return;
}
// The oop location was healed by another barrier, but still needs upgrading.
// Re-apply healing to make sure the oop is not left with weaker (remapped or
// finalizable) metadata bits than what this barrier tried to apply.
ptr = prev_ptr;
}
}
分析:barrier() 的第一行就是快路径——若 fast_path(o)(即 is_load_good_or_null)成立,直接 ZPointer::uncolor(o) 抹掉颜色位返回真实地址,正常引用几乎零开销。只有元数据位不是 “load good”(对象已移动、需要 remap)时才进慢路径,make_load_good() 调 relocate_or_remap 顺着 forwarding 指针找到对象新位置。而”读时自愈”落在 self_heal():它用 CAS(AtomicAccess::cmpxchg)把重新着色后的新指针写回字段,成功即完成自愈;若已被别的线程抢先治愈,则升级到更强的元数据状态后重试,保证字段最终收敛到 “load good”。
关键创新:读屏障是”自愈”的——第一次读取已移动对象时,读屏障修正引用并更新字段,后续读取都不再经过屏障。这实现了并发转移(转移对象时应用线程仍可运行)。
再停一下:读屏障为什么能”自愈”?为什么修一次就够、而不是每次读都要修正? 因为读屏障在慢路径结束时,会用 CAS 把”重新着色后的新指针”写回原字段——这一写就把”旧地址”换成了”新地址”,之后任何线程再读这个字段拿到的已经是好指针,直接走快路径返回,自然不再过慢路径。这正是 ZGC 能”并发转移”的关键:GC 线程批量搬对象,应用线程第一次撞上旧指针时自己顺手把引用修好,两边谁都不用停下来等对方。
并发转移 vs G1 的 STW 转移
G1 的转移阶段需要 STW——暂停所有应用线程,将存活对象从待回收 Region 拷贝到新 Region。ZGC 的转移是并发的——应用线程读屏障自愈 + GC 线程批量转移,全程无 STW。
这里再追问一层:为什么 G1 的转移必须 STW,而 ZGC 却能做到并发? 因为 G1 没有”着色指针”这种把状态挂在引用本身的手段——它搬对象后,得靠 STW 期间统一修正所有指向旧位置的指针,否则应用线程会拿到失效地址;而 ZGC 把”这个对象搬过没有、新家在哪”的信息编码进指针,应用线程在读的那一刻自行修正,于是转移和修引用都不需要暂停世界。
总结
| G1 | ZGC | |
|---|---|---|
| 停顿 | 随堆增长(200ms @ 几十GB) | <1ms 不随堆增长 |
| 指针开销 | 0(64 位全用于寻址) | 4 bit 用于 GC 元数据 |
| 转移 | STW | 并发(读屏障自愈) |
| 适用堆 | 几 GB ~ 几十 GB | 几百 GB ~ TB 级 |
章末提问
- “ZGC 的停顿为什么能 <1ms,还和堆大小无关?” 结论先行:因为它把转移从 STW 变成了并发,停顿只剩扫描 GC Roots 等必须暂停的少量工作,不随堆增长。因为读屏障让应用线程在读取时自行修正已移动对象,转移和修引用都不需要停下来等全堆。
- “着色指针把 4 位拿去做元数据,会不会浪费寻址能力?” 结论先行:会,但可接受,因为实际寻址仍够约 8TB。因为 Linux 用户态本就用不满 64 位地址空间,让出的 4 位换来了并发转移能力,这个交换是划算的。
- “为什么 Marked0 和 Marked1 要交替使用?” 结论先行:为了省掉每轮 GC 后重置标记位的开销。因为标记状态用两个位轮流表达,下一轮直接切换使用另一位,就无需遍历堆把所有对象的标记位清零。
- “读屏障每读一次引用都检查一遍,性能扛得住吗?” 结论先行:扛得住,因为快路径只有一个判断加一次位运算,正常引用几乎零开销。因为绝大多数时候对象没移动、指针是 load good 状态,走快路径直接返回,只有真正需要 remap/relocate 的慢路径才付出代价。
- “用一句话说 ZGC 和 G1 的核心区别?” 结论先行:G1 靠 STW 转移、停顿随堆增长,ZGC 靠着色指针加读屏障把转移并发化、停顿不随堆增长。因为二者对”对象被搬走后指针怎么修正”给出了不同答案:G1 暂停统一修,ZGC 读到当场自愈。