一个 TB 级堆上的停顿问题
假设有一台交易撮合服务,堆开到 1TB,行情快照、订单簿和会话缓存长期驻留,同时每秒产生大量短命的报价对象和序列化缓冲。团队最初用 G1,把 MaxGCPauseMillis 压到几十毫秒,结果发现大堆下混合回收的停顿仍然会突破目标,撮合线程被卡住时,行情已经走了好几档。换成非分代 ZGC 后,停顿稳定在亚毫秒级,但新的问题出现了:分配速率一高,应用线程就会因为拿不到可用页而等待 GC 回收,吞吐下降,CPU 里 GC 线程占比上升。
这个现象不是配置没调好,而是非分代 ZGC 的结构性约束。它把所有对象放在一起,每次回收都要遍历整个对象图,不管这些对象是刚分配的还是活了几小时的。年轻对象大多很快死亡,回收它们成本低、收益高;老对象存活率高,回收它们成本高、收益低。把两者混在一起,等于每次都按最贵的那部分付费。
分代 ZGC 要解决的就是这个错配。它把堆逻辑上分成年轻代和老代,各自独立回收,让高频、便宜的年轻代回收承担大部分内存回收任务,老代回收则低频、并发地进行整理。理解它为什么能在保持亚毫秒暂停的同时提高分配速率,需要看清彩色指针、读屏障和写屏障三者的分工。
非分代 ZGC 的基线:全堆并发回收的代价
ZGC 的核心承诺是并发。它把标记、转移、重定位这些重活都放在应用线程运行期间完成,只在极短的同步点暂停应用线程,官方描述为不超过 1 毫秒。这个特性与堆大小无关,几百 MB 到 16TB 都能维持。
做到这一点靠的是彩色指针和读屏障。彩色指针是一个 64 位对象指针,除了对象地址,还携带若干元数据位,用来编码对象当前已知的状态,比如是否存活、地址是否已经修正。当应用代码读取对象字段里指向另一个对象的引用时,ZGC 注入的读屏障会先解释这些元数据位,必要时采取动作,再把引用交给应用使用。
非分代 ZGC 只用彩色指针和读屏障。标记可达对象的工作也挂在读屏障上:应用每读到一个引用,读屏障顺带判断这个对象是否需要被标记。问题在于,读屏障的执行频率远高于写屏障,把标记逻辑塞进读路径,会让每次对象读取都承担额外判断。读路径越热,这部分开销越难摊薄。
更关键的是回收粒度。非分代 ZGC 每次运行都要处理整个堆的存活对象,因为对象没有年龄区分。当堆里长期存活的数据占比很高时,每次回收能真正释放的内存有限,却要付出遍历全部存活对象的成本。分配速率一旦超过回收速率,应用线程就会在分配新对象时等待内存,表现为分配停顿。
分代 ZGC 的三件套:彩色指针、读屏障与写屏障
分代 ZGC 在彩色指针上增加了新的元数据位,并引入了写屏障。写屏障是 ZGC 在应用向对象字段写入引用时注入的代码片段。它利用彩色指针里的元数据位,判断这次写入是否产生了跨代引用,以及被覆盖掉的旧引用是否需要被标记。
这个设计的直接效果是把标记工作从读屏障搬到了写屏障。写屏障的执行频率通常低于读屏障,把标记逻辑放在这里,读屏障就可以被简化:它只需要在对象被转移后修正地址,并更新元数据表示地址已正确,后续读屏障看到这个元数据就不会重复检查对象是否被移动过。
分代 ZGC 还使用不同的标记位和重定位位,让标记和转移两套状态互不干扰。写屏障负责记录跨代引用,读屏障负责地址修正,两者配合,使年轻代可以独立于老代被回收。
这里有一个容易忽略的边界:彩色指针和读屏障是 ZGC 的通用机制,写屏障是分代模式特有的。非分代 ZGC 没有写屏障,因此也不具备跨代引用的记录能力。把分代 ZGC 的行为当成所有 ZGC 的行为,会在排查日志和调优参数时产生误判。
年轻代回收与老代并发整理的分工
分代 ZGC 把堆分成两个逻辑代:年轻代存放最近分配的对象,老代存放长期存活的对象。每一代独立回收,ZGC 因此可以集中回收收益高的年轻对象。
年轻代回收频率高。大多数对象在年轻代就死亡,回收它们只需要处理少量存活对象,成本低、释放内存多。存活下来的对象达到一定条件后晋升到老代。ZGC 动态调整代的大小、GC 线程数量和晋升阈值,不需要手工配置代大小或对象在年轻代停留多久。
老代回收频率低。它负责整理长期存活对象,释放的空间相对少,但因为是并发进行,不会带来长暂停。老代回收与年轻代回收可以交错发生,应用线程在大部分时间里继续执行。
跨代引用是分代回收必须处理的问题:老代对象可能引用年轻代对象,年轻代回收时不能只看年轻代内部的引用。写屏障记录下这些跨代引用,年轻代回收时把它们当作根的一部分,避免漏标。这正是写屏障存在的意义,也是分代 ZGC 相比非分代 ZGC 多出来的成本项。
下面的流程图展示一次年轻代回收在分代 ZGC 中的状态流转,以及写屏障和读屏障在其中的位置。
flowchart TD
A[应用线程分配新对象] --> B[对象进入年轻代]
B --> C{对象是否被老代引用}
C -->|是| D[写屏障记录跨代引用]
C -->|否| E[仅年轻代内部引用]
D --> F[年轻代回收启动]
E --> F
F --> G[以跨代引用和根为起点并发标记]
G --> H[读屏障修正被转移对象地址]
H --> I[存活对象晋升老代]
I --> J[死亡对象空间被回收]
J --> K[老代低频并发整理]
图中的关键转折在写屏障和读屏障的分工:写屏障在对象被写入时就记录跨代引用,年轻代回收不必扫描整个老代;读屏障只在对象被转移后修正地址。两者把标记和转移的成本分散到不同的执行路径上。
版本边界:从 JDK 21 到默认分代
分代 ZGC 的启用方式随 JDK 版本变化,这是选型时必须确认的第一件事。
JDK 21 中,ZGC 同时提供分代和非分代两个版本。-XX:+UseZGC 选择非分代 ZGC,要启用分代需要额外加上 -XX:+ZGenerational:
java -XX:+UseZGC -XX:+ZGenerational -Xmx1t ...
JDK 23 起,分代模式成为 ZGC 的默认模式。JEP 474 把 ZGenerational 的默认值从 false 改为 true,并弃用该选项。此时 -XX:+UseZGC 直接使用分代 ZGC;显式写 -XX:+ZGenerational 会得到弃用警告;写 -XX:-ZGenerational 才会退回非分代模式,同时收到非分代模式将被移除的警告。
后续版本计划移除非分代模式,届时 ZGenerational 选项会变得多余。这意味着依赖非分代 ZGC 特定行为、GC 日志格式或管理接口数据的系统,在升级 JDK 时需要额外验证。JEP 474 明确提示,与非分代模式紧密耦合的工作负载可能需要调整配置。
分代 ZGC 的暂停时间目标与非分代一致,仍是不超过 1 毫秒,堆大小支持范围仍是从几百 MB 到多 TB。分代不是用暂停时间换吞吐,而是在保持暂停时间的前提下改善分配停顿、内存开销和 GC 的 CPU 开销。
与非分代 ZGC、G1 的权衡对比
选型不能只看“停顿低”一个维度。下表从几个工程维度对比三种方案,其中分代 ZGC 与非分代 ZGC 的差异来自 JEP 439 的目标描述,G1 的停顿特征来自 JEP 439 对默认收集器的说明。表中不给出具体数字,因为不同堆大小、分配速率和存活集下结果差异很大,需要以实际压测为准。
| 维度 | 分代 ZGC | 非分代 ZGC | G1 |
|---|---|---|---|
| 暂停时间量级 | 亚毫秒,与堆大小基本无关 | 亚毫秒,与堆大小基本无关 | 毫秒到秒级,随堆和存活集变化 |
| 回收粒度 | 年轻代高频、老代低频,独立回收 | 每次回收整个堆 | 分代,但停顿受堆和区域数影响 |
| 分配停顿风险 | 较低,年轻代回收频繁释放空间 | 较高,回收速度可能跟不上分配 | 中等,依赖并发标记与混合回收 |
| 堆内存开销 | 较低,可更充分利用堆 | 较高,需要更多预留空间 | 中等,依赖记忆集和卡表 |
| GC CPU 开销 | 较低,年轻代回收成本低 | 较高,每次遍历全堆存活对象 | 中等,并发线程与写屏障有成本 |
| 写屏障 | 有,用于记录跨代引用 | 无 | 有,用于维护卡表和记忆集 |
| 调优复杂度 | 低,代大小和晋升阈值自适应 | 低,主要调 -Xmx | 中,需平衡停顿目标和吞吐 |
| 适用场景 | 大堆、低延迟、高分配速率 | 大堆、低延迟、分配速率适中 | 中等堆、可接受毫秒级停顿 |
从表中可以看出,分代 ZGC 相对非分代 ZGC 的收益集中在分配停顿、内存开销和 GC CPU 开销三项,代价是引入写屏障。相对 G1,分代 ZGC 的优势是暂停时间与堆大小解耦,代价是并发回收需要额外的 CPU 余量和堆预留空间。
这里需要区分事实和推断。JEP 439 明确把“降低分配停顿风险、降低所需堆内存开销、降低 GC CPU 开销”列为分代 ZGC 的目标,并强调不应以显著吞吐下降为代价。至于某个具体服务能提升多少,属于工程测量问题,不能从 JEP 直接推出。
生产环境中的可观测信号与失败模式
分代 ZGC 的自适应程度较高,官方建议主要调优手段是设置最大堆大小。但“自适应”不等于不需要观察。
分配停顿是最需要盯的信号。它表现为应用线程在分配对象时等待可用内存。当分配速率持续超过回收速率,即使暂停时间很短,请求延迟也会抖动。此时可以检查堆余量是否足够,以及年轻代回收是否跟得上分配节奏。
跨代引用过多会加重写屏障负担。如果老代对象大量引用年轻代对象,写屏障记录和后续处理的开销上升,年轻代回收的收益被侵蚀。这种模式常见于长生命周期的缓存持有短生命周期对象引用的场景。
GC 日志和管理接口数据在分代模式下与非分代模式不同。JEP 474 明确提示,与非分代模式紧密耦合的 GC 日志和管理接口数据在切换后可能变化。依赖这些数据做容量规划或告警的系统,需要在升级前验证解析逻辑。
CPU 余量不足是另一个失败模式。ZGC 的并发回收需要与应用线程争抢 CPU。官方建议低延迟场景下系统 CPU 利用率不要长期超过 70%。如果容器 CPU limit 设置过紧,GC 线程被限流,回收速度下降,分配停顿随之出现。
还有一个容易被忽视的边界:并非所有工作负载都受益于分代。JEP 474 的风险说明指出,某些本质上非分代的工作负载在切换到分代模式后可能出现轻微性能退化。这类工作负载的对象存活时间分布没有明显的年轻代集中特征,分代带来的收益有限,写屏障的成本却依然存在。
选型判断与不适用场景
回到开头那台 TB 级堆的交易服务。如果它符合“大量短命对象 + 长期存活数据集 + 对停顿敏感”的特征,分代 ZGC 是比非分代 ZGC 更合适的选择:年轻代高频回收能跟上分配速率,老代低频并发整理避免长暂停,写屏障的额外成本由读屏障简化和回收收益抵消。
如果停顿目标可以放宽到几十毫秒,且 CPU 和内存资源紧张,G1 仍然值得考虑。它的并发线程和记忆集开销相对可控,在中等堆下调优经验成熟。
如果工作负载的对象存活时间分布平坦,没有明显的“朝生夕死”特征,分代带来的收益会缩小。此时应通过压测对比分代与非分代的分配停顿、GC CPU 占比和实际吞吐,而不是默认分代一定更好。
选型前需要确认的版本边界:JDK 21 必须显式加 -XX:+ZGenerational 才启用分代;JDK 23 起默认分代,-XX:-ZGenerational 才退回非分代;非分代模式已被弃用并计划移除,长期方案不应建立在它之上。
最后,分代 ZGC 的调优重心不在参数数量,而在资源余量。堆要留出足够空间让分配在回收期间继续进行,CPU 要留出余量让并发回收线程不被饿死。把这两点做对,比反复调整 GC 线程数更有效。