Java 技术
#G1#SATB#并发标记#写屏障#JFR

G1 GC 并发标记的 SATB 队列:快照一致性与漏标问题的工程解法

本文聚焦 G1 GC 并发标记阶段的 SATB 队列机制,解释其如何通过写前屏障记录旧引用,保证快照一致性,避免漏标。结合高并发订单服务场景,分析 SATB 与增量更新的区别、浮动垃圾的产生,以及如何通过日志和 JFR 观察标记进度。

在高并发订单服务中,堆内存往往达到数 GB 甚至数十 GB,对象分配和引用更新频繁。当 G1 触发并发标记时,应用线程仍在运行,对象图不断变化。如果标记算法不能正确处理这些变化,就可能把存活对象误判为垃圾,导致程序数据丢失。G1 采用 SATB(Snapshot-At-The-Beginning)队列机制来解决这一问题。本文将以一个订单服务为例,分析 SATB 如何保证并发标记的正确性,以及它在工程上的代价和观察方法。

并发标记的挑战:三色标记与漏标

G1 的并发标记阶段使用三色标记算法遍历对象图。对象被标记为三种颜色:白色表示尚未被访问,灰色表示已被访问但其引用字段尚未全部扫描,黑色表示该对象及其所有引用字段都已扫描完毕。标记从 GC Roots 出发,将可达对象由白变灰,再变黑。标记结束后,白色对象被认为是不可达的,会被回收。

问题在于,标记是并发进行的。应用线程(Mutator)在修改引用关系的同时,标记线程在遍历对象图。Wilson 在 1994 年证明,当且仅当以下两个条件同时满足时,一个存活对象会被漏标:

  1. 一个黑色对象被插入了一条指向白色对象的新引用;
  2. 所有从灰色对象到该白色对象的直接或间接引用都被删除了。

例如,订单服务中有一个订单对象(黑色)和一个支付对象(白色),支付对象原本只被一个灰色对象引用。并发标记期间,应用线程将支付对象引用赋给订单对象,同时删除了灰色对象对支付对象的引用。此时,标记线程尚未扫描到支付对象,它仍是白色,但已经没有灰色对象能引导标记到达它,最终会被误判为垃圾。

要避免漏标,只需破坏上述两个条件之一。由此产生两种策略:增量更新(Incremental Update)和 SATB。增量更新破坏第一个条件:当黑色对象插入新引用时,记录该引用,并在重新标记阶段以黑色对象为根重新扫描。CMS 采用的就是增量更新。而 G1 采用 SATB,破坏第二个条件:当灰色对象删除引用时,记录被删除的旧引用,保证旧引用指向的对象仍被标记。

SATB 的核心机制:写前屏障与队列

SATB 的思想是:在并发标记开始时,对堆中存活对象做一个快照。标记过程中,所有被修改的引用关系都记录下来,确保快照中的存活对象不会被漏标。具体实现依赖于写前屏障(Pre-Write Barrier)。

在 G1 中,每次引用字段赋值操作前,都会执行一段屏障代码。这段代码会读取被赋值字段的旧值(即即将被覆盖的引用),如果旧值非空,就将其入队到一个 SATB 队列中。这样,即使应用线程随后删除了对该对象的引用,该对象仍会因队列中的记录而被标记。

SATB 队列是每个 Java 线程私有的,同时还有一个共享队列用于非 Java 线程(如 JIT 编译器线程)产生的记录。当私有队列满时,记录会被转移到全局队列。并发标记线程会定期处理这些队列中的引用,将它们标记为灰色,继续遍历。

以下是一个简化的伪代码,展示写前屏障的逻辑:

// 伪代码:引用字段赋值前的屏障
void preWriteBarrier(ReferenceField field, Object newValue) {
    Object oldValue = field.get();
    if (oldValue != null) {
        SATBQueue.enqueue(oldValue); // 将旧引用入队
    }
    field.set(newValue); // 实际赋值
}

这段代码的关键在于,它记录了被覆盖的旧引用,而不是新引用。这样,即使旧引用指向的对象在并发标记期间失去所有引用,它仍然会被标记为存活。

从根到队列:SATB 标记的完整流程

在订单服务中,假设 G1 触发了一次并发标记。整个过程分为几个阶段:

  1. 初始标记(Initial Mark):短暂 STW,标记 GC Roots 直接引用的对象,并设置 nextTAMS 指针。
  2. 并发标记(Concurrent Mark):应用线程和标记线程并发执行。标记线程从 GC Roots 开始遍历对象图,同时应用线程的引用赋值会触发写前屏障,将旧引用入队。
  3. 重新标记(Remark):短暂 STW,处理 SATB 队列中剩余的引用,完成最终标记。
  4. 清理(Cleanup):统计各 Region 的存活对象,为后续混合回收做准备。

在并发标记阶段,标记线程会不断从 SATB 队列中取出引用,将其指向的对象标记为灰色,并加入遍历栈。这样,即使某个对象在快照后失去所有引用,它仍会被标记,从而避免漏标。

以下流程图展示了 SATB 队列在标记过程中的作用:

flowchart TD
    A[应用线程修改引用] --> B[写前屏障记录旧引用]
    B --> C[旧引用入队 SATB 队列]
    C --> D[并发标记线程处理队列]
    D --> E[将旧引用指向的对象标记为灰色]
    E --> F[继续遍历对象图]
    F --> G[标记完成]

在这个流程中,关键是写前屏障在赋值前捕获旧引用,确保快照中的对象不会因引用删除而漏标。

SATB 与增量更新的对比

增量更新和 SATB 是解决漏标问题的两种不同思路。增量更新记录新插入的引用,在重新标记阶段重新扫描;SATB 记录被删除的引用,在并发标记阶段处理。两者在实现代价和浮动垃圾方面有显著差异。

对比维度增量更新(CMS)SATB(G1)
记录时机赋值后,记录新引用赋值前,记录旧引用
破坏条件破坏条件一(黑对象插入新引用)破坏条件二(灰对象删除引用)
浮动垃圾较少较多,因为所有被删除引用的对象都被标记
重新标记阶段需要重新扫描记录的对象图主要处理队列中的引用,扫描量较小
实现复杂度相对简单需要额外的队列和屏障
适用场景老年代收集,停顿时间敏感大堆、追求吞吐量和可预测停顿

从表格可以看出,SATB 的代价是产生更多浮动垃圾,因为它会保守地标记所有被删除引用的对象,即使它们已经是垃圾。但这也换来了更简单的重新标记阶段,因为大部分工作已在并发阶段完成。

浮动垃圾与漏标的区别

浮动垃圾(Floating Garbage)是指并发标记期间产生的新垃圾,它们未被本次标记识别,但会在下次 GC 中被回收。漏标则是指存活对象被误判为垃圾,会导致程序错误。SATB 通过记录旧引用,避免了漏标,但不可避免地产生了更多浮动垃圾。

例如,订单服务中有一个已取消的订单对象,在并发标记开始时它被一个活动订单引用。标记过程中,活动订单删除了对该订单的引用,该订单变成垃圾。但由于 SATB 记录了旧引用,它仍会被标记为存活,直到下次 GC 才被回收。这就是浮动垃圾。

浮动垃圾的代价是内存占用增加,可能导致更频繁的 GC。但相比漏标导致的程序崩溃,这是可以接受的。G1 通过调整并发标记的触发阈值,可以在一定程度上控制浮动垃圾的比例。

观察 SATB 与标记进度:日志与 JFR

在工程实践中,我们需要观察并发标记的进度和 SATB 队列的处理情况。G1 提供了丰富的日志和 JFR 事件。

通过 JVM 参数 -Xlog:gc+marking=debug 可以输出标记相关的日志,包括并发标记的开始、处理 SATB 队列的次数、标记耗时等。例如,日志中会出现 Concurrent MarkSATB Buffer 等关键字。

JFR(Java Flight Recorder)提供了更细粒度的事件,如 G1ConcurrentMarkG1SATBBufferFlush 等。通过 JFR 可以观察到:

  • 并发标记的持续时间;
  • SATB 队列的处理频率和耗时;
  • 标记线程的 CPU 使用率;
  • 浮动垃圾的估计量。

在订单服务中,如果发现并发标记阶段耗时过长,或 SATB 队列积压严重,可能意味着应用线程的引用更新过于频繁,或者堆中对象图过大。此时可以调整 -XX:ConcGCThreads 增加并发标记线程数,或调整 -XX:G1SATBBufferSize 增大队列缓冲区。

以下是一个 JFR 事件的示意片段(非真实数据):

G1ConcurrentMark {
  start = 10:00:00.000
  duration = 120 ms
  satbBuffersFlushed = 45
  markedBytes = 2.5 GB
}

通过持续监控这些指标,可以在标记异常时及时调整参数,避免因标记延迟导致 Full GC。

适用边界与失败模式

SATB 机制在 G1 中运行良好,但它并非没有边界。以下情况可能导致 SATB 失效或产生问题:

  • SATB 队列积压:如果应用线程的引用更新速度过快,SATB 队列可能积压,导致标记线程处理不过来,最终在重新标记阶段花费更多时间。
  • 浮动垃圾过多:如果应用在标记期间大量删除引用,会产生大量浮动垃圾,可能导致堆内存压力增大,触发更频繁的 GC。
  • 并发标记失败:如果并发标记无法在堆满之前完成,G1 会退化为 Full GC,导致长时间停顿。

在订单服务中,如果出现突发的大批量订单取消操作,会短时间内产生大量引用删除,SATB 队列会迅速增长。此时应观察日志,如果发现 SATB Buffer 处理频繁,可以考虑增加并发标记线程数。

另外,SATB 只保证快照开始时存活的对象不被漏标,对于标记期间新分配的对象,G1 通过 TAMS 指针将它们视为隐式存活,不会参与标记。这可能导致新分配的对象在下次 GC 前一直占用内存,但不会造成漏标。

结语

SATB 队列是 G1 并发标记正确性的基石。它通过写前屏障记录旧引用,破坏了漏标的必要条件,保证了快照一致性。虽然它带来了浮动垃圾的代价,但相比漏标导致的程序错误,这是值得的。理解 SATB 的机制,有助于我们在高并发场景下正确配置 G1,并通过日志和 JFR 监控标记过程,确保服务的稳定运行。

资料来源

  1. G1: A Garbage-First Garbage Collector (PDF)
  2. Java Hotspot G1 GC的一些关键技术 | 美团 · 技术团队
  3. JVM G1(Garbage First)垃圾收集器浅析
  4. SATB深入详解与问题剖析【纯理论】 - cexo