JVM ZGC 深度实战

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 代表了一种现代的垃圾收集器设计思路——通过硬件层次的巧妙利用(染色指针、多映射)和积极的并发策略(并发标记 + 并发转移 + 负载屏障),将延迟边界压缩到近乎物理极限。

其核心取舍有三:

  1. 用 10-15% 的吞吐量换 <1ms 的延迟敏感度。因为大部分企业并非 CPU-bound,而是在等 IO 或网络。在高并发场景下,GC 停顿才是真正的瓶颈。

  2. 用复杂的硬件耦合换简单的时间复杂度。染色指针和多重映射依赖 64 位架构的地址空间冗余,这让 ZGC 无法直接移植到 32 位平台。

  3. 用内存换时间的经典多映射。同一份物理内存对应多个虚拟地址视图,这在 256GB+ 堆上虚拟内存浪费不可忽略,但 ZGC 团队认为 64 位虚拟地址空间足够慷慨。

对于正在面临 G1 停顿困扰的工程团队来说,ZGC 是一个值得立即投入的调优方向。JDK 21+ 的分代 ZGC 进一步弥合了吞吐量的短板,使其适用范围更加广泛。

一句话总结:ZGC 证明了"空间换时间"的古老哲学在现代 GC 设计中依然有效,关键在于理解硬件的能力边界并善加利用。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部