在高并发订单服务中,堆内存往往达到数 GB 甚至数十 GB,对象分配和引用更新频繁。当 G1 触发并发标记时,应用线程仍在运行,对象图不断变化。如果标记算法不能正确处理这些变化,就可能把存活对象误判为垃圾,导致程序数据丢失。G1 采用 SATB(Snapshot-At-The-Beginning)队列机制来解决这一问题。本文将以一个订单服务为例,分析 SATB 如何保证并发标记的正确性,以及它在工程上的代价和观察方法。
并发标记的挑战:三色标记与漏标
G1 的并发标记阶段使用三色标记算法遍历对象图。对象被标记为三种颜色:白色表示尚未被访问,灰色表示已被访问但其引用字段尚未全部扫描,黑色表示该对象及其所有引用字段都已扫描完毕。标记从 GC Roots 出发,将可达对象由白变灰,再变黑。标记结束后,白色对象被认为是不可达的,会被回收。
问题在于,标记是并发进行的。应用线程(Mutator)在修改引用关系的同时,标记线程在遍历对象图。Wilson 在 1994 年证明,当且仅当以下两个条件同时满足时,一个存活对象会被漏标:
- 一个黑色对象被插入了一条指向白色对象的新引用;
- 所有从灰色对象到该白色对象的直接或间接引用都被删除了。
例如,订单服务中有一个订单对象(黑色)和一个支付对象(白色),支付对象原本只被一个灰色对象引用。并发标记期间,应用线程将支付对象引用赋给订单对象,同时删除了灰色对象对支付对象的引用。此时,标记线程尚未扫描到支付对象,它仍是白色,但已经没有灰色对象能引导标记到达它,最终会被误判为垃圾。
要避免漏标,只需破坏上述两个条件之一。由此产生两种策略:增量更新(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 触发了一次并发标记。整个过程分为几个阶段:
- 初始标记(Initial Mark):短暂 STW,标记 GC Roots 直接引用的对象,并设置 nextTAMS 指针。
- 并发标记(Concurrent Mark):应用线程和标记线程并发执行。标记线程从 GC Roots 开始遍历对象图,同时应用线程的引用赋值会触发写前屏障,将旧引用入队。
- 重新标记(Remark):短暂 STW,处理 SATB 队列中剩余的引用,完成最终标记。
- 清理(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 Mark 和 SATB Buffer 等关键字。
JFR(Java Flight Recorder)提供了更细粒度的事件,如 G1ConcurrentMark、G1SATBBufferFlush 等。通过 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 监控标记过程,确保服务的稳定运行。