1. 三色标记法概述
三色标记法(Tri-color Marking)是现代JVM垃圾回收器中用于并发标记阶段的核心算法。我第一次接触这个概念是在优化一个高并发交易系统时,当时系统频繁出现长时间GC停顿,深入研究后发现问题的根源就在于标记算法的实现细节。
简单来说,三色标记法通过给对象赋予黑、灰、白三种颜色状态,来追踪垃圾回收过程中对象的可达性状态。这种标记方式特别适合并发标记场景——也就是垃圾回收线程与用户线程同时运行的情况。在实际生产环境中,CMS和G1收集器都采用了这种标记策略。
关键理解:三色标记本质上是一种"渐进式标记"策略,它允许GC线程分批次、分阶段地完成标记工作,而不是一次性扫描整个堆内存。这种设计大幅减少了STW(Stop-The-World)的时间。
2. 三色标记的核心概念解析
2.1 三种颜色的具体含义
白色对象:这是所有对象的初始状态。表示该对象尚未被GC线程访问过,其可达性尚未确定。在标记周期开始时,堆中所有对象都是白色。
灰色对象:表示该对象已被GC线程访问,但其引用的对象还未被完全扫描。灰色对象就像一个"待办事项"——我们知道它存活,但还不清楚它引用了哪些对象。
黑色对象:表示该对象及其所有引用都已被完整扫描。黑色对象是确定存活的,且不会再次被扫描。如果其他对象引用了黑色对象,GC会认为这条引用路径已经被处理过。
2.2 标记过程的典型流程
初始标记(Initial Mark):短暂STW阶段,仅标记GC Roots直接关联的对象,将这些对象从白色变为灰色。这个阶段通常很快,因为只扫描直接引用。
并发标记(Concurrent Mark):GC线程与用户线程并发执行。从灰色对象开始,沿着引用链逐步标记。对象会从灰色变为黑色,其引用的对象从白色变为灰色。
最终标记(Final Remark):再次短暂STW,处理在并发标记期间发生变化的引用关系。这是确保标记准确性的关键阶段。
并发清除(Concurrent Sweep):回收所有白色对象占用的内存空间。
3. 并发标记的挑战与解决方案
3.1 多标问题(浮动垃圾)
多标产生的典型场景:
- 线程栈上的局部变量(GC Root)引用了对象A
- GC线程已经标记完A及其引用链
- 方法执行结束,局部变量被销毁
- 对象A及其引用链实际上已经不可达,但已经被标记为存活
这种情况产生的垃圾称为"浮动垃圾"。实际上这不是严重问题,只是会导致本次GC少回收一些内存。这些浮动垃圾会在下一次GC时被回收。
实战经验:在内存充足的系统中,浮动垃圾的影响可以忽略。但对于内存紧张的边缘设备,可能需要通过调整-XX:+CMSScavengeBeforeRemark参数来减少浮动垃圾。
3.2 漏标问题(致命错误)
漏标必须同时满足两个条件:
- 灰色对象断开了对白色对象的引用(条件1)
- 黑色对象重新引用了该白色对象(条件2)
这种情况会导致本应存活的对象被错误回收,可能引发严重的程序错误。以下是一个代码示例:
// 初始状态 class A { C c; } // 灰色对象 class B { } // 黑色对象 class C { } // 白色对象 // 并发期间操作 a.c = null; // 条件1:灰色对象A断开对白色对象C的引用 b.c = c; // 条件2:黑色对象B引用了白色对象C4. 解决漏标的两种策略
4.1 增量更新(Incremental Update)
代表实现:CMS收集器
核心思想:当黑色对象插入对白色对象的引用时,将该黑色对象变回灰色。相当于记录"新增的引用"。
具体实现是通过写屏障(Write Barrier)技术:
// 写屏障伪代码 void writeBarrier(Field field, Object newValue) { if(isBlack(field.owner) && isWhite(newValue)) { markGray(field.owner); // 将黑色对象变灰 } field = newValue; // 执行实际写操作 }4.2 原始快照(Snapshot At The Beginning, SATB)
代表实现:G1收集器
核心思想:保留标记开始时的对象图快照。记录被删除的引用,确保按照快照中的引用关系来标记。
对应的写屏障实现:
// SATB写屏障伪代码 void satbWriteBarrier(Field field, Object oldValue) { if(isGray(field.owner) && isWhite(oldValue)) { addToSATBQueue(oldValue); // 记录被断开引用的对象 } field = newValue; // 执行实际写操作 }4.3 两种策略对比
| 特性 | 增量更新 | 原始快照 |
|---|---|---|
| 记录方向 | 记录新增引用 | 记录删除引用 |
| 重新扫描对象 | 黑色对象变灰 | 白色对象入队 |
| 适用场景 | 写多读少 | 读多写少 |
| 内存开销 | 相对较小 | 需要维护SATB队列 |
| 典型收集器 | CMS | G1 |
5. 三色标记的实践影响
5.1 对GC停顿时间的影响
三色标记通过并发标记大幅减少了STW时间。以G1收集器为例:
- 初始标记:通常1-5ms
- 并发标记:100-500ms(完全并发)
- 最终标记:5-20ms
- 并发清除:完全并发
相比之下,传统的标记-清除算法可能需要数百毫秒的STW时间。
5.2 内存占用考量
三色标记需要额外的空间来存储颜色信息。现代JVM通常使用标记位图(Mark Bitmap)来实现:
- 每个对象头中的标记位
- 单独的位图区域
- 卡表(Card Table)辅助记忆集
典型的内存开销约为堆大小的1-2%。
5.3 调优参数建议
-XX:+CMSParallelRemarkEnabled:启用CMS并行最终标记-XX:+CMSScavengeBeforeRemark:最终标记前先做一次Young GC-XX:G1ConcRefinementThreads:G1并发标记线程数-XX:+ParallelRefProcEnabled:并行处理引用对象
6. 常见问题排查
6.1 标记时间过长
症状:并发标记阶段耗时异常增加 可能原因:
- 堆过大且标记线程不足
- CPU资源被应用线程抢占 解决方案:
- 增加
-XX:ConcGCThreads - 减少
-XX:InitiatingHeapOccupancyPercent
6.2 最终标记停顿时间波动大
症状:Final Remark阶段时间不稳定 可能原因:
- 引用变化频繁
- SATB队列溢出 解决方案:
- 增加
-XX:G1SATBBufferSize - 调整
-XX:G1ReservePercent
6.3 漏标导致的程序错误
症状:对象被意外回收导致NPE 诊断方法:
- 添加
-XX:+VerifyAfterGC验证 - 检查GC日志中的引用处理记录 解决方案:
- 确保写屏障正确实现
- 考虑切换到更保守的GC策略
7. 从源码角度看三色标记
HotSpot VM中相关关键类:
ConcurrentMarkThread:并发标记的主线程CMBitMap:并发标记位图实现SATBMarkQueueSet:SATB队列管理CMMarkStack:灰色对象标记栈
关键代码片段:
// 写屏障实现示例(hotspot/src/share/vm/gc_implementation/g1/g1BarrierSet.cpp) void G1BarrierSet::write_ref_field_pre_work(oop* field, oop new_val) { if (!_g1->is_marked_next((void*)new_val)) { satb_mark_queue_set().enqueue(new_val); } }这个屏障确保任何被覆盖引用的对象都会被记录到SATB队列中,防止漏标。