一个被误判的停顿
某订单服务在促销时段每隔几十秒出现一次数百毫秒的 GC 停顿,堆使用率并不高,年轻代回收也很快,但老年代混合回收的停顿明显拉长。运维第一反应是加大堆,结果停顿没有消失,只是间隔变长。日志里 Update RS 和 Scan RS 两个阶段占比偏高,说明问题不在存活对象数量,而在 G1 需要扫描的卡数量。
G1 把堆划分为固定大小的卡(card),每张卡对应堆上一小块区域,卡表(card table)用一个字节记录该卡是否“脏”。应用线程修改对象引用时,写屏障会把对应卡标记为脏,并把卡位置放入脏卡队列(Dirty Card Queue)。并发 refinement 线程随后取出这些卡,重新检查其中的引用,把真正跨区域的引用登记到 RSet(Remembered Set,记忆集)中。如果应用产生脏卡的速度长期超过 refinement 线程的处理速度,队列会堆积,GC 暂停时就要一次性处理大量积压卡,Update RS 阶段随之拉长。
这篇文章要回答的是:当生产速度超过消费速度时,G1 靠什么机制把压力反弹回应用线程,而不是让队列无限膨胀。
写屏障把卡放进队列
写屏障是编译进应用代码的一小段逻辑,在引用字段被写入后执行。它做两件事:把卡表对应字节置为脏,然后把卡地址追加到当前线程私有的 refinement buffer 中。这个 buffer 是线程本地的,追加操作不需要全局锁,因此写屏障本身的开销很低。
线程本地 buffer 填满后,整批卡会被转移到全局的脏卡队列集合。这个集合由多个队列组成,应用线程和 refinement 线程分别从不同端操作,减少争用。卡进入全局队列后,就等待 refinement 线程消费。
这里有一个容易被忽略的细节:写屏障在标记卡之前会检查卡是否已经是脏的。如果卡已经是脏的,说明它已经在队列里或已经被处理过,重复入队没有意义。这个检查让同一张卡在被 refinement 线程处理之前,不会被反复追加。资料中描述的“人为延迟”正是利用这一点:卡从标记到被 refinement 处理之间存在时间差,在这段时间内应用对同一张卡的重复写入不会产生新的队列条目,从而降低队列增长速度。
队列阈值如何形成背压
脏卡队列不是无界缓冲区。G1 为队列长度设置了一个阈值,当队列中待处理卡的数量超过阈值时,应用线程在写屏障里不再只是入队,而是自己动手处理一部分卡。这就是背压:消费能力不足时,生产者也承担处理工作,队列长度被压回阈值以下。
这个协作由两个层次控制。第一层是队列长度阈值本身,它决定应用线程从什么时候开始参与处理。第二层是 -XX:G1ConcRefinementThresholdStep,它控制阈值随队列压力增长的步长。当队列持续偏高时,阈值会按步长上调,让更多应用线程更早地介入,形成渐进式的压力反馈。
需要区分的是,应用线程处理卡和 refinement 线程处理卡走的是同一套 refinement 逻辑,只是触发者不同。应用线程在写屏障路径上处理卡,会直接增加该次引用写入的延迟。因此背压的代价是把 GC 工作从暂停阶段转移到了应用运行阶段,用应用延迟换 GC 停顿。
示意逻辑:写屏障入队与背压判断
on reference write:
mark_card_dirty(card)
if card_already_dirty:
return
append_to_thread_buffer(card)
if thread_buffer_full:
flush_to_global_queue()
if global_queue_length > threshold:
process_cards_locally() // 应用线程参与 refinement
这段示意逻辑省略了并发细节,但展示了关键状态变化:卡从“干净”变为“脏”,从线程本地 buffer 进入全局队列,队列超阈值后应用线程开始消费。
并发 refinement 线程的处理路径
refinement 线程是 G1 启动的专用线程,数量由 -XX:G1ConcRefinementThreads 控制。它们从全局队列取出卡,执行以下步骤:先把卡表对应字节清为干净,然后扫描该卡覆盖的堆区域,找出指向其他区域的引用,最后把这些引用所属的卡位置登记到目标区域的 RSet 中。
清卡这一步很关键。清卡之后,应用线程如果再写同一个引用字段,写屏障会重新把卡标记为脏并再次入队。这保证了在 refinement 线程扫描卡内容期间发生的新写入不会被遗漏。
如果配置 -XX:G1ConcRefinementThreads=0,G1 不启动专用 refinement 线程,所有 refinement 工作都由应用线程在写屏障路径上完成。这种配置下背压是常态而非例外,适合 refinement 工作量很小的场景,但不适合写引用密集的服务。
refinement 线程的启动和停止是动态的。G1 根据队列积压程度决定激活多少个线程,积压越多,激活的线程越多,直到达到配置的上限。这个动态调节和阈值背压共同构成两层控制:线程数量应对持续压力,阈值背压应对突发压力。
flowchart TD
A[应用线程写引用] --> B[写屏障标记卡为脏]
B --> C{卡已是脏?}
C -->|是| D[跳过入队]
C -->|否| E[追加到线程本地 buffer]
E --> F{buffer 满?}
F -->|是| G[转移到全局脏卡队列]
F -->|否| H[继续应用执行]
G --> I{队列长度超阈值?}
I -->|是| J[应用线程本地处理卡]
I -->|否| K[refinement 线程消费]
J --> L[清卡并扫描引用]
K --> L
L --> M[登记到目标区域 RSet]
图中的关键转折在 队列长度超阈值 这个判断:它把原本只由 refinement 线程承担的消费工作部分转移给应用线程,从而限制队列继续增长。
参数与行为的对应关系
下表列出与脏卡队列背压直接相关的参数,以及调整它们时实际改变的行为。参数名和语义来自 OpenJDK 源码中 G1ConcurrentRefine 相关定义,具体默认值随 JDK 版本和堆配置变化,不应硬编码记忆。
| 参数 | 作用对象 | 调大后的效果 | 调小后的效果 | 适用判断 |
|---|---|---|---|---|
-XX:G1ConcRefinementThreads | 专用 refinement 线程数上限 | 并发消费能力增强,应用线程参与减少 | 更多工作落到应用线程,写延迟上升 | 写引用密集且 CPU 有余量时上调 |
-XX:G1ConcRefinementThresholdStep | 队列阈值增长步长 | 阈值上升更快,应用线程更早介入 | 阈值上升更慢,队列允许更长 | 队列频繁堆积时观察其影响 |
-XX:G1RSetUpdatingPauseTimePercent | 暂停中处理脏卡的时间预算 | 允许暂停阶段处理更多积压卡 | 暂停阶段处理更少,更多留给并发 | 混合回收 Update RS 偏高时关注 |
-XX:G1ConcRefinementGreenZone | 激活线程的队列长度下限 | 更早激活线程 | 更晚激活线程 | 与线程数配合观察 |
-XX:G1ConcRefinementYellowZone | 激活更多线程的队列长度 | 更晚进入高并发档 | 更早进入高并发档 | 与线程数配合观察 |
-XX:G1ConcRefinementRedZone | 触发应用线程介入的队列长度 | 应用线程更晚介入 | 应用线程更早介入 | 写延迟敏感时谨慎调小 |
这张表的核心信息是:背压不是单一参数的结果,而是阈值、线程数和暂停预算三者共同作用。只调线程数而不看阈值,可能让队列在暂停前仍然堆积;只调阈值而不看线程数,可能让应用线程承担过多工作。
生产环境中的观测与定位
回到订单服务的场景。要确认停顿是否由脏卡队列堆积引起,可以按以下顺序收集信号。
第一,打开 GC 日志,观察混合回收中 Update RS 和 Scan RS 的耗时占比。如果 Update RS 明显偏高,说明暂停阶段在处理积压的脏卡。
第二,使用 JFR 的 GC 相关事件或 -Xlog:gc+refine=debug 观察 refinement 线程的活动。如果 refinement 线程长期处于激活状态且队列长度持续偏高,说明消费能力不足。
第三,观察应用线程的写延迟。如果写引用密集的路径上出现延迟抖动,且与队列堆积时间吻合,说明背压已经让应用线程参与了 refinement。
第四,检查 RSet 大小。如果 RSet 持续膨胀,说明跨区域引用多,refinement 工作量大,单纯增加线程可能只是延缓堆积。
诊断路径可以归纳为:先确认 Update RS 占比,再确认队列是否堆积,然后区分是线程数不足还是跨区域引用过多,最后决定是调线程、调阈值还是调整对象布局减少跨区域引用。
失败模式与边界
背压机制失效有几种典型表现。
队列持续堆积但 refinement 线程未满负荷,通常说明线程激活逻辑没有及时响应,或者队列长度统计与阈值判断之间存在延迟。这种情况下调大 G1ConcRefinementThreads 不一定有效,需要先确认线程是否被激活。
应用线程在写屏障路径上处理卡导致写延迟尖峰,是背压生效的代价。如果业务对写延迟敏感,调小阈值让应用线程更早介入反而会加剧问题,应该优先增加 refinement 线程或减少跨区域引用。
RSet 膨胀导致 refinement 本身变慢,形成正反馈:RSet 越大,每次 refinement 要检查的目标区域越多,处理速度越慢,队列越容易堆积。这种情况下调整队列参数只能缓解症状,根本办法是减少跨区域引用,例如调整对象分配让相关对象落在同一区域。
-XX:G1ConcRefinementThreads=0 的配置在写引用密集的服务中会让所有 refinement 工作落在应用线程上,写延迟会显著上升。这个配置只适合 refinement 工作量极小的场景。
需要明确的是,以上行为描述基于 OpenJDK G1 的实现。不同 JDK 版本在阈值计算、线程激活策略和默认值上可能有差异,参数语义应以对应版本的源码和文档为准。
与替代方案的比较
Serial 和 Parallel 收集器也使用卡标记,但它们不在并发阶段处理卡,而是把卡积累到 GC 暂停时统一扫描。这种方式实现简单,暂停阶段扫描卡的工作量取决于应用写入模式,写引用密集时暂停会明显拉长。G1 的并发 refinement 把部分工作移出暂停,代价是写屏障更复杂、需要专用线程和队列管理。
ZGC 和 Shenandoah 使用不同的屏障机制,不依赖卡表和 RSet 来管理跨区域引用,因此在跨区域引用密集的场景下没有 RSet 膨胀问题。但它们的屏障在每次引用读写时都有固定开销,且对堆和 CPU 的配置要求不同。选择哪种收集器取决于延迟目标、吞吐要求和应用的对象图特征,不能仅凭脏卡队列这一个维度判断。
从工程决策角度看,如果应用写引用不密集且停顿可接受,默认的卡标记加暂停扫描已经够用;如果写引用密集且停顿目标严格,G1 的并发 refinement 加背压是合理选择,但需要接受写屏障开销和调参成本;如果跨区域引用极多导致 RSet 持续膨胀,可能需要考虑对象布局优化或换用不依赖 RSet 的收集器。
脏卡队列的背压机制本质上是在应用延迟和 GC 停顿之间做权衡。它不会消除跨区域引用的处理成本,只是决定这个成本在应用线程和 GC 线程之间如何分配。理解这一点,才能在看到 Update RS 偏高时判断是该调参数还是该改代码。