ZGC 的染色指针解决了什么

一句话速记

染色指针(Colored Pointer)= 把 GC 元数据(4 bits)塞进 64 位指针的高位,让 ZGC 能在并发阶段通过指针本身判断对象是否被标记/已移动,从而不需要 STW 扫描对象头里的 GC 标记位——这是 ZGC 停顿极短的根本原因之一。同时配合读屏障,用户线程访问移动后的对象时自动重映射地址,无需 STW 等全部移动完成。

通俗解释(5 分钟版)

传统 GC 怎么做标记

  • 在对象头里设一个 mark bit:已标记 / 未标记
  • 移动对象后,所有引用该对象的指针都要更新
  • 更新引用必须 STW——不然有的线程还拿着旧地址读到错误数据

ZGC 的创新

  • 64 位系统指针只用了低 42 位做地址(4TB 以内),高位闲着
  • ZGC 借用高位中的 4 bits 存 GC 状态:
    63       46 45 44 43 42 41                0
    ┌──────────┬──┬──┬──┬──┬────────────────────┐
    │ 保留(0)  │M1│M0│R │F │  对象地址 (42 bits) │
    └──────────┴──┴──┴──┴──┴────────────────────┘
    M1,M0: 标记位(两代交替使用)
    R:     Remapped(是否已重映射/移动)
    F:     Finalizable(是否只剩 finalize 可达)
    
  • GC 线程标记对象 → 翻转指针里的 M bit,不改对象头
  • 移动对象 → 翻转指针里的 R bit,旧地址映射到新地址(通过内存映射)
  • 用户线程读指针 → 触发读屏障:检查 R bit,自动重映射到新地址

核心效果不需要扫描 / 修改对象头,GC 状态完全在指针里——移动对象、标记对象都可并发进行,STW 极短。

关键细节

1)多重内存映射(Multi-Mapping)

染色指针有个问题:M bit 变了之后,地址就变了(同一个对象有多个”指针值”)。

ZGC 的解法:用 Linux 的**内存映射(mmap)**让多个虚拟地址映射同一个物理页:

虚拟地址空间:
  M0 视图:0x4000_0000_0000 + 偏移
  M1 视图:0x8000_0000_0000 + 偏移
  R  视图:0xC000_0000_0000 + 偏移

三个虚拟地址区间 → 指向同一物理内存

所以即使指针高位 bits 不同,实际访问的是同一块物理内存——解决了多个指针值问题。

副作用:ZGC 堆的虚拟地址空间是物理内存的 3 倍(JDK 15 前)或更多。不是真的占用物理内存,但某些监控工具会误报”内存占用超大”。

2)读屏障的作用

// 用户线程执行:
Object ref = obj.someField;
 
// JIT 在这行代码背后插入读屏障(伪代码):
Object ref = obj.someField;
if (ref.color_bits != expected_color) {
    ref = slow_path_remap(ref);  // 从 forwarding table 查新地址
    obj.someField = ref;          // 顺手修正引用(self-healing)
}

关键:修正是**自愈式(self-healing)**的——用户线程第一次访问移动后的对象时自动修正引用,下次访问就不需要再走 slow path。所以读屏障的额外开销会随着时间逐渐消退。

3)与 G1/CMS 的对比

GC        标记位置       更新引用时机     是否需要 STW 更新引用
──────────────────────────────────────────────────────────────
CMS       对象头 mark    Remark 阶段      需要(Final Remark STW)
G1        Region card    Cleanup / 下次   需要(Mixed GC STW)
ZGC       指针高位       读时自愈         不需要(并发移动 + 读屏障)

4)染色指针的限制

限制                                       说明
──────────────────────────────────────────────────────────
只能用在 64 位系统                         32 位地址没多余 bits
最大堆 4TB(42 位地址)                    JDK 15+ 扩到 16TB
不支持压缩指针(CompressedOops)           UseCompressedOops 和 ZGC 不兼容
JNI 代码里的裸指针需要特殊处理             ZGC 有对应机制

延伸追问

  • Q:读屏障会不会很慢?每次读对象都检查? → 读屏障只加在引用类型字段的读取上,基本类型(int/long)不需要。JIT 编译后的检查是几条 CPU 指令(test + cmov),一般只有 2-5% 的额外 CPU 开销;频繁命中 slow path 时才明显——但 self-healing 机制让 slow path 越来越少。
  • Q:ZGC 分代(JDK 21)和非分代的区别? → 非分代 ZGC 对新生代和老年代用同一套流程(每次都扫全堆),开销高。分代 ZGC 把年轻代和老年代分开处理——年轻代用更频繁的小 GC,减少老年代压力,吞吐接近 G1。
  • Q:染色指针只有 ZGC 在用吗? → 目前是的。Shenandoah 用的是 Brooks Pointer(在对象头前加一个转发指针);G1/CMS 用对象头 mark word。

我的记法

  • 染色指针 = 把 GC 状态 4 bits 塞进 64 位指针高位
  • 作用:并发标记/移动时不用改对象头,不需要 STW 更新引用
  • 配套机制:读屏障 + 自愈(用户线程读时自动修正引用)
  • 多重映射:同一物理页映射 3 个虚拟地址(解决颜色不同的指针问题)
  • 限制:仅 64 位、最大 4-16TB、不兼容 CompressedOops
  • 一句话:「染色指针让 GC 信息活在指针里,GC 并发进行,不打扰用户线程」

状态

  • 已背速记
  • 能讲通俗版
  • 能答追问