垃圾收集器的停顿来自一件看似不可避免的事:运行中的线程可能正在读写对象,而收集器又要判断哪些对象仍然存活、移动对象并修正引用。传统做法是在某些阶段暂停应用线程,等收集器完成一致性工作后再继续。
ZGC 的设计目标是把这些工作尽可能与应用并发执行。它不是“没有停顿”的收集器:根扫描、线程握手和阶段切换仍会有短暂的 stop-the-world 区段。它试图让停顿与堆容量、对象数量和回收工作量保持较弱的关系,从而服务于对尾延迟敏感、且需要大堆的应用。
理解 ZGC 的关键,是看懂三者如何配合:并发阶段负责大部分标记和搬迁,着色指针把对象状态编码进引用,load barrier 在应用解引用时修复或解释这些引用。分代 ZGC 则在同一套并发思路上增加了按对象年龄分层回收的能力。
从实验性 ZGC 到分代默认模式
| 版本 | 变化 | 意义 |
|---|---|---|
| JDK 11,JEP 333 | ZGC 以实验特性出现 | 先验证面向低延迟大堆的收集器设计 |
| JDK 15,JEP 377 | ZGC 成为产品特性 | 可在生产环境中评估和使用 |
| JDK 21,JEP 439 | 分代 ZGC 作为产品特性提供 | 年轻代与老年代可采用不同回收节奏 |
| JDK 23,JEP 474 | 分代模式成为默认模式 | 新应用默认使用分代 ZGC |
| JDK 24,JEP 490 | 移除非分代模式 | 收敛实现与使用路径 |
分代 ZGC 不是另一台独立收集器,而是 ZGC 的主要运行模式。升级旧服务时,仍应阅读目标 JDK 的发布说明和启动日志,确认实际选用的模式、可用参数和构建发行版;不要把早期版本使用的 -XX:+ZGenerational 机械地复制到新版本。
停顿预算为什么不能只靠“暂停得更快”
假设一次回收需要检查几百万个对象并搬动其中一部分。如果这些工作都必须在应用线程停下时完成,停顿就会随着工作量增长。把任务分段并在每一段之间恢复应用线程,也可以降低单次停顿,但会让总回收过程更长,增加状态协调成本。
ZGC 采用另一种取舍:让应用线程在多数标记与搬迁工作期间继续运行,再用屏障维持并发修改下的正确性。收集器和应用会同时消耗 CPU;应用仍可能观察到短暂停顿,系统负载高、内存分配过快或收集器追不上时,延迟也可能变差。
收集器因此优化的是延迟分布和停顿上界特征,不是无条件提升吞吐量。对吞吐优先的批处理作业,G1 或其他收集器可能拥有更合适的 CPU 与内存取舍;需要通过代表性负载测量,而不能单凭收集器名称选型。
着色指针:让引用携带收集器状态
Java 程序看到的对象引用是一个地址样式的值,但 JVM 并不需要把它视作“只有地址含义”的裸数值。ZGC 会在引用中保留元数据位,用不同的颜色状态表示对象引用与堆区域之间的关系,例如引用是否需要修复、对象是否处于可搬迁状态。
着色的价值在于避免为每个对象维护一份必须在所有地方同步查询的状态。运行时读取一个对象引用时,屏障可以检查颜色,并据此判定该引用可直接使用、需要转发到新位置,或需要执行必要的修复。具体位布局与实现会随平台和 JDK 版本演进;应用程序不能假设对象引用的内部编码。
着色指针也不是让 Java 程序“带颜色地操作指针”。应用仍使用普通引用、字段访问和数组下标。编码、解码及访问约束由 HotSpot、JIT 编译器和垃圾收集器共同维护。
当前实现中的颜色类别
当前 OpenJDK 源码会把指针元数据按用途分组。下面是类别层面的说明,不是稳定的逐位规范:
| 元数据类别 | 作用概览 |
|---|---|
| Remapped | 引用是否符合当前重映射与搬迁阶段的读屏障要求 |
| Marked young / Marked old | 分别服务年轻代与老年代的标记周期 |
| Finalizable | 表示终结相关的引用状态 |
| Remembered | 支持代际引用维护 |
这些状态不等价于普通对象中的字段。ZGC 会在不同阶段切换哪些颜色表示“当前正确”,一些状态也会被组合编码。位数、位序和指针是否以特定形式重叠都依赖 CPU 架构与 HotSpot 实现;应用不能读取或构造带色引用。
将阶段状态带在引用上,可以让访问屏障快速判断当前引用是否已经经过所需处理,而不必每次都查询对象外部的一套全局状态。代价是引用加载路径必须和目标平台的 JIT 机器码协作,引用编码也属于 GC 实现的一部分。
Load barrier:把并发搬迁变成可恢复的访问过程
如果收集器把对象从旧地址移动到新地址,而某个应用线程手里还保留着旧引用,那么单纯移动内存会让该线程访问失效位置。ZGC 在对象加载路径上插入 load barrier:应用读取字段或引用数组元素时,运行时代码检查该引用的状态,并在需要时沿转发表找到对象的新位置,修复当前引用后继续。
概念上可以这样理解一次读取:
- 应用准备解引用
holder.next。 - load barrier 检查读出的引用状态。
- 如果对象已搬迁,屏障按转发信息取得新引用。
- 屏障把修正后的引用交给后续代码,并尽可能消除以后重复修复的成本。
JIT 会为常见快速路径生成低成本检查;只有落入需要处理的状态时,才执行更复杂的慢路径。屏障因此是 ZGC 并发能力的核心,也意味着对象读取并非完全没有额外成本。具体开销取决于硬件、访问模式、编译器生成代码、对象布局和回收阶段。
当慢路径知道引用所在的字段地址时,它还可以修复堆里的引用槽:比较当前槽位与屏障观察到的旧引用,再通过原子更新把它替换为转发后的引用和当前可接受的元数据状态。这个过程称为 self-healing。多个线程可能同时访问同一个槽,原子比较交换可避免较旧观察覆盖较新的状态;若并发更新已使槽位变化,屏障会重新检查。
字段保存旧引用 → load barrier 查转发状态 → 当前读取使用新引用
└→ 原子修复字段槽所以有些访问路径虽然进入慢路径,却为后续访问整理了引用。屏障不是一种“每次读取固定加多少纳秒”的成本:快路径、慢路径比例与访问局部性都会影响实际结果。
写屏障与 load barrier 的职责不同。跨代引用跟踪需要收集器留意老年代对象指向年轻代对象的边;引用写入时记录这种关系,下一轮年轻代回收便不必扫描整个老年代。屏障的细节由 GC 模式与实现控制,程序不应依赖某个内部屏障指令的具体形式。
并发搬迁如何保住对象图的一致性
移动对象不是简单的 memcpy。收集器必须判断哪些区域值得回收,搬移其中的存活对象,并处理应用线程仍可能持有的旧引用。ZGC 把区域选择、对象复制和引用修复拆解为多个阶段;应用线程可以在很多阶段继续运行,而屏障帮助解释搬迁前后可能同时存在的引用。
并发性带来空间与 CPU 成本。搬迁期间需要保留目标空间,允许应用和收集器并行工作也会争夺处理器。若应用的分配速度高于回收速度,或堆余量不足以容纳待搬迁对象,收集器就更难维持低延迟目标。预留更大堆可以缓解压力,却增加进程驻留内存和容器记账成本。
因此“ZGC 能处理大堆”不意味着“堆越小越好”或“给够内存就必定没有延迟问题”。分配速率、存活对象比例、对象生命周期、并发 CPU 配额和内存上限共同决定它能否赶上应用。
Page 与搬迁集合的成本
ZGC 按 page 管理堆空间。一次周期会根据标记结果和回收策略选择需要处理的区域;搬迁其中的存活对象时,收集器需要新的目标空间。对象复制的字节数、区域的存活比例和同时进行的应用分配,都会影响这轮工作能否及时完成。
选择回收区域不是无条件“压缩整个堆”。如果某页大部分对象仍存活,复制它所回收的空间可能较少;如果堆余量紧张,即便待回收页有大量垃圾,也可能缺乏足够搬迁空间。因此收集器要同时平衡回收收益和搬迁成本。
在并发周期中,应用仍可能继续分配。一个直观但非调参公式的压力估算是:
周期内新增分配量 ≈ 应用分配速率 × 并发回收持续时间如果应用在回收完成前消耗掉可用余量,线程可能遇到 allocation stall,收集器必须优先处理这类空间压力。低停顿依赖收集器能在空间被耗尽前追上应用,并不是把堆空间约束消除。
分代 ZGC:短命对象与长寿对象分开管理
许多服务会不断创建临时对象:请求解析缓冲区、格式化结果、中间集合和短命任务状态。另一些对象会在缓存、会话或索引中存活很久。若每轮回收都以同样方式面对整个堆,短命对象的高周转会迫使收集器反复处理大量实际很少变化的老对象。
分代回收把新对象放在年轻代,经过若干次回收仍存活的对象逐步晋升到老年代。年轻代回收可以更频繁地回收短命对象;老年代则按不同节奏并发处理。为了不漏掉老年代指向年轻代的对象,运行时需要维护跨代引用信息。
分代模式的收益取决于对象年龄分布。若工作集几乎全部长寿,年轻代收集减少的扫描工作可能有限;若大量对象很快死亡,分代通常能降低每次回收所处理的堆范围。分代还需要代际引用记忆、屏障与独立回收周期,所以应观察实际 GC 日志与延迟数据。
年轻代回收必须找到从老年代进入年轻代的边,否则仍被老对象引用的年轻对象会被错误回收。分代 ZGC 会用代际屏障和记忆信息维护这些关系;当前实现也把 remembered、young/old mark 与 remap 等逻辑状态纳入着色指针协议。这与“每轮扫描整个老年代”相比可以减少工作量,但写屏障和记忆信息自身仍需要 CPU 与内存。
如果对象大多在年轻代就死亡,通常能从代际回收获益;若大量对象迅速晋升,或几乎所有对象都属于长期存活集,收益就会小得多。判断时要观察存活率、晋升速率和不同代周期的 CPU 时间,而不只是查看每个 pause 的时长。
启用方式与观测重点
在支持 ZGC 的 HotSpot 构建中,可以显式选择收集器:
java -XX:+UseZGC -Xms8g -Xmx8g -Xlog:gc* -jar service.jarJDK 23 起,ZGC 的默认模式是分代模式;JDK 24 移除了非分代模式。生产启动参数仍应针对实际部署的 JDK 与发行版核验。固定堆初始值和最大值可以减少运行期间堆大小变动带来的变量,但不是所有容器或负载的最佳设置。
分析日志时先问两件事:周期为什么启动?收集器在空间余量耗尽前完成工作了吗?统一 GC 日志可显示周期原因、阶段耗时与堆占用;jcmd <pid> GC.heap_info、JFR 和容器监控则帮助区分堆压力与 CPU 限流。具体日志标签会随版本变化,应以目标运行时的输出为准。
如果分配停顿增多,检查分配突发、周期持续时间和堆余量;如果并发周期变长且 CPU throttling 同时上升,检查容器 CPU 配额;如果存活集持续扩大,先检查缓存、引用链和对象保留原因。增加并发 GC 线程可能缩短周期,也可能让应用线程更频繁地被 CPU 争抢。
评估时至少同时看以下指标:
- 业务延迟的 p50、p99、p99.9,以及 GC 停顿分布;
- 吞吐量、CPU 使用率和容器 CPU throttling;
- 分配速率、对象晋升、存活集大小和收集周期;
- 堆已提交量、进程 RSS、容器内存余量;
- GC 周期期间请求错误率、超时和队列积压。
只看平均停顿会掩盖尾延迟;只看 GC 日志里最短的 pause,也无法说明整个服务更快。比较收集器时,需固定 JDK、堆大小、CPU 配额、数据集和预热过程,并重复测试。若生产延迟改善但 CPU 成本明显上升,就要把容量成本一并纳入决策。
ZGC 与 Shenandoah 的共同点和取舍
ZGC 与 Shenandoah 都尝试把更多 GC 工作转为并发执行,也都使用读屏障等机制来支持对象搬迁。它们并不是接口相同、内部策略只差参数的同一个收集器:实现路径、区域管理、屏障优化、代际能力的成熟度与默认设置各有演进。
适合的选型问题不是“谁的宣传停顿数字更小”,而是:在当前 JDK 构建和硬件上,谁能以可接受的 CPU、内存和吞吐成本,满足这项服务的延迟目标?同一业务的缓存层、批处理任务和交互请求服务,也可能作出不同选择。
适用边界
ZGC 值得优先评估的场景包括:延迟敏感服务、大型堆、长尾停顿造成明显业务影响,且有足够 CPU 和内存余量的部署。对极度节省 CPU 的小容器、纯吞吐批处理、或应用分配速率已经逼近内存带宽上限的场景,使用成本可能高于收益。
低延迟 GC 也不会修复内存泄漏、无限缓存或高频临时分配造成的架构问题。收集器只能管理可达性与空间回收;对象活得太久、代码制造太多垃圾,仍需在应用层解决。
资料
- JEP 333:ZGC: A Scalable Low-Latency Garbage Collector (Experimental)
- JEP 377:ZGC: A Scalable Low-Latency Garbage Collector
- JEP 439:Generational ZGC
- JEP 474:ZGC: Generational Mode by Default
- JEP 490:ZGC: Remove the Non-Generational Mode
- OpenJDK Project ZGC
- OpenJDK HotSpot ZGC source
- ZGC colored pointer metadata
- ZGC barrier and self-healing implementation