1. 三色标记法基础概念
三色标记法(Tri-color Marking)是现代垃圾回收器实现并发标记的核心算法。这个算法的精妙之处在于,它通过简单的颜色状态转换,就能清晰地表达对象在垃圾回收过程中的可达性状态。
1.1 颜色状态定义
在JVM的实现中,每个对象都会有一个标记位(mark bit)来表示它的颜色状态:
-
白色(⚪):表示对象尚未被垃圾回收器访问过。在标记开始时,所有对象默认都是白色。标记结束后仍然保持白色的对象将被判定为垃圾。
-
灰色(🔘):表示对象已经被垃圾回收器访问过,但该对象引用的其他对象(即它的"孩子")还没有被完全检查。灰色对象会被放入一个专门的待处理队列。
-
黑色(⚫):表示对象及其所有引用的对象都已经被检查完毕。黑色对象被认为是确定存活的对象,不会被回收。
实际JVM实现中,这些颜色状态通常用对象头中的标记位来表示,而不是真的存储颜色值。例如在HotSpot VM中,mark word的特定比特位就用于标记对象状态。
1.2 基本标记流程
让我们通过一个简单的对象引用图来理解三色标记的基本过程:
code复制初始状态:
GC Roots → ⚪A → ⚪B → ⚪C
步骤1:标记GC Roots直接引用的对象为灰色
GC Roots → 🔘A → ⚪B → ⚪C
步骤2:处理灰色对象A
- 检查A引用的B,将B标记为灰色
- A本身标记为黑色
GC Roots → ⚫A → 🔘B → ⚪C
步骤3:处理灰色对象B
- 检查B引用的C,将C标记为灰色
- B本身标记为黑色
GC Roots → ⚫A → ⚫B → 🔘C
步骤4:处理灰色对象C
- C没有引用其他对象
- C标记为黑色
GC Roots → ⚫A → ⚫B → ⚫C
最终,所有从GC Roots可达的对象都被标记为黑色,而不可达的对象保持白色,可以被安全回收。
2. 并发标记的挑战与漏标问题
2.1 为什么需要并发标记?
在传统的Stop-The-World垃圾回收器中,标记阶段需要暂停所有应用线程。随着堆内存的增大(现代应用堆内存动辄数十GB),这种暂停时间会变得不可接受。并发标记允许垃圾回收线程与应用线程同时运行,大大减少了停顿时间。
2.2 漏标问题的产生
并发标记的核心难题在于:当垃圾回收线程正在标记对象图时,应用线程可能同时修改对象引用关系。这会导致一种称为"漏标"(Missing Mark)的问题,即本应存活的对象被错误地标记为垃圾。
考虑以下场景:
code复制初始状态:
GC Roots → 🔘A → ⚪B
⚫D(已完成标记)
步骤1:GC线程处理A
- A标记为黑色
- A到B的引用被移除
GC Roots → ⚫A
⚫D → ⚪B(应用线程新增的引用)
步骤2:标记结束
- B保持白色,被错误回收
这里,对象B实际上通过D可达,但由于D已经是黑色(被认为已经处理完成),GC线程不会再扫描它,导致B被漏标。
2.3 漏标的精确条件
漏标不是随便发生的,它需要同时满足两个严格条件:
-
赋值条件(Mutator Condition):一个黑色对象开始引用一个白色对象。即已经完成扫描的对象新增了对未扫描对象的引用。
-
删除条件(Deletion Condition):所有从灰色对象到该白色对象的引用都被删除。即没有任何灰色对象还保留着对该白色对象的引用。
只有当这两个条件同时满足时,漏标才会发生。理解这一点对设计解决方案至关重要。
3. 解决漏标的主流方案
3.1 CMS的增量更新(Incremental Update)
CMS(Concurrent Mark-Sweep)垃圾回收器采用增量更新策略来解决漏标问题。其核心思想是:破坏漏标的第一个条件,确保黑色对象不会新增对白色对象的引用。
实现机制:
- 通过写屏障(Write Barrier)技术拦截所有引用字段的写操作
- 当发现黑色对象要引用白色对象时,立即将黑色对象"降级"为灰色
- 这个灰色对象会被重新放入标记队列,等待再次扫描
示例:
code复制// 伪代码:CMS写屏障实现
void cms_write_barrier(oop* field, oop new_value) {
if ($gc_phase == MARKING && is_black($this) && is_white(new_value)) {
// 将当前对象由黑变灰
mark_gray($this);
// 加入标记队列
enqueue($mark_stack, $this);
}
*field = new_value; // 执行实际的引用更新
}
优缺点分析:
- 优点:实现相对简单,对写操作的影响较小
- 缺点:需要重新扫描被降级的对象,增加了标记阶段的工作量
- 适用场景:中等规模的堆内存,对停顿时间敏感但并发要求不极端的情况
3.2 G1的SATB(Snapshot-At-The-Beginning)
G1(Garbage-First)垃圾回收器采用SATB策略,其核心思想是:在标记开始时对整个对象图建立逻辑快照,确保能识别出所有存活对象。
实现机制:
- 标记开始时记录所有存活对象的初始引用关系
- 通过写屏障拦截所有引用删除操作
- 将被删除的引用记录下来,确保这些引用指向的对象不会被漏标
示例:
code复制// 伪代码:G1 SATB写屏障实现
void g1_write_barrier(oop* field, oop new_value) {
if ($gc_phase == MARKING) {
oop old_value = *field;
if (old_value != null && is_white(old_value)) {
// 记录被覆盖的引用
enqueue($satb_queue, old_value);
}
}
*field = new_value;
}
优缺点分析:
- 优点:处理删除引用更高效,适合大规模堆内存
- 缺点:需要维护SATB队列,占用额外内存
- 适用场景:大堆内存应用,特别是那些会产生大量临时对象的应用
3.3 ZGC的染色指针(Colored Pointers)
ZGC采用了一种完全不同的思路:染色指针技术。它将标记信息存储在指针本身而不是对象头中。
关键特点:
- 使用64位指针的高位存储标记信息(包括颜色状态)
- 读屏障(Load Barrier)确保在访问对象时能正确处理并发标记
- 不需要专门的写屏障来处理漏标问题
实现优势:
- 并发标记阶段几乎不需要暂停应用线程
- 标记信息与对象分离,减少了对对象头的修改
- 特别适合超大堆内存(TB级别)的场景
4. 实现细节与性能考量
4.1 写屏障的实现技巧
写屏障是并发标记的关键技术,其实现质量直接影响GC性能:
-
条件过滤:通过快速路径检查避免不必要的屏障操作
code复制if (gc_phase != MARKING) return; // 快速路径 -
卡表(Card Table)优化:将堆划分为卡(通常512字节一块),只扫描被修改的卡
code复制card_table[address >> 9] = DIRTY; -
并行处理:使用多个GC线程并行处理写屏障记录
4.2 标记队列的设计
高效的标记队列对并发标记性能至关重要:
- 并发数据结构:使用无锁或细粒度锁的队列实现
- 工作窃取:允许GC线程从其他线程的队列中偷取工作
- 批量处理:一次性处理多个对象,减少同步开销
4.3 与其它GC阶段的协同
三色标记需要与GC其它阶段良好配合:
- 初始标记(Initial Mark):短暂STW阶段,标记GC Roots直接引用的对象
- 并发标记(Concurrent Mark):主要的三色标记阶段
- 最终标记(Final Mark):处理剩余的灰色对象和写屏障记录
- 清理阶段:回收白色对象占用的内存
5. 实战经验与调优建议
5.1 常见问题排查
-
过早回收问题:
- 症状:对象被意外回收导致NullPointerException
- 检查:确认写屏障是否正确实现,SATB队列是否溢出
-
标记阶段过长:
- 可能原因:标记队列太小,GC线程不足
- 解决方案:增大标记栈大小,增加GC线程数
-
内存占用过高:
- 可能原因:SATB队列或卡表占用过多内存
- 调整:-XX:G1SATBBufferSize, -XX:G1UpdateBufferSize
5.2 参数调优指南
对于CMS:
code复制-XX:+UseConcMarkSweepGC
-XX:ParallelGCThreads=4 # 并发标记线程数
-XX:CMSInitiatingOccupancyFraction=70 # 触发CMS的堆占用率
对于G1:
code复制-XX:+UseG1GC
-XX:ConcGCThreads=4 # 并发标记线程数
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用率
-XX:G1HeapRegionSize=4m # 区域大小
对于ZGC:
code复制-XX:+UseZGC
-XX:ConcGCThreads=4 # 并发标记线程数
-XX:ZAllocationSpikeTolerance=2.0 # 分配速率容忍度
5.3 最佳实践
-
对象分配模式:
- 避免在标记阶段大量分配短期对象
- 长期存活对象尽量分配在老年代
-
引用更新模式:
- 减少在标记阶段修改对象引用
- 批量更新引用比单次更新性能更好
-
监控指标:
- 关注GC日志中的标记阶段耗时
- 监控SATB队列和标记栈的使用情况
6. 三色标记法的演进与未来
现代垃圾回收器对三色标记法进行了各种创新:
-
区域化标记:
- G1将堆划分为多个区域,可以并行标记不同区域
- ZGC进一步细分为2MB的页面,实现更细粒度并发
-
引用处理优化:
- 对软引用、弱引用等特殊引用类型的处理优化
- 并行处理引用对象,减少停顿时间
-
与压缩算法的结合:
- 标记完成后直接进行压缩,减少内存碎片
- 如Shenandoah的并发压缩算法
-
硬件加速:
- 利用现代CPU的SIMD指令加速标记过程
- 实验性的GPU加速标记算法
在实际系统开发中,理解三色标记法的原理有助于:
- 更合理地设计对象结构和引用关系
- 优化GC参数配置,减少停顿时间
- 诊断和解决与内存相关的问题
- 选择合适的GC算法和JVM配置