问题引入
2002 年 CMS 收集器随 JDK 1.4.2 发布时,Java 堆的主流容量还在几百 MB 级别。到了今天,几十 GB 的堆司空见惯。如果仍然采用 Serial GC 的全程 STW(Stop-The-World)策略,一次 Full GC 的停顿时间将从秒级滑向分钟级——这对在线服务是毁灭性的。
CMS 的答案是并发标记:让 GC 线程和用户线程同时工作。但并发带来了一个致命的一致性问题——用户线程在 GC 线程扫描时修改了对象引用关系,导致"本该存活的对象被误杀"。
三色标记算法就是为了解决这个一致性困境而诞生的,而漏标(Lost Object Problem)则是并发标记必须跨过的最深陷阱。
核心概念
三色标记
三色标记将堆中的对象划分为三种颜色状态:
| 颜色 | 含义 | 状态 |
|---|---|---|
| 白色 | 未访问 | 候选垃圾 |
| 灰色 | 自身已访问,引用字段未扫描完 | 待处理 |
| 黑色 | 自身和全部引用字段都已扫描 | 确认存活 |
标记过程从 GC Roots 开始,初始时所有对象都是白色。GC Roots 直接引用的对象变为灰色,然后从灰色集合中不断取出对象,将其引用到的白色对象染灰,自身染黑,直到灰色集合为空。最终白色对象即为垃圾。
读图导引:黄色节点(灰色)是标记工作的"前沿"——灰色集合不为空,标记就未完成。绿色节点(黑色)是已确认存活的对象。注意最右侧白色对象 C,如果标记结束时仍为白色,它将被回收。
不变性:三色标记正确工作的前提是——黑色对象不会直接引用白色对象。如果黑色直接指向白色,而灰色集合已经为空,GC 就不会再去扫描那个白色对象,导致误杀。
并发标记的困境
当用户线程和 GC 标记线程并行执行时,引用关系会被动态修改。假设 GC 正在标记,用户线程执行了如下操作:
java
objA.field = objC; // 黑色 objA 新增指向白色 objC 的引用
objB.field = null; // 灰色 objB 删除了指向 objC 的引用
如果 objC 只被 objB 引用(灰色),GC 原本会在扫描 objB 时发现 objC。但 objB 删除了引用,而 objA(已被扫描完,已成黑色)新增了对 objC 的引用——GC 不会再扫描 objA 的引用字段。objC 被漏标了。
原理分析
漏标的充要条件
漏标不是任意引用修改都会发生的,必须两个条件同时满足:
读图导引:粉色节点(条件1)和蓝色节点(条件2)必须同时成立才会漏标。只满足一个不会出问题——如果条件1不满足(黑色没指向白色),白色对象确实无根可寻;如果条件2不满足(灰色还指向白色),灰色变黑色时会扫描到白色。
理解这个"与"关系至关重要:只要破坏其中一个条件,漏标就不会发生。CMS 和 G1 选择了不同的破坏路径。
CMS:增量更新
CMS(Concurrent Mark Sweep)选择破坏条件1。它的策略是:当黑色对象插入指向白色对象的引用时,把这个黑色对象重新变回灰色。
读图导引:纵向观察时间轴,关键转折点在第二条消息——写屏障(Write Barrier)在引用赋值后触发,将已完成的黑色对象重新拉回待处理的灰色状态。
术语锚定:
- 增量更新(Incremental Update):记录黑色对象新插入的指向白色对象的引用,然后将黑色对象重置为灰色,重新扫描
- 写屏障(Write Barrier):在引用类型字段赋值时插入的额外逻辑,类似于 AOP 的切面;CMS 使用写后屏障(Post-Write Barrier),在赋值操作完成后触发
暗面:增量更新的代价是可能重复扫描。如果黑色对象已经扫描了大量引用,重新变灰意味着这些工作要重来一次。更严重的是,如果用户线程持续修改引用,黑色对象可能反复被拉回灰色队列,导致标记阶段长时间无法结束——这就是 CMS 的并发模式失败(Concurrent Mode Failure)诱因之一。
G1:原始快照 SATB
G1(Garbage-First)选择破坏条件2。它的策略是:当灰色对象删除指向白色对象的引用时,把这个引用关系记录到快照中,让白色对象在本次 GC 中存活。
读图导引:注意时间顺序——写屏障在引用删除前触发(Pre-Write Barrier),先记录旧值,再执行赋值。objC 因此被标记为存活,即使它在逻辑上可能已经是垃圾(浮动垃圾)。
术语锚定:
- SATB(Snapshot At The Beginning):在并发标记开始时建立逻辑快照,任何在快照中存活的对象,即使引用被删除,也在本次 GC 中存活
- 前写屏障(Pre-Write Barrier):在引用赋值操作之前触发,记录被覆盖的旧引用值
- 浮动垃圾(Floating Garbage):并发标记期间引用关系变化导致的、本该回收却被标记为存活的对象;下次 GC 再回收,不会导致内存泄漏
暗面:SATB 的代价是产生浮动垃圾。由于 SATB 保守地保留快照中存活的对象,并发标记期间变成垃圾的对象会被延后回收。G1 的 Region 化设计可以缓解这个问题——下次 Young GC 或 Mixed GC 时这些浮动垃圾所在的 Region 会被清理。
两种方案对比
| 维度 | CMS 增量更新 | G1 SATB |
|---|---|---|
| 破坏条件 | 条件1(黑色插入白色引用) | 条件2(灰色删除白色引用) |
| 写屏障时机 | Post-Write(赋值后) | Pre-Write(赋值前) |
| 核心代价 | 重复扫描黑色对象 | 产生浮动垃圾 |
| 失败模式 | 并发模式失败(标记跟不上分配) | 较少出现 |
| 实现复杂度 | 较低 | 较高(需维护 SATB 队列) |
读图导引:粉色节点展示 CMS 的代价——重新扫描;蓝色节点展示 G1 的代价——保守标记。这是两种工程哲学:CMS 追求精确但承担重复工作风险,G1 追求稳定但承担空间浪费。
写屏障的本质
写屏障不是 JVM 层面的"钩子",而是 JIT 编译器在引用类型字段赋值时自动插入的一段机器码。以伪代码表示:
java
// 原始代码
obj.field = newValue;
// CMS 的 Post-Write Barrier 插入后
obj.field = newValue;
if (isBlack(obj) && isWhite(newValue)) {
enqueue(obj); // 将黑色对象重新标记为灰色
}
// G1 的 Pre-Write Barrier 插入后
Object oldValue = obj.field; // 先读旧值
if (isGray(obj) && isWhite(oldValue)) {
enqueue(oldValue); // SATB 记录
}
obj.field = newValue;
暗面:写屏障是高频操作——每次引用赋值都要执行额外逻辑。如果屏障逻辑太重(如频繁入队列),会拖慢用户线程。G1 为此做了大量优化:SATB 队列使用本地缓冲区减少同步,标记采用位图(Bitmap)而非修改对象头,等等。
实战与源码
场景:CMS 并发模式失败排查
某服务使用 CMS,日志频繁出现 concurrent mode failure,随后退化为 Serial Old GC,停顿数秒。
根因分析:
- 并发标记阶段,新生代对象晋升速度过快,老年代空间在标记完成前就被填满
- 或者,增量更新导致黑色对象反复被拉回灰色队列,标记周期被拉长
解决方向:
- 调低触发 CMS 的阈值
-XX:CMSInitiatingOccupancyFraction(默认 92%,可降至 70%) - 增大老年代空间
- 减少大对象分配(
-XX:PretenureSizeThreshold) - 根本方案:升级到 G1 或 ZGC
场景:G1 的 Remark 阶段停顿优化
G1 的并发标记分为四个阶段:Initial Mark(STW)→ Concurrent Mark → Remark(STW)→ Cleanup(STW)。其中 Remark 阶段处理 SATB 队列中的剩余记录。
如果 Remark 停顿过长,通常是 SATB 队列积压过多。可开启:
-XX:+ParallelRefProcEnabled:并行处理 Reference 对象- 调整
-XX:RefsPerThread:控制每个线程处理的引用数
常见问题
Q1:漏标会导致什么后果?
漏标意味着存活对象被当作垃圾回收。如果对象 A 被漏标,后续代码再次访问 A 时,要么读到空指针(NullPointerException),要么读到被其他对象复用的内存(更危险,数据混乱)。这是并发 GC 的底线问题——宁可多保留(浮动垃圾),不可误杀。
Q2:为什么 SATB 不会漏标,但增量更新理论上也可能漏标?
增量更新在黑色重新变灰后,如果用户线程再次在 GC 重新扫描之前修改了引用(比如又把那个引用删了),理论上仍存在漏标窗口。虽然实际中通过多次迭代和最终 STW remark 阶段兜底,但 SATB 的"快照保守"策略在理论上更严密——只要快照时对象可达,就一定不会被回收。
Q3:ZGC 和 Shenandoah 还用三色标记吗?
ZGC/Shenandoah 的并发标记阶段仍然基于三色标记的思想,但它们不依赖写屏障来解决漏标。ZGC 使用染色指针(Colored Pointers)和读屏障(Load Barrier)——在读取对象引用时检查指针颜色,如果对象还未被重映射(Relocated),就触发并发处理。这彻底绕过了"黑色指向白色"的问题,因为读屏障保证了用户线程永远不会通过"坏指针"访问到错误位置。
Q4:浮动垃圾是内存泄漏吗?
不是。浮动垃圾只是延迟回收——它们在本次 GC 中幸存,但下次 GC 时如果引用关系确认已断,就会被回收。真正的内存泄漏是对象永远被某个 GC Root 引用着,永远无法回收。SATB 的保守策略不会产生泄漏,只可能暂时浪费一些堆空间。
总结
三色标记是并发 GC 的理论基石,而漏标问题揭示了一致性与并发性之间的根本张力。
- 三色不变性(黑色不指向白色)是标记正确的充要条件
- 漏标需要两个条件同时满足:黑色插入白色引用,且灰色删除指向同一白色对象的引用
- CMS 的增量更新破坏条件1,代价是黑色对象可能反复被拉回重扫
- G1 的 SATB 破坏条件2,代价是产生浮动垃圾,但标记过程更稳定可控
- 写屏障是实现上述策略的底层机制,CMS 用写后屏障,G1 用写前屏障
理解这个选择——"重扫还是保守"——是理解 G1 取代 CMS 的设计逻辑的关键一步。ZGC 与 Shenandoah 则走得更远,干脆抛弃了"写屏障修补三色标记"的思路,转而在指针和读屏障层面做文章。