Java 技术
#ZGC#JVM#GC#虚拟内存#读屏障

ZGC 多重映射堆视图:彩色指针如何用地址空间换并发整理

面向 TB 级堆低延迟服务,解释 ZGC 为何把同一份物理堆映射成 Marked0、Marked1、Remapped 三个虚拟视图,彩色指针如何借视图编码标记状态,读屏障在加载引用时做什么,以及多重映射带来的虚拟地址与常驻内存开销。同时对比 Shenandoah 的 Brooks 指针方案,给出地址空间规划与诊断要点。

一个 TB 级堆的停顿预算问题

假设有一类低延迟服务:堆上限设在数 TB,常驻对象几百 GB,要求单次 GC 停顿稳定在毫秒级以下。用 G1 这类以复制整理为主的收集器时,真正难受的不是标记,而是整理——把存活对象搬到新位置,意味着所有指向旧地址的引用都必须被修正。如果修正发生在应用线程停下来的窗口里,窗口长度就随存活对象数量增长,堆越大越难压住。

一个自然的直觉是:既然停顿来自“搬完再改指针”,那就让应用线程和 GC 线程同时干,谁先碰到旧地址谁负责改。问题在于,应用线程读到一个引用时,怎么知道这个地址是旧的、新的,还是本轮标记还没访问过的?如果每次读引用都要去问一张全局表,这张表本身就会成为竞争点。ZGC 的做法是把答案直接编进指针里,再用多重映射让同一份物理内存同时拥有几个虚拟地址,使“指针里的状态位”和“实际地址”能对上号。

多重映射:一份物理内存,三套虚拟地址

ZGC 用 mmap 把同一段物理内存映射到多个虚拟地址区间,这些区间在 ZGC 里称为视图(view)。资料给出的视图是 Marked0、Marked1 和 Remapped。应用创建一个对象时,物理页只分配一次,但它在三个视图里各有一个虚拟地址;同一时间只有一个视图是“有效”的,另外两个视图里的对应地址不表示当前状态。

这里的“有效”不是内存保护意义上的可读可写,而是语义上的:指针高位携带的视图标记,决定了读屏障应该把这次访问解释成哪一轮的状态。GC 通过切换当前视图来推进回收阶段,而不是去遍历堆把所有指针改一遍。

为什么需要两个 Marked 视图而不是一个?如果只有 Marked 和 Remapped,上一轮标记为活的、本轮已经死掉的对象,和本轮标记为活的对象,会落在同一个视图里,无法区分。用 Marked0 和 Marked1 交替:本轮标记写入 Marked0,下一轮写入 Marked1,于是“上一轮活、本轮死”的对象留在旧 Marked 视图里,可以直接被判定为垃圾。Remapped 视图则表示地址已经是当前正确地址、不需要再修正。

彩色指针:把标记状态编进地址位

64 位指针里,高位有一部分并不参与实际寻址。ZGC 把其中若干位用作元数据位,这就是彩色指针(colored pointer)。资料提到 ZGC 用高 4 位保存标志位,从而把可管理堆上限压到 16TB 量级——这是“用地址空间换状态编码”的直接代价。

关键点在于,这些位不是独立于地址存在的标签,而是地址的一部分。同一个对象在 Marked0 视图和 Remapped 视图里的指针值不同,但解引用后落到同一物理内存。因此:

  • 指针本身就能回答“这个引用属于哪一轮标记、地址是否已修正”。
  • 修正地址不需要遍历堆,只需要在读到旧视图指针时改写这一次加载的结果。
  • 视图切换是全局的、O(1) 的动作,与堆大小无关。

这也解释了为什么 ZGC 的停顿不随堆增长:它把“整理”这件重活拆成了大量分散在应用线程加载路径上的小动作。

读屏障:加载引用时到底发生了什么

读屏障是 JIT 编译器在“从堆中读取对象引用”的位置注入的一小段代码。注意范围:只有从堆里读引用字段才需要,读局部变量、读基本类型字段不触发。

用示意逻辑描述一次加载:

// 伪代码:读屏障的判定逻辑,非真实 API
ref = obj.field          // 原始加载,可能带旧视图标记
if (ref 的视图标记 != 当前有效视图) {
    if (对象已被转移) {
        ref = 读取转发信息,得到新地址
        把新地址写回 obj.field   // 自愈
    } else {
        ref = 把 ref 重新编码到当前视图
    }
}
return ref

两个动作值得注意。第一是自愈(self-healing):读屏障发现旧指针后,会把修正后的指针写回字段,后续再读就不必重复修正。第二是“重新编码到当前视图”并不移动对象,只是让这次加载返回的指针与当前阶段语义一致。

资料指出,读屏障对性能有影响,测试中最高约 4%,但换来了并发能力与更低的 STW。这是一个典型的权衡:把成本摊到每次引用加载上,换取停顿与堆大小解耦。

一次完整回收:视图切换与对象流转

把上面的机制串成一条时间线。初始状态下整个堆处于 Remapped 视图。

flowchart TD
    A[初始: 全堆 Remapped 视图] --> B[初始标记 STW: 扫描 GC Roots]
    B --> C[并发标记: 活对象切到 Marked0]
    C --> D[再标记 STW: 处理并发期间引用变化]
    D --> E[初始转移 STW: 转移 Roots 直达对象]
    E --> F[并发转移: 搬移对象并切回 Remapped]
    F --> G[重定位: 应用线程读到旧指针时自愈]
    G --> H[下一轮标记切换到 Marked1]
  • 初始标记:只扫描 GC Roots 直接引用的对象,停顿与 Roots 数量成正比,与堆大小无关。
  • 并发标记:GC 线程与应用线程并行。GC 线程访问到 Remapped 视图的对象就把它切到 Marked0;应用线程通过读屏障做同样的事。标记期间新分配的对象直接进入 Marked0。
  • 再标记:处理并发标记期间引用关系变化导致的漏标,并处理软引用、虚引用等非强引用。停顿很短。
  • 初始转移:扫描 Roots 并转移其直接引用对象,同样只与 Roots 数量相关。
  • 并发转移:GC 线程把 Marked0 视图的活对象搬到新位置并切到 Remapped;应用线程若访问到尚未转移的活对象,也会参与转移。
  • 重定位:不是独立的大阶段,而是分散在读屏障里的自愈过程——谁读到旧指针,谁负责把这次加载修正到新地址。

整个过程中,真正 STW 的只有初始标记、再标记、初始转移三处,且都与堆大小无关。这就是“停顿与堆解耦”的来源。

地址空间与常驻内存:多重映射的真实开销

多重映射最容易被误解的一点是:它并不把物理内存也乘以三。物理页只分配一份,三个视图是虚拟地址层面的映射。因此常驻内存(RSS)不会因为视图数量翻倍。

但虚拟地址空间(VSZ)会显著膨胀。三个视图意味着每个堆地址在进程地址空间里出现三次。对于 TB 级堆,这会直接反映在 VSZ 上,可能触发一些监控告警或运维误判——看到进程 VSZ 远超物理内存时,需要先确认这是 ZGC 的映射行为,而不是内存泄漏。

另一个约束是堆上限。彩色指针用掉高位后,可寻址范围被压缩,资料给出的上限在 16TB 量级。也就是说,地址空间不是免费的:你用它换来了并发整理能力,代价是单堆可寻址上限和更高的虚拟地址占用。

还有一点:多重映射依赖操作系统的虚拟内存映射能力,这也是 ZGC 早期只在特定平台可用的原因之一。

与 Shenandoah Brooks 指针的对比

Shenandoah 解决同一个问题的思路不同:它在对象里保留一个额外的指针字段(Brooks pointer),指向对象自身或转发后的新位置。应用线程读引用时,需要经过这个间接层,先读 Brooks pointer 再解引用。

两种方案的差异可以整理成一张表:

维度ZGC 彩色指针 + 多重映射Shenandoah Brooks 指针
状态存放位置指针高位元数据位对象内额外指针字段
地址空间开销高,同一物理内存多份虚拟映射低,无多重映射
单对象内存开销无额外字段每个对象多一个指针宽度
解引用路径读屏障判定视图,通常一次加载需经 Brooks pointer 间接寻址
堆上限约束受元数据位挤压,约 16TB 量级不受指针位编码限制
视图切换成本O(1) 全局切换依赖转发指针逐个更新
主要代价虚拟地址空间、读屏障对象头膨胀、间接访问

可以这样理解取舍:ZGC 把成本放在地址空间和指针编码上,换来对象布局紧凑、视图切换廉价;Shenandoah 把成本放在每个对象上,换来不依赖多重映射、地址空间占用小。选择哪条路,取决于你是更在意虚拟地址预算,还是更在意单对象内存开销与平台约束。

生产环境中的观察与失败模式

在 TB 级堆服务里,几个信号值得长期观察:

  • GC 日志中的停顿分布:ZGC 的 STW 阶段应稳定在毫秒级以下,若出现异常尖峰,先排查是否触发了非并发的退化路径。
  • 分配速率与回收速率的平衡:ZGC 是并发收集器,必须留出足够 headroom。资料强调,-Xmx 要能容纳 live set 并留出 GC 运行期间的分配空间;headroom 不足会导致分配停顿。
  • 并发 GC 线程数:给太多会抢应用 CPU,给太少回收跟不上分配。资料提到 JDK 17 起 ZGC 会动态伸缩并发 GC 线程数,因此手动调 -XX:ConcGCThreads 的必要性下降。
  • VSZ 与 RSS 的差异:VSZ 偏高是多重映射的正常表现,不应直接等同于内存问题。
  • 浮动垃圾:并发期间应用线程新分配的对象本轮无法回收,只能等下一轮。堆越大、分配越快,浮动垃圾越多,这是并发整理的固有代价。

常见的失败模式包括:headroom 不足导致分配停顿;CPU 过度使用(资料建议系统 CPU 利用率尽量不超过 70%)导致 GC 线程被饿死;以及把 VSZ 误判为内存泄漏而做出错误的容量决策。

版本边界与适用判断

资料给出了清晰的版本脉络:ZGC 在 JDK 11 作为实验特性引入,JDK 15 转为生产可用;JDK 21 引入分代模式,需要用 -XX:+UseZGC -XX:+ZGenerational 显式启用,非分代模式则只用 -XX:+UseZGC。分代模式增加了 store barrier,并把部分标记工作从读屏障移到写屏障,以优化更频繁执行的读屏障路径。

需要区分的是:多重映射、彩色指针、读屏障是 ZGC 的核心设计,属于实现层面的机制;而“停顿不超过 1 毫秒”这类表述是设计目标与实测观察,不是对所有工作负载的硬保证。资料本身也承认,这些目标并非对每一种可设想的工作负载都成立。

因此,判断是否适用 ZGC,不能只看“堆很大”或“要低延迟”,还要看:能否接受更高的虚拟地址占用与读屏障开销;是否有足够 CPU 余量让并发线程跟上分配;以及堆上限是否落在彩色指针可寻址范围内。这些前提不成立时,多重映射带来的收益会被抵消,甚至退化到频繁分配停顿。

资料来源

  1. ZGC: A Scalable Low-Latency Garbage Collector (OpenJDK Wiki)
  2. JEP 333: ZGC: A Scalable Low-Latency Garbage Collector (Experimental)
  3. JEP 439: Generational ZGC
  4. 12 张图带你彻底理解ZGC