一、补全核心定义:三色状态的"动态性"与"不变式"
原文定义了三种颜色,这里补充两个支撑算法正确性的关键概念:
1.1 三色不变式(Tri-color Invariant)
为了保证标记结果的正确性,并发标记过程中必须始终维护以下两条不变式之一:
-
强三色不变式 :黑色对象不能直接引用白色对象。如果存在黑色→白色的引用,白色对象就会被错误地遗漏。
-
弱三色不变式 :所有被黑色对象引用的白色对象,必须能从某个灰色对象通过引用链到达。即白色对象要么不被黑色引用,要么被黑色引用时,一定有另一个灰色对象也在引用它(或能间接到达它)。
SATB和增量更新,本质都是通过不同的手段来维护这两条不变式。
1.2 "浮动垃圾"的本质
原文提到了浮动垃圾,这里明确它的来源:
-
定义 :在并发标记开始时 是可达对象(被快照记录),但在并发标记期间被用户线程改为不可达的对象。
-
为什么存在:SATB机制保证了快照中的所有对象都会被标记为存活,即使它们在标记过程中变成了垃圾。
-
影响:这些浮动垃圾无法在本次GC中被回收,必须等到下一次GC。这是G1和CMS内存占用有时看起来"回收不彻底"的核心原因之一。
二、SATB vs 增量更新:从"拦截点"看本质差异
原文从特性上做了对比,这里从写屏障拦截的具体操作来拆解,这是理解两者对性能影响的关键。
2.1 SATB(Snapshot-At-The-Beginning)------关注"删除"
-
拦截操作 :
reference.field = new_value(引用赋值) -
屏障逻辑:
java
// 伪代码:在赋值前执行 if (old_value != null && old_value.isWhite()) { // 将即将被覆盖的旧引用对象(old_value)记录到SATB日志中 satb_log.add(old_value); } // 执行实际赋值 reference.field = new_value; -
本质 :它关心的是**"引用被切断前,被切断的那个对象"**。即使这个对象后续再也没有被任何灰色/黑色对象引用,SATB日志也会保留它,使其在本轮GC中仍然存活。
-
性能特点 :"拦前" 操作,只需记录旧值,逻辑简单,对赋值操作的开销相对固定。
2.2 增量更新(Incremental Update)------关注"新增"
-
拦截操作 :
reference.field = new_value(引用赋值) -
屏障逻辑:
java
// 伪代码:在赋值后执行 // 执行实际赋值 reference.field = new_value; // 如果赋值方是黑色对象,且新值(new_value)是白色 if (reference.isBlack() && new_value.isWhite()) { // 将黑色对象(reference)重新标记为灰色,加入标记栈 mark_stack.push(reference); } -
本质 :它关心的是**"谁引用了新对象"**。通过将黑色对象"降级"为灰色,强制GC重新扫描这个黑色对象的所有引用,从而确保新建立的引用链被正确标记。
-
性能特点 :"拦后" 操作,且需要判断赋值方的颜色,如果赋值频繁且赋值方多为黑色对象,会导致大量对象被反复"降级"和重扫,增加GC负担。
2.3 为什么G1最终选择了SATB?
G1在早期版本曾尝试过增量更新,但最终在主流版本中稳定使用SATB,原因在于:
-
RSet的维护成本:G1的Region模型已经通过RSet维护跨代/跨区引用。SATB的"浮动垃圾"特性,与G1"分批回收、容忍部分垃圾"的设计哲学更匹配。
-
避免"标记抖动":增量更新在频繁引用修改的场景下,可能导致同一个黑色对象被反复标记为灰色、扫描、再变黑,产生不必要的重复扫描。
三、深入写屏障:不只是"记录",还有"剪枝"
原文提到写屏障,这里补充一个容易被忽视的细节:写屏障不仅是"记录者",还是"优化者"。
3.1 屏障的"过滤"机制
并不是所有引用修改都会被记录到SATB日志中。为了减少日志体积和后续处理开销,屏障会进行过滤:
-
如果旧引用对象已经是灰色或黑色:说明它已经被标记队列处理过或正在处理,不再记录(避免重复)。
-
如果新引用对象是白色,且赋值方是黑色:在SATB中不记录新增(因为SATB只关心被删除的引用),而是依赖已记录的旧引用链来保证可达性。这正是SATB"保守标记"的体现。
3.2 卡表(Card Table)与RS的配合
在G1中,写屏障除了处理SATB日志,还要维护RSet:
-
当发生引用赋值时,写屏障会判断这个赋值是否跨Region。
-
如果是跨Region,需要将目标Region的RSet中记录下"源Region引用了你"。
-
这个操作称为**"脏卡"**标记,后续GC线程通过扫描脏卡来更新RSet,避免在STW阶段全量扫描。
调优启示 :如果应用存在大量的跨Region引用修改(如频繁操作大型缓存Map),写屏障的开销会显著增加。此时可以通过-XX:G1ConcRefinementThreads调整RSet更新的并发线程数,或优化数据结构减少跨区引用。
四、三色标记在GC日志中的"蛛丝马迹"
理解三色标记后,再去看GC日志,你就能"看"到算法的执行痕迹。以G1为例:
4.1 并发标记阶段的日志
text
[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0012 secs]
[GC concurrent-mark-start]
[GC concurrent-mark-end, 0.0356 secs]
-
concurrent-mark阶段:就是三色标记中**"灰色→黑色遍历"**的核心过程。 -
如果这个阶段耗时较长,可能意味着对象图复杂(引用链深),或SATB日志积压严重。
4.2 最终标记阶段的修正
text
[GC remark, 0.0123 secs]
-
remark阶段(最终标记):就是STW处理SATB日志,将快照中记录的白色对象重新标记为灰色的过程。 -
如果这个阶段耗时异常,通常意味着SATB日志过大,说明在并发标记期间有大量的引用被修改(如大量临时对象创建和销毁)。此时需要关注代码中是否存在"频繁更新引用"的热点逻辑。
4.3 清理阶段的"垃圾占比"
text
[GC cleanup, 0.0011 secs]
- 清理阶段会统计每个Region的垃圾占比,这个占比就是三色标记结果的直接体现:黑色对象占Region的比例越低,垃圾占比越高,越有可能被优先回收。
五、易混淆概念辨析:三色标记 ≠ GC算法
这是一个常见的认知误区,这里明确一下层次关系:
-
三色标记 :是一种并发可达性分析算法 ,解决的是"如何在并发环境下准确标记存活对象"的问题。它是手段,不是目的。
-
CMS/G1/ZGC :是垃圾收集器 ,是完整的实现。它们都使用三色标记作为并发标记阶段的实现方式,但各自在"标记后的处理"(如CMS的并发清除、G1的复制整理、ZGC的染色指针)上完全不同。
类比:三色标记像"如何在地图上标注出所有需要保留的建筑"的通用方法,而CMS/G1/ZGC则是不同风格的"拆迁队",它们用这个方法标注,但后续怎么拆、怎么搬、怎么整理,各有各的流程。
六、总结:三色标记的"得与失"
补充一个原文未明说的本质权衡:
| 维度 | 传统STW标记 | 三色标记(并发) |
|---|---|---|
| 正确性保证 | 绝对准确,无漏标/错标 | 通过屏障保证无漏标,但允许错标(浮动垃圾) |
| 停顿时间 | 随堆增大线性增长,不可控 | 与堆大小无关,仅与GC Roots和SATB日志相关 |
| 吞吐量 | 高(无额外屏障开销) | 略低(写屏障、日志记录消耗CPU) |
| 内存占用 | 低 | 较高(RS、SATB日志、卡表等元数据) |
核心权衡 :三色标记本质是用**"CPU资源(屏障开销) + 内存资源(元数据) + 浮动垃圾(空间换时间)"** 来换取**"STW停顿时间的可控与降低"**。
理解了这个底层权衡,你在做GC选型和调优时,就能更清晰地判断:当应用对延迟极度敏感(如交易系统、游戏服务)时,接受少量浮动垃圾和CPU开销来换取低停顿是值得的;而当应用对吞吐量要求极高(如离线批处理)、且能接受较长STW时,Parallel Scavenge可能是更合适的选择。