Java 技术
#G1 GC#JVM#RSet#写屏障#GC调优

G1 GC 并发 refinement 线程:RSet 更新的异步处理与线程数调优

高并发订单服务中,写屏障产生的跨 Region 引用更新会先进入 Dirty Card Queue,再由并发 refinement 线程异步写入 RSet。本文解释这一异步链路的完整生命周期、refinement 线程与 mutator 的竞争关系,以及 -XX:G1RefinementThreads 等参数在吞吐与停顿之间的取舍,并给出监控与排查方法。

一个订单服务在流量高峰期出现了奇怪的现象:Young GC 的单次停顿从平时的十几毫秒涨到接近一百毫秒,但堆里并没有明显的大对象,Eden 区大小也没有变化。把 GC 日志翻出来,会发现每次 Young GC 的“Update RS”阶段耗时明显拉长,而“Scan RS”反而变化不大。这个信号通常指向同一个问题:并发 refinement 线程没能及时把写屏障产生的跨 Region 引用更新写进 RSet,导致 Young GC 在 STW 阶段不得不自己动手处理积压的 Dirty Card。

要理解这件事,需要先弄清楚 G1 为什么不能直接在赋值时更新 RSet,以及这套异步机制在什么条件下会失效。

写屏障为什么不能直接更新 RSet

G1 把堆切成大小相等的 Region,对象不再按物理连续的新生代、老年代划分,而是逻辑上属于某个代。这种布局带来一个直接问题:回收某个 Region 时,怎么知道堆里还有哪些对象引用了它?如果每次回收都扫描整个堆,G1 就失去了“只回收一部分 Region”的意义。

RSet(Remembered Set,记忆集合)就是为这个目的存在的。每个 Region 维护一份 RSet,记录“谁引用了我”——key 是引用方的 Region 起始地址,value 是被引用方 Region 内对应的 Card 索引。Card 是 Region 内部按固定粒度切分的内存块,Card Table 记录每个 Card 是否“脏”。RSet 是 points-into 结构,Card Table 是 points-out 结构,两者配合,让 G1 在做 Young GC 时只需扫描年轻代 Region 的 RSet,而不必遍历整个老年代。

问题出在更新时机上。对象引用赋值在 Java 程序里极其频繁,如果每次 putfield 都同步去改 RSet,就要在赋值路径上引入锁或原子操作,多线程下竞争会直接拖垮应用吞吐。G1 的解法是延迟处理:赋值时只通过写屏障把“这个 Card 变脏了”这个事实记录下来,真正的 RSet 更新交给后台线程。

写屏障分两部分。写前屏障服务于 SATB(Snapshot-At-The-Beginning),在引用被覆盖前记录旧值,维持并发标记的正确性。写后屏障负责跨 Region 引用跟踪:当发生跨 Region 的引用写入时,把对应的 Card 标记为脏,并把这个 Card 放进一个队列。这个队列就是 Dirty Card Queue。

每个应用线程(mutator)有自己的线程私有 Dirty Card Queue。私有队列写满后,整批转移到全局 Dirty Card Queue。这样设计的好处是:绝大多数写操作只碰线程本地结构,几乎无竞争;只有队列满时才需要一次转移,把竞争压缩到低频路径上。

从写屏障到 RSet:一次更新的完整生命周期

假设订单服务里有一个 Order 对象位于老年代 Region A,它的某个字段被更新为指向一个位于年轻代 Region B 的 Address 对象。这次赋值会触发写后屏障,把 Region B 中对应 Card 的索引记录到当前 mutator 的私有 Dirty Card Queue。

接下来发生的事取决于队列的积压程度。G1 用一组阈值把全局 Dirty Card Queue 的容量分成几个区间,通常称为绿区、黄区、红区。绿区表示积压很少,只需要少量 refinement 线程工作;黄区表示积压上升,需要激活更多线程;红区表示积压严重,refinement 线程数量会被提升到上限。

并发 refinement 线程从全局队列中取出 Card,解析出这个 Card 覆盖的内存范围,找出其中所有指向其他 Region 的引用,然后把“哪个 Region 的哪个 Card 引用了本 Region”写进目标 Region 的 RSet。这一步涉及对 RSet 内部数据结构的修改,不同 refinement 线程可能同时处理指向同一个 Region 的 Card,因此需要同步。

RSet 内部用 Per Region Table(PRT)记录引用情况,并会根据引用密度在三种粒度间切换:稀疏时直接记录 Card 索引,细粒度时记录引用方的 Region 索引,粗粒度时只用一个比特位表示“有引用”。粒度越粗,占用空间越小,但后续扫描时需要回退到整堆扫描才能找到具体引用,代价更高。

整个链路可以用下面的流程图表示:

flowchart TD
    A[mutator 执行跨 Region 赋值] --> B[写后屏障标记 Card 为脏]
    B --> C[写入线程私有 Dirty Card Queue]
    C --> D{私有队列是否已满}
    D -->|否| E[继续处理业务逻辑]
    D -->|是| F[整批转移到全局 Dirty Card Queue]
    F --> G{全局队列积压区间}
    G -->|绿区| H[少量 refinement 线程工作]
    G -->|黄区| I[激活更多 refinement 线程]
    G -->|红区| J[线程数提升到上限]
    H --> K[解析 Card 并更新目标 Region 的 RSet]
    I --> K
    J --> K
    K --> L{Young GC 触发时是否仍有积压}
    L -->|无积压| M[Update RS 阶段快速完成]
    L -->|有积压| N[STW 阶段由 GC 线程处理剩余 Card]

这张图里最关键的分叉在最后一步。如果 refinement 线程在 Young GC 开始前已经把全局队列清空,那么 GC 的 Update RS 阶段几乎不花时间;如果还有积压,GC 线程必须在 STW 状态下继续处理,这段时间直接加到停顿上。订单服务遇到的停顿抖动,往往就来自这条路径。

refinement 线程与 mutator 的竞争关系

refinement 线程和 mutator 抢的是同一批 CPU 核心。这是一个零和博弈:refinement 线程占用核心越多,mutator 能用的核心越少,应用吞吐下降;refinement 线程太少,Dirty Card 积压,停顿变长,甚至可能因为 RSet 更新不及时影响回收精度。

在容器化部署中,这个矛盾会更明显。如果容器的 CPU limit 设得比较紧,JVM 看到的可用处理器数量可能远小于宿主机,refinement 线程数量、GC 线程数量的默认计算都会受影响。此时如果应用本身线程数很多,refinement 线程和 mutator 的争抢会加剧。

另一个容易被忽略的竞争点是全局 Dirty Card Queue 本身。所有 mutator 的私有队列满时都要往全局队列转移,所有 refinement 线程都从全局队列取任务,这个结构上的同步点在高并发下会成为热点。私有队列的存在正是为了把大部分压力挡在全局队列之外,但如果单个 mutator 产生脏 Card 的速度极快,私有队列会频繁写满,转移操作就会变得密集。

还有一种情况是“假性空闲”:refinement 线程数量足够,但全局队列仍然积压。这通常意味着单个 Card 的处理成本很高,比如某个 Region 的 RSet 已经退化到粗粒度,或者大量 Card 指向同一个 Region 导致锁竞争。此时增加线程数收益有限,需要从引用模式入手。

线程数参数与调优判断

G1 提供了一组参数控制 refinement 线程的行为。需要说明的是,这些参数属于 HotSpot 实现细节,不同 JDK 版本的具体默认值和内部阈值可能不同,调优前应以目标 JDK 版本的官方文档和实际观测为准。

参数作用调大时的效果调小时的代价适用判断
-XX:G1ConcRefinementThreads设置并发 refinement 线程数积压消化更快,但占用更多 CPU 核心积压可能持续到 STW 阶段队列长期不清空且 CPU 有余量时考虑调大
-XX:G1ConcRefinementGreenZone绿区阈值,低于此值只需少量线程更早激活线程,响应更及时可能过早占用 CPU通常保持默认,除非观测到积压起步太慢
-XX:G1ConcRefinementYellowZone黄区阈值,触发更多线程更早进入高线程数状态与 mutator 争抢加剧积压增长快但总量不高时可微调
-XX:G1ConcRefinementRedZone红区阈值,线程数提升到上限极端积压时消化能力更强停顿抖动可能转移到吞吐下降仅在确认队列频繁触及红区时关注
-XX:G1ConcRefinementServiceIntervalMillisrefinement 线程的服务间隔响应更快,CPU 占用上升响应变慢,积压可能增加与停顿目标联动调整

这张表的核心信息是:线程数不是唯一旋钮,阈值决定了线程“什么时候被叫醒”。如果积压增长很慢但始终不清零,调阈值比调线程数更有效;如果积压瞬间冲高,调线程数上限才有意义。

调优的起点应该是观测,而不是直接改参数。GC 日志中与 refinement 相关的关键信号包括:Update RS 阶段的耗时、每次 Young GC 处理的 Dirty Card 数量、并发 refinement 线程的实际工作时间。如果 Update RS 耗时占比持续上升,同时并发阶段处理的 Card 数量没有同步上升,说明积压确实存在。

生产环境的可观测信号与排查路径

在订单服务这类高并发场景中,建议按以下顺序排查。

先确认是不是 refinement 问题,而不是其他 GC 阶段的问题。打开 GC 日志,对比 Update RS、Scan RS、Object Copy 各阶段的耗时占比。如果 Update RS 明显拉长,而 Scan RS 稳定,基本可以定位到 RSet 更新链路。

然后看 Dirty Card 的产生速率。如果应用侧有大量跨代引用写入,比如频繁更新老年代对象中指向新创建对象的字段,脏 Card 产生速率会很高。这类代码模式在业务上往往可以优化:把频繁变更的引用集中到少数对象上,或者调整对象生命周期让它们尽量留在同一代内,都能减少跨 Region 引用。

接着看 CPU 使用情况。如果 refinement 线程已经占用了可观的核心数,但队列仍然积压,说明问题不在线程数量,而在单个 Card 的处理成本或锁竞争。此时可以观察 RSet 的粒度分布,粗粒度占比高意味着后续扫描代价大,需要从引用密度入手。

最后考虑容器边界。在 CPU limit 较紧的容器里,盲目调大 refinement 线程数会挤压 mutator,导致整体吞吐下降。这种情况下,更合理的做法是评估是否可以通过降低跨 Region 引用频率来减少脏 Card 产生,而不是继续加线程。

与替代方案的权衡

G1 的这套异步 refinement 机制,本质是在“赋值路径低延迟”和“RSet 更新及时性”之间做交换。对比来看:

如果直接在写屏障里同步更新 RSet,赋值路径会引入同步开销,高并发下竞争严重,吞吐下降明显,但 RSet 始终是最新的,GC 停顿更可预测。G1 选择异步,是为了保护应用吞吐,代价是引入了积压和 STW 阶段补处理的可能。

与 CMS 相比,CMS 同样使用 Card Table 和写屏障,但它的 RSet 概念和 G1 不同。G1 的 RSet 是 per-Region 的 points-into 结构,维护成本更高,但换来的是回收时可以只扫描部分 Region。这个设计选择决定了 G1 必须有一套独立的并发 refinement 机制来分摊维护成本。

与 ZGC、Shenandoah 这类低延迟收集器相比,它们采用了不同的并发标记和引用处理策略,对写屏障的依赖方式也不同,停顿特性更优,但通常需要更新的 JDK 版本和不同的调优思路。选择哪种收集器,取决于应用对停顿的敏感程度、JDK 版本约束和团队运维能力。

边界与不适用场景

这套调优方法有几个明确的前提。第一,问题确实出在 RSet 更新链路,而不是其他 GC 阶段或应用本身的内存分配模式。第二,CPU 资源有调整空间,或者可以通过减少跨 Region 引用来降低脏 Card 产生速率。第三,JDK 版本支持相关参数,且参数语义与文档一致。

如果应用本身跨 Region 引用极少,refinement 线程几乎不工作,调这些参数没有意义。如果停顿问题主要来自 Object Copy 或大对象分配,方向也不对。如果容器 CPU 已经饱和,增加 refinement 线程只会让 mutator 更慢,整体表现可能更差。

一个务实的做法是:先用 GC 日志和 JFR 确认 refinement 链路的实际状态,再决定是调线程数、调阈值,还是回到应用层减少跨代引用。参数调整只是手段,减少不必要的跨 Region 引用才是从源头降低这套机制压力的方式。

资料来源

  1. G1 GC 调优文档
  2. G1收集器:JVM垃圾回收的新一代王者 | Seven的菜鸟成长之路
  3. Java Hotspot G1 GC的一些关键技术 | 美团 · 技术团队
  4. 从原理聊JVM(二):从串行收集器到分区收集开创者G1-京东云开发者社区