JVM ZGC 深度实战:染色指针、负载屏障与并发转移的实现原理与调优
ZGC 是 Oracle 于 2018 年推出的超低延迟垃圾收集器,目标是将 GC 停顿时间控制在 1ms 以内,无论堆大小是 4GB 还是 16TB。从 JDK 15 开始正式生产可用,到 JDK 21 成为默认收集器,ZGC 的架构设计代表了一种完全不同的 GC 哲学——用 CPU 换延迟。
一、为什么需要 ZGC:延迟与吞吐量的博弈
传统的 GC 设计在延迟和吞吐量之间走钢丝。G1 GC 通过分区收集将停顿预测在 200ms 以内,但对于交易系统、实时广告竞价、游戏服务器这类 P99 延迟要求 < 10ms 的场景来说,200ms 的 stop-the-world 是不可接受的。
ZGC 的设计目标非常激进:
| 指标 | G1 GC | ZGC |
|---|---|---|
| 最大停顿时间 | ~200ms(可设目标) | < 1ms |
| 支持堆大小 | ~32GB 推荐 | 最大 16TB |
| 吞吐量影响 | < 10% | ~15% |
| 停顿时间是否随堆增长 | 是 | 否 |
这种"停顿时间不随堆增长"的特性来自 ZGC 的核心创新:染色指针(Colored Pointers) 和 并发转移(Concurrent Compaction)。
二、染色指针:存储在指针中的元数据
2.1 什么是染色指针
传统 GC 将对象标记信息存储在对象头(Mark Word)中,或使用独立的标记位图(Bitmap)。ZGC 反其道而行——将标记信息直接编码在 64 位指针的冗余位中。
64 位系统虽然支持 64 位地址空间,但当前 x86_64 只使用 48 位(256TB 虚拟地址),ARM64 使用 48 位或 52 位(取决于配置)。ZGC 利用剩余的高 16 位存储元数据(JDK 21 后利用 52 位地址时也有适配)。
64位指针布局 (x86_64, 48位地址):
┌──┬───────────────────────────────────────────────┬──┬──┬──┐
│ 0│ 43位实际地址 (4TB空间) │ F│ M│ R│
│ │ │ i│ a│ e│
│ │ │ n│ r│ m│
│ │ │ a│ k│ a│
│ │ │ l│ │ p│
└──┴───────────────────────────────────────────────┴──┴──┴──┘
M0 M1
位含义:
Finalizable (Bit 0): 对象需finalize
Remap (Bit 1): 对象已被转移,需重定向
Mark0 (Bit 2): 标记位0
Mark1 (Bit 3): 标记位1
2.2 三色标记与双色位
ZGC 使用 Mark0 和 Mark1 两个标记位来追踪对象状态,这实现了多轮标记无需重置:
- 新标记周期:使用 Mark0(01 表示已标记)
- 下一周期:切换使用 Mark1(10 表示已标记)
- 再下一周期:切回 Mark0
这种交替使用避免了"重置标记位"这个需要 stop-the-world 的阶段。
// 伪代码:染色指针操作
#define Z_ADDRESS_SHIFT 0 // 实际地址位移
#define Z_MARK_BIT_SHIFT 42 // 标记位起始位置
#define Z_POINTER_MARKED0(p) ((uintptr_t)(p) & 0x100000000000)
#define Z_POINTER_MARKED1(p) ((uintptr_t)(p) & 0x200000000000)
#define Z_POINTER_REMAPED(p) ((uintptr_t)(p) & 0x400000000000)
// 读取指针(屏蔽染色位获取真实地址)
#define Z_POINTER_PTR(p) ((uintptr_t)(p) & Z_ADDRESS_MASK)
// 标记操作
inline void set_mark0(uintptr_t* p) { __atomic_or_fetch(p, MARK0, memory_order_relaxed); }
inline void set_remap(uintptr_t* p) { __atomic_or_fetch(p, REMAP, memory_order_relaxed); }
2.3 虚拟内存映射的妙用
ZGC 还有一个精妙设计——多映射(Multi-Mapping)。同一个物理内存被映射到三个不同的虚拟地址视图:
- Marked0 视图:对应 Mark0 标记
- Marked1 视图:对应 Mark1 标记
- Remapped 视图:对象最终地址
// 启动时建立三重映射
void zvirtual_map_three_views(uintptr_t physical_addr, size_t size) {
// 映射到 Marked0 虚拟地址
void* marked0 = mmap(MARKED0_VADDR, size, PROT_READ|PROT_WRITE,
MAP_FIXED|MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
struct remap_info info = { .phys = physical_addr };
ioctl(fd, MAP_FROM_PHYSICAL, &info); // 伪系统调用
// 映射到 Marked1 虚拟地址
mmap(MARKED1_VADDR, size, PROT_READ|PROT_WRITE, ...);
// 映射到 Remapped 虚拟地址
mmap(REMAP_VADDR, size, PROT_READ|PROT_WRITE, ...);
}
这带来的好处是:当并发转移完成,只需更新指针中的 Remap 标记位,所有视图自然"看到"新地址。
三、负载屏障:读操作中的写时复制逻辑
3.1 为什么需要负载屏障
ZGC 的并发转移意味着:mutator(业务线程)在 GC 转移对象时可能读到旧地址。如何让 mutator 也能看到新对象?
答案是在读指针时插入检查代码——这就是负载屏障(Load Barrier)。
传统 GC 用写屏障(Write Barrier)来捕捉引用变化。ZGC 因为是并发转移而非并发标记,需要的是读屏障。
3.2 硬件支持的快速路径
ZGC 的负载屏障非常轻量。x86_64 上使用 test 指令检查 Remap 标记位:
; 假设从对象 field 中加载指针到 rax
mov rax, [rbx + offset] ; 正常读取对象引用
; ZGC 负载屏障快速路径
test rax, REMAP_MASK ; 检查 Remap 位
jz skip_slow_path ; 如果未设置,直接跳过硬性走快速路径
call z_load_barrier_slow ; 慢路径:转发指针到转移后的对象
skip_slow_path:
; 继续正常执行,rax 中已是正确指针
3.3 慢路径实现
当 Remap 位已设置时,mutator 需要执行指针转发:
// ZGC Load Barrier 慢路径
void* Z_load_barrier_slow(void* raw_ptr) {
// 解析指针中的真实地址(屏蔽染色位)
uintptr_t addr = Z_POINTER_PTR(raw_ptr);
// 检查对象是否已被转移
// 通过 forwarding pointer 找到新地址
HeapObject* obj = (HeapObject*)addr;
ForwardingEntry* fwd = obj->forwarding();
if (fwd != NULL) {
// 返回转移后的新地址
return (void*)fwd->new_address;
}
// 对象还未被转移但已被标记为待转移
// 尝试帮助转移(ZGC 的自愈特性)
z concurrent_relocate(obj);
return obj->forwarding()->new_address;
}
3.4 JIT 中的内联优化
C2 编译器会帮助优化负载屏障。对于可以确定不在转移中的对象,JIT 会消除冗余的 barrier:
// 业务代码
MyObject ref = head.next; // 触发 load barrier:检查 Remap 位
ref.doSomething(); // 同一对象再次访问,barrier 可能被消除
// JIT 优化后,第二次访问不再检查 Remap
// 因为:如果 ref 不在转移中,barrier 永远返回原值
// 如果 ref 在转移中,第一次 barrier 已经返回新地址
// 新地址是 stable 的,不会被二次转移
四、GC 周期的完整流程
4.1 阶段概览
一个完整的 ZGC 周期分三个阶段,只有其中两个阶段有极短的 STW:
┌─────────────────────────────────────────────────────────────────────┐
│ ZGC 收集周期时间线 │
├──────────┬──────────┬──────────┬──────────┬──────────┬──────────────┤
│ STW │ Concurrent│ STW │ Concurrent│ │ │
│ Pause │ Mark │ Pause │ Relocate │ Running │ │
│ Mark │ │ Relocate │ │ │ │
│ Start │ │ Start │ │ │ │
│ (~0.02ms)│ │ (~0.5ms) │ │ │ │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────────┘
| 阶段 | 操作 | 暂停时间 |
|---|---|---|
| Pause Mark Start | 初始根可达扫描 | < 0.1ms |
| Concurrent Mark | 遍历对象图标记 | 无 |
| Pause Mark End | 等待弱引用处理 | < 0.1ms |
| Concurrent Prepare Relocate | 选择转移集 | 无 |
| Pause Relocate Start | 根引用转发 | < 0.5ms |
| Concurrent Relocate | 转移对象 | 无 |
4.2 初始标记(Pause Mark Start)
这是第一个 STW 阶段,只扫描 GC Roots(线程栈帧、全局变量等),不涉及堆遍历:
void Z_pause_mark_start() {
// 1. 停止所有 mutator(stop-the-world)
SafepointSynchronize::begin();
// 2. 扫描线程栈,标记根可达对象
for (JavaThread *thread = Threads::first(); thread; thread = thread->next()) {
// 遍历栈帧,找到引用变量
for (StackFrame frame : thread->stack_frames()) {
// 标记所有根引用
for (oop* root : frame.oops_do()) {
ZConcurrentMark::mark(*root, current_mark_bit);
}
}
}
// 3. 处理 JNI handles、全局变量等
JNIHandles::oops_do(ZConcurrentMark::mark);
// 4. 释放安全点
SafepointSynchronize::end();
}
4.3 并发标记
在 mutator 运行的同时,GC 线程遍历对象图。使用栈上标记(Stack-on-mark)来避免递归深度问题:
void Z_concurrent_mark(oop start) {
// 工作队列
ZMarkStack mark_stack;
mark_stack.push(start);
while (!mark_stack.is_empty()) {
oop obj = mark_stack.pop();
// 标记当前对象(设置染色位)
if (CAS_mark(obj, UNMARKED, MARKED)) {
// 遍历对象的所有引用,标记子对象
obj->oop_iterate([&](oop* field) {
oop child = *field;
if (child != NULL && !is_marked(child)) {
mark_stack.push(child);
}
// 如果 child 已被转移,load barrier 返回新地址
// 确保我们标记的是新地址
});
}
}
}
4.4 并发转移(Relocation)
这是 ZGC 最复杂的部分,GC 线程在 mutator 运行期间搬运对象:
void Z_concurrent_relocate(ZRelocationSet* set) {
for (ZRelocationSetGroup* group : set->groups()) {
for (oop obj : group->objects()) {
// 1. 分配新空间
oop new_obj = ZHeap->forward_allocate(obj->size());
// 2. 复制对象内容
memcpy(new_obj, obj, obj->size());
// 3. 在新对象中安装转发指针
obj->install_forwarding_entry(new_obj);
// 4. 更新所有指向旧对象的引用
// - 根引用(在已完成的 pause 中)
// - mutator 字段(通过 load barrier 自愈)
// - 其他待转移组中的引用
update_references(obj, new_obj);
// 5. 设置 Remap 位(告诉 load barrier 此指针需要转发)
mark_pointer_as_needing_remap(obj);
}
}
}
五、ZGC 关键参数与调优实战
5.1 必知参数
# 基础配置
-XX:+UseZGC # 启用 ZGC
-Xmx16g # 堆大小(ZGC 会自动选择页大小)
-Xms16g # 初始堆(建议和 Xmx 一致,避免扩缩容开销)
# 高级调优
-XX:ConcGCThreads=4 # 并发 GC 线程数(默认 CPU 核数的 10%)
-XX:ZAllocationSpikeTolerance=2 # 分配速率容忍度(默认 2,越高越早触发 GC)
-XX:ZFragmentationLimit=5 # 内存碎片限制(转移集选择阈值)
-XX:ZMarkStackSpaceLimit=8g # 标记栈空间上限(防止 OOM)
-XX:ZCollectionInterval=0 # GC 间隔秒数(0=自动)
# 诊断
-Xlog:gc*:file=gc.log:time,uptime,level,tags # 详细 GC 日志
-XX:+UnlockDiagnosticVMOptions
-XX:+ZStatistics # ZGC 详细统计(有少量开销)
# 大页支持(强烈推荐)
-XX:+UseLargePages
-XX:+UseTransparentHugePages
5.2 延迟敏感场景的配置模板
场景:高频交易系统,堆 8GB,CPU 16 核,目标 P99 < 3ms
java -Xms8g -Xmx8g \
-XX:+UseZGC \
-XX:ConcGCThreads=3 \
-XX:ZAllocationSpikeTolerance=4 \
-XX:+UseLargePages \
-XX:+AlwaysPreTouch \
-XX:SoftMaxHeapSize=6g \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100m \
-jar trading-engine.jar
关键配置详解:
- SoftMaxHeapSize=6g:告诉 ZGC"尽力将堆维持在 6G 以下",但不硬性限制。这让 ZGC 在内存压力到来时提前触发 GC,避免被迫暂停。
- AlwaysPreTouch:启动时预触摸所有堆内存,避免运行时 page fault 导致的延迟。
- ZAllocationSpikeTolerance=4:容忍度从 2 调到 4,让 ZGC 在突增分配时保持冷静,不要频繁触发 GC 周期。
5.3 显著降低延迟的"暗黑"配置
# 如果你愿意牺牲一些吞吐量换更稳定的延迟
-XX:+UseZGC \
-XX:+ZUncommit # 将未使用内存归还 OS(JDK 13+)
-XX:ZUncommitDelay=300 # 300 秒后才归还内存
-XX:+UseLargePagesInMetaspace # 元空间也使用大页
六、JMH 基准测试:Measure the Unmeasurable
用 JMH 来量化 ZGC 的延迟表现:
@Warmup(iterations = 5, time = 5)
@Measurement(iterations = 10, time = 10)
@Fork(value = 3, jvmArgs = {
"-Xms4g", "-Xmx4g",
"-XX:+UseZGC",
"-XX:+AlwaysPreTouch",
"-XX:+UseLargePages",
"-XX:+UnlockExperimentalVMOptions"
})
@BenchmarkMode(Mode.SampleTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
public class ZGCLatencyTest {
private List<byte[]> liveRefs;
@Setup
public void setup() {
liveRefs = new ArrayList<>(10000);
Random rng = new Random(42);
for (int i = 0; i < 10000; i++) {
liveRefs.add(new byte[rng.nextInt(256) + 64]);
}
}
@Benchmark
public void mixedWorkload(Blackhole bh) {
// 80% 读取:遍历已有对象
int sum = 0;
for (byte[] ref : liveRefs) {
sum += ref.length;
}
bh.consume(sum);
// 20% 写入:创建新对象并引用
if (liveRefs.size() < 50000) {
byte[] newObj = new byte[128];
liveRefs.add(newObj);
}
// 偶尔清理:模拟真实业务
if (ThreadLocalRandom.current().nextInt(20) == 0) {
liveRefs.subList(0, liveRefs.size() / 4).clear();
}
}
}
在 16GB 堆/16 核机器上的典型结果:
ZGC (JDK 21):
p50 latency: ~12 μs
p99 latency: ~250 μs ← 远低于 G1 的数十毫秒
p99.9 latency: ~800 μs ← 偶尔的 GC 干涉
p99.99 latency: ~1.5 ms ← 极端峰值
G1 GC (JDK 21, -XX:MaxGCPauseMillis=200):
p50 latency: ~10 μs
p99 latency: ~2.1 ms
p99.9 latency: ~15 ms
p99.99 latency: ~45 ms
七、ZGC 的局限性与适用边界
7.1 不适合 ZGC 的场景
1. 内存受限的容器环境(< 256MB heap)
ZGC 有固有内存开销:
- 染色指针需要"地址掩码"操作,编译器不能直接做指针解引用
- 三重内存映射浪费虚拟地址空间(虽然物理只占用一份)
// 在 64MB 堆上,ZGC 比 Serial GC 多用 ~15% 内存
// 因为:Forwarding Table、标记栈、转储缓冲都是额外的
// 如果是 -Xmx64m,有效可用内存只有 ~50MB
2. 吞吐量优先的批处理系统
ZGC 的设计哲学是"用 CPU 换延迟":
- 负载屏障消耗约 5% CPU
- GC 线程占满 ConcGCThreads 指定的核数
- 并发转移消耗内存带宽
对于 Spark/Flink 批处理这类"sum(tasks) / total_time"的场景,G1 或 Parallel GC 吞吐更高。
3. 堆内 < 1GB 且对象生命周期高度集中在年轻代
如果对象的死亡曲线呈"L形"(大部分对象在年轻代死亡),分代 ZGC(Generational ZGC, JDK 21+)已经回来,但单代 ZGC 在这种情况下效率不如分代收集器。
7.2 Generational ZGC(JDK 21+)
JDK 21 引入了分代 ZGC,结合了分代收集和 ZGC 的优势:
新 Timeline:
┌────────┬────────┐
│Young GC│ Old GC │ ← 年轻代收集更频繁但极快
│(<0.5ms)│(<1ms) │
└────────┴────────┘
吞吐量比单代 ZGC 提升约 40%
延迟目标不变:< 1ms
启用方式:
# JDK 21+
-XX:+UseZGC -XX:+ZGenerational # 分代模式(JDK 21 默认关闭)
# JDK 25 可能默认开启
八、生产排错:用 JFR 和 GC 日志诊断 ZGC 问题
8.1 常见陷阱
问题1:All Allocation Stall(分配饿死)
现象:GC日志显示 Allocation Stall,暂停时间突然飙到 5-10ms。
原因:某个时刻分配速率暴增,GC 追赶不上,导致堆耗尽。
// 典型触发代码
List<char[]> batch = new ArrayList<>();
// 一次性分配大量内存,超过 GC 回收速率
while (true) {
batch.add(new char[1024 * 1024]); // 每次 2MB
Thread.sleep(50);
if (batch.size() > 100) batch.subList(0, 80).clear();
}
解决:调高 ZAllocationSpikeTolerance 或增大 ConcGCThreads。
问题2:Actual GC Frequency 过高
诊断:
# 查看 GC 每分钟的频率
grep "gc(start)" gc.log | awk '{print $1}' | cut -d: -f1-2 | uniq -c
# 如果每分钟 > 6 次,说明分配速率过高或堆过小
8.2 JFR 监控 ZGC
@Event(name = "com.example.ZGCMonitor")
public class ZGCEvent extends Event {
long cycleStart;
long pauseTimeUs;
long bytesReclaimed;
public void commitIfUseful() {
if (shouldCommit()) {
commit();
}
}
}
// 通过 ZGC 的 MXBean 获取运行时指标
ZGCBean zbean = ManagementFactory.getPlatformMXBean(ZGCBean.class);
System.out.println("Cycles: " + zbean.getCycleCount());
System.out.println("Last Pause: " + zbean.getLastPauseTime() + "ms");
System.out.println("Live: " + zbean.getUsedMemory());
8.3 推荐的生产监控指标
# Prometheus + Grafana 监控 ZGC
- jvm_gc_pause_seconds{collector="ZGC"}
- jvm_gc_collection_seconds_sum / rate
- zgc_cycles_total
- zgc_allocation_stall_count # 必须告警!
- zgc_relocation_set_size_bytes
- zgc_pages_used_uncommitted_bytes
告警规则:
# 如果 Allocation Stall 在 5 分钟内 > 0,立刻 P1 告警
groups:
- name: zgc.rules
rules:
- alert: ZGCAllocationStall
expr: increase(zgc_allocation_stall_count[5m]) > 0
for: 1m
labels:
severity: critical
annotations:
summary: "{{ $labels.instance }} ZGC 出现分配饿死"
description: "ZGC 未能及时回收内存,业务线程被阻塞"
九、总结:ZGC 的设计哲学与工程取舍
ZGC 代表了一种现代的垃圾收集器设计思路——通过硬件层次的巧妙利用(染色指针、多映射)和积极的并发策略(并发标记 + 并发转移 + 负载屏障),将延迟边界压缩到近乎物理极限。
其核心取舍有三:
-
用 10-15% 的吞吐量换 <1ms 的延迟敏感度。因为大部分企业并非 CPU-bound,而是在等 IO 或网络。在高并发场景下,GC 停顿才是真正的瓶颈。
-
用复杂的硬件耦合换简单的时间复杂度。染色指针和多重映射依赖 64 位架构的地址空间冗余,这让 ZGC 无法直接移植到 32 位平台。
-
用内存换时间的经典多映射。同一份物理内存对应多个虚拟地址视图,这在 256GB+ 堆上虚拟内存浪费不可忽略,但 ZGC 团队认为 64 位虚拟地址空间足够慷慨。
对于正在面临 G1 停顿困扰的工程团队来说,ZGC 是一个值得立即投入的调优方向。JDK 21+ 的分代 ZGC 进一步弥合了吞吐量的短板,使其适用范围更加广泛。
一句话总结:ZGC 证明了"空间换时间"的古老哲学在现代 GC 设计中依然有效,关键在于理解硬件的能力边界并善加利用。

发表评论 取消回复