← 返回文章列表

CMS、G1、ZGC:并发阶段与停顿时长的取舍

2021年9月22日Java
JVMGC

选 GC 时,最容易被一句「低延迟」带走。业务真正关心的却是:请求尾延迟是否下降,吞吐是否够,CPU 和内存还剩多少余量。GC 暂停是其中一部分,不能直接替代接口指标。

笔者最近翻一个老服务的启动参数,看到一串 UseConcMarkSweepGC、CMSInitiatingOccupancyFraction、CMSScavengeBeforeRemark。这套东西在 JDK 8 上是正经调优,在已经移除 CMS 的 JDK 上就不成立了:参数要么被当成过时项忽略掉,要么直接让进程起不来——前一种不看启动日志,根本不知道它没生效。这篇想按「哪些工作能和业务线程并发、哪些必须停下来」把三个回收器过一遍,看它们的取舍各自落在哪一步,最后落到比较时要看的几个数。这里保留历史演进的背景,也注明后续版本变化,避免把旧参数带到新 JDK。

CMS、G1、ZGC 的回收阶段按 STW 与并发分色,琥珀色是应用线程全部停住的阶段

CMS 用碎片换短暂停,代价落在哪一步

CMS(Concurrent Mark Sweep)是 JVM 往低延迟方向走的第一步,思路说起来很简单:把标记和清除尽量挪到业务线程旁边去跑,只留下两段短暂停。

它的回收周期一共四步:

阶段应用线程在做什么
初始标记暂停顺着 GC Roots 找一层,只标记直接可达的对象
并发标记继续跑遍历整个对象图,同时跟踪并发期间发生的引用变动
重新标记暂停补上并发标记漏掉的变动,重新扫描被写屏障记下的引用
并发清除继续跑回收不可达对象占用的空间,不搬动存活对象

仔细看这四步,暂停只落在初始标记和重新标记上。这两段之所以短,是因为它们的活和根集合、漏标数量有关,跟堆有多大基本脱钩;真正耗时的并发标记与并发清除被挪到了并发阶段,代价转成 CPU 占用——并发线程要和应用抢核,这部分不是白拿的。

麻烦出在并发标记的这段时间里。业务线程照常分配、照常晋升,老年代占用只增不减;而 CMS 的清除是标记-清除式的,不做压缩,碎片留在原地。

  • Concurrent Mode Failure:分配需求追上甚至超过回收进度,或者空间条件已经无法满足分配时触发。CMS 走后备路径,退化成单线程的 Serial Old 做整堆压缩,一次停顿以秒计,几十 GB 的堆上更难看。
  • 碎片直接引发 Full GC:剩余空间总量看着够,却没有一块连续空间装下要晋升的大对象,于是走同一套后备路径。
  • 晋升失败(promotion failed):年轻代回收想把存活对象挪进老年代,但老年代腾不出位置。

这三条路径的日志长得不一样,后果却是同一种:平均暂停再漂亮也没用,业务感知到的是那一次。所以读 CMS 日志,第一时间找的是有没有出现过并发模式失败或晋升失败。

-XX:CMSInitiatingOccupancyFraction 就是给这条路径留的余量:让 CMS 在老年代占用还没爬到危险线时提前开始并发周期。它配 -XX:+UseCMSInitiatingOccupancyOnly 才按固定阈值走,否则 JVM 会自己调整这个值——只看前者不看后者,参数可能压根没按你写的执行。-XX:+CMSScavengeBeforeRemark 是另一个用暂停换时间的旋钮:在重新标记之前先做一次年轻代回收,缩短重新标记要扫的范围,代价是多一次年轻代暂停。

还有一个容易被漏掉的前提:CMS 的并发阶段从来没有承诺过「不停顿」。初始标记和重新标记本身要进安全点(safepoint),必须等所有应用线程都停在检查点上才能开始。所以哪怕只算这两段,暂停时间也不只取决于 GC 自己的效率,还取决于有没有线程迟迟到不了安全点。至于并发周期开不开得起来,则要看 CPU:并发线程抢不到核,回收进度就会落后于分配进度,绕一圈还是回到并发模式失败。

拿多久的日志看,决定了能看到什么。 刚重启的实例老年代很空,几个小时里可能一次并发模式失败都没有;跑满几天之后,碎片和晋升速度才显出形状。日志留得不够长,看到的只是「没问题」。真要看这段,至少覆盖一个完整的发布周期——这一点在另一篇排查记录里也是同一个道理,那里的问题是缓存没淘汰,见 一次 Full GC 排查。

CMS 在 JDK 9 被标记为弃用,见 JEP 291;在 JDK 14 被移除,见 JEP 363。它被拿掉的理由也很直白:维护成本高,而且 G1 与后来的 ZGC 已经覆盖了它原本的定位。今天读 CMS,多是为了解释存量系统那些老日志,而不是给新系统定参数。

G1 把堆切成 Region 以后

G1(Garbage First)的思路换了个方向:既然整堆压缩太贵,那就别一次动整堆。

堆被切成一组等大的 Region,Region 数量有个上限(大约两千个左右),大小由 -XX:G1HeapRegionSize 指定,实际会取成 2 的幂。每个 Region 在某一轮回收里扮演 Eden、Survivor、Old 或者 Humongous 中的一个角色,角色不绑在固定地址上,所以年轻代与老年代不再各占一块连续空间,谁空出来谁就能换角色。

回收动作叫转移(evacuation):挑一批 Region,把里面还活着的对象复制到空闲 Region 里去,原来的 Region 整体清空。复制天然不产生碎片,这一条顺手解决了 CMS 最疼的问题。

跨 Region 的引用怎么办?G1 用写屏障把它们记进记忆集(Remembered Set,RSet),也就是「谁指向我」的账本。回收某个 Region 时,顺着这本账就能找到所有外部引用,不必遍历整堆。代价分散在每一次引用赋值上,加上 RSet 自身的堆占用——这笔开销在 G1 上是常驻的,调参时容易被忽略。

停顿预测模型拿历史数据估算「回收 N 个 Region 大概要多久」,再在 -XX:MaxGCPauseMillis 目标下挑收益最高的一批 Region 动手。这个参数是软目标,不是硬上限:堆太紧、分配太快、挑出来的 Region 里存活对象太集中,实际暂停照样超出。数值压得太激进,一次只收几个 Region,回收速度跟不上分配速度,反倒更容易撞上 Full GC。

大于 Region 一半的对象按 Humongous 处理,占连续多个 Region,分配与回收都不走 Eden → Survivor 那条路。日志里频繁出现 G1 Humongous Allocation,通常指向大数组或大缓冲区;先调 -XX:G1HeapRegionSize 把 Region 放大,往往比继续压低暂停目标更有效。

Mixed GC 是在一次回收里连部分老年代 Region 一起做。它得等老年代占用爬到 -XX:InitiatingHeapOccupancyPercent(默认 45%)才启动并发标记周期;标记还没走完、空间先满了,就退化成 Full GC。JDK 8、9 中,G1 的这个后备路径使用单线程;从 JDK 10 起,Full GC 已改为并行执行,见 JDK 10 发布说明。并行化能缩短停顿,但这仍然是需要重点排查的整堆回收。

翻日志时,[GC pause (G1 Evacuation Pause)] 是设计内的转移,[Full GC (G1 Compaction Pause)] 才是出事的那一类。除了暂停分布,还要看有没有出现后一种,以及老年代占用曲线的形状——是平稳锯齿,还是一路上扬。

Region 大小是个牵一发动全身的旋钮。堆固定时,Region 越大,总的 Region 数量越少,回收的可选粒度就越粗——挑一批就是把一大块地方整个搬空;Region 越小,粒度越细,Humongous 的门槛也跟着降下来,原本正常的大对象开始变成 Humongous,反而多出一类要照顾的分配。这个值多数情况下留给默认就够,但看到 Humongous 日志频繁出现时,调它比继续压暂停目标更值得先试。

另一个要留意的开销是 RSet 占的内存和它的维护线程。G1 会拿一部分并发线程去做 Refine,把写屏障记下的脏记录整理进 RSet;这部分工作同样要抢 CPU 周期。

G1 在 JDK 9 成为服务端默认回收器,见 JEP 248。上一代服务端默认的 Parallel 并没有消失,它换了个位置:吞吐优先、对暂停不敏感的批处理任务上,它依然比 G1 划算。

ZGC 为什么敢说停顿和堆大小无关

G1 的转移必须暂停,堆越大、存活对象越多,这一段越长。ZGC(Z Garbage Collector)想做的是把转移也变成并发阶段,靠的是染色指针(colored pointers)和读屏障(load barrier)。

染色指针把标记信息编进 64 位指针本身的几个比特位,而不是记在对象头上。对象被搬走以后,那个仍然带着旧颜色的指针本身就是「这里已经没有对象了」的证据,GC 不需要扫遍整堆去找谁还指着它——指针自己带着答案。

应用线程每次通过指针访问对象,读屏障先看一眼颜色。发现对象已经转移,就按转发表把指针改成新地址再返回,这个动作叫自愈(self-healing),发生在应用线程自己身上,不需要把其他线程停下来等。

留下来的暂停阶段只做那些改不动对象图的工作,主要是扫描根集合。工作量取决于线程数和栈深度,基本不随堆大小增长——这就是「停顿与堆大小无关」的来源。

「停顿与堆大小无关」不等于「任何负载都低于 1 ms」:

  • CPU 核数不够,并发阶段跑不完,回收进度就会落后于分配进度。
  • 分配速率长期高于回收速率,堆占用一路爬升,最后还是兜不住。
  • 系统调度抖动、容器 CPU 限流同样会落进接口指标里。
  • 堆余量不足时,并发转移没有落脚的地方。

并发阶段本身也在吃 CPU 周期,省下来的是暂停,不是开销。上面这几条和「换 ZGC 就能解决延迟问题」是两回事。

版本演进也得跟上:

  • ZGC 在 JDK 11 作为实验特性引入,见 JEP 333。
  • JDK 15 转为正式可用于生产的特性,见 JEP 377。

ZGC 的参数与默认模式还在变,启用方式得按目标 JDK 的说明复核;照抄别人博客上的参数,最容易踩到版本差异。

读屏障的成本也值得写清楚:它挂在指针访问这条路径上,等于给每次对象访问加了一道检查,这笔开销落在执行访问的那个应用线程头上。它换来的是暂停与堆大小脱钩,但这份成本不出现在暂停里,而是摊进吞吐和 CPU。所以「ZGC 让延迟更好看」和「ZGC 让单位算力更便宜」是两个方向的事。

最后一点容易误判:用 jmap、jcmd 这类工具做堆直方图或堆转储时,请求活对象视图本来就会触发一次完整 GC。日志里因此出现一段远超平时的停顿,很可能是工具带来的,而不是服务自己的回收行为。排查时先把这段时间点和工具调用对上,别急着下结论。

那么下决心换的时候,大堆到底该选哪个?如果堆是几十 GB、暂停目标在几十毫秒这个量级,G1 通常够用,而且它的调参空间更大、回归风险更低。堆到了几百 GB 以上,或者服务对 P99 的容忍度本来就极窄,G1 转移存活对象那一停会随堆一起放大,这时候 ZGC 的并发转移才有意义。G1 和 ZGC 都吃 CPU,核数少、限流紧的容器里换过去可能适得其反。还有一种更常见的走错方向:真正的问题在分配速率,收集器再换也治不好。

用同一个负载比较,比哪几个数

比较收集器的前提是先固定变量:JDK 版本、机器或容器资源、堆大小、请求分布、预热条件。任何一个变了,数据就没法对齐。另外,GC 日志在 JDK 9 换成统一日志(Unified Logging)之后格式变过一次,两边的日志得先能对着看,再谈横向比较。这四类指标至少要看:

  • P99 尾延迟。一次几百毫秒的停顿在平均值上几乎没有痕迹,落在一个具体请求上就是超时。顺带看错误率和超时率有没有跟着动。
  • 吞吐与 CPU 占用。暂停短了、并发阶段多花的 CPU 是真实成本,同样机器能承载的 QPS 会跟着变。
  • GC 后的存活集。它决定下一轮回收要搬多少东西,也决定堆该开多大;存活集和堆上限之间的差距就是余量,这份余量同时也是并发回收能跑起来的前提。
  • 进程实际内存与容器限制。ZGC 的并发阶段和对象转移都要额外空间,只盯 Java 堆很容易把容器 OOM 记成内存泄漏。

把这三类回收器和这几项成本摆到一起,大致是这么个对应关系:

关注点主要看哪个数换过去要付什么
暂停短暂停分布、P99 尾延迟CPU 占用上升,吞吐可能下降
吞吐高QPS、CPU 占用单次暂停变长
扛大堆停顿是否随堆放大内存余量与 CPU 核数要给够

如果生产问题来自一个无限增长的本地缓存,换收集器只是把 Full GC 往后推:先看内存曲线是不是单调上涨、GC 后的存活集是不是真在长,再谈收集器。反过来,对象生命周期本身合理、当前收集器的暂停确实压住了业务目标,换才有清楚的验证方向——这个方向是能写进验收标准的,不是感觉。

换收集器之前,先看剩下几个变量

尾延迟这件事,GC 只是其中一个来源。把指标摊开之后,往往能看出暂停和接口超时之间对不对得上:

  • 一次几百毫秒的停顿,如果服务的超时设成 200 ms,它不只是慢,而是直接把请求打成失败,还可能连带触发上游重试,把压力放大。
  • 更常见的情况是暂停确实存在,但接口的 P99 主要由下游调用决定,换收集器改不动这一层。这种时候先看暂停分布和接口延迟分布是不是同一个形状。
  • 如果 P99 的尖峰出现在固定时间点,那更像定时任务或批量导出在集中分配对象,把批处理的节奏和并发度调下来,比换回收器便宜得多。

对象分配本身也值得先看一遍。短命对象多,年轻代回收就频繁,每次暂停不长但次数多;大对象多,G1 这边容易撞上 Humongous,ZGC 那边则要更频繁地转移。这两种问题的解法完全不同,而它们都会先表现为「延迟不稳」。另外,堆开得太小会把并发回收的余量吃掉,堆开得太大又会让一次整堆压缩的停顿更长——先把堆放到合理区间,再谈换哪个收集器,顺序不能反。

堆大小和线程模型还会互相牵制。并发度上去了,分配速率和对象存活时间也跟着变;同样一个收集器,放在「事件循环加业务线程池」和「一线程一请求」两种模型下,表现不会一样。所以压测的负载要和上线后的线程模型一致,否则测出来的暂停分布没有参考价值。

收尾

切换前笔者会在测试环境用同一负载把候选跑一轮,把上面几个数收集齐,再灰度到一台或一个可用区观察完整的业务周期,配置里留好回退。换收集器的收益只有落到接口指标上才算数,回收器之间也排不出一条必然的升级顺序。当年把 CMS 那一串参数当成通用最佳实践带过来的习惯,就是从「不问版本、只看结论」开始的——这一点和七年前写并发那篇时踩的是同一个坑:锁升级的链条、收集器的默认值,都是要看实现和版本的结论,不是能背下来的常识。

如果要在团队里推这件事,笔者会按下面这个顺序走:

  1. 先确认当前 JDK 版本和它默认的回收器,再谈要不要动。
  2. 用足够长的日志判断当前方案到底卡在哪一步,是并发模式失败、Humongous,还是纯粹的分配速率太高。
  3. 固定负载,把候选回收器的四类指标收集齐,日志格式对齐到同一个 JDK 版本可比。
  4. 灰度期间盯住 P99 和错误率,同时盯 CPU 与容器内存,别只看暂停。
  5. 上线后保留回退开关,并留下足够的观察时间——缓存类问题不会在一天里暴露。

真出问题时,那套从日志到堆转储的路子,笔者单独整理在了 一次 Full GC 排查 里;这篇只负责把「换哪个」的判断依据摆清楚。

至于 ZGC 在真实高分配速率负载下的表现,笔者还没有一手数据,先挖个坑,等有合适的压测环境再补。

← 返回文章列表