一个 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 余量让并发线程跟上分配;以及堆上限是否落在彩色指针可寻址范围内。这些前提不成立时,多重映射带来的收益会被抵消,甚至退化到频繁分配停顿。