垃圾收集器底层算法-三色标记法

796 阅读7分钟

垃圾回收,回收的是在系统中处于“死亡”状态的对象,那怎么判定对象是处于 “存活”还是“死亡”呢?

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1;任何时候计数器为0的对象就是不可能再被使用的。

但是引用计数法无法解决循环引用的问题。

可达性分析算法

JVM默认时通过可达性分析来判定对象是否存活的,基本思路就是通过一系列的称为 “GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots 没有任何引用链相连,则证明此对象时不可用的。

所有的垃圾回收器在执行这一系列操作时,必不可少的会引起 STW(Stop The World),为什么?因为你在分析的过程中,已经分析过的对象引用关系发生了变化,等分析完成后,对已经分析过的对象就会出现判定失误的情况。所以可达性分析的过程中是必定需要STW的。那如何来减少STW的时间?接下来三色标记法登场。

三色标记法

什么是三色标记法。

对分析的对象进行颜色标记,有三种颜色:黑色、灰色、白色,每一种颜色代表什么含义呢?

黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色对象代表已经扫描过,它是安全存活

灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

白色:表示对象尚未被垃圾收集器访问过。在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束阶段,仍然是白色的对象,即代表不可达。

Untitled-2022-06-13-1010.png

如上图,A和C均是被扫描过,且对象的所有引用都已经扫描过。 B是灰色,因为B的引用中只有C被扫描过,而D还没有被扫描过。D和E都没有被扫描过,初始时默认白色,所以D和E都是白色,当D被扫描过后,D和B都会变成黑色。但是E没有可以直达的引用,无法被扫描到,E最终还是白色。白色最终将会被垃圾回收器回收掉。

但是三色标记也存在缺陷,在并发标记阶段,因为用户线程与GC线程会同时运行,在这个过程中会出现漏标和多标的问题。

多标

假设已经遍历到B了(变成了灰色),此时执行了 A->B = null,断开了。此时B,C,D变成不可达了,但是依然会遍历下去,最终标记成黑色,B,C,D在本轮的GC中依然作为存活对象,不会被回收掉,而是等到下一轮GC时才会被回收掉。这部分应该被回收却没有被回收的被称为浮动垃圾

Untitled-2022-06-13-1011.png

漏标

什么情况下会产生漏标?假如已经遍历到了B,此时B已经标记成灰色,D还处于未被扫描的状态,执行了一下逻辑,B和D的引用断开,A和D产生引用:

D d = a.b.d; //读
a.b.d = null; //写
a.d = d; //写

Untitled-2022-06-13-1012.png B继续遍历最终会变成黑色,A虽然和D产生了引用,但是 A已经被标记成黑色了,所以D会一直处于白色,这就导致被引用的对象被当成垃圾误删除,这属于严重的BUG。

那么有什么办法来解决漏标的问题?

D d = a.b.d; //读
a.b.d = null; //写
a.d = d; //写

解决方案就是把误解的这部分对象,在上边读/写/写三个操作的任意一个步骤中记录下来(并发标记),然后在统一重新扫描(重新标记)。不过需要了解的一点是重新扫描是需要STW的,如果程序一直跑的话,集合数据就会一直不多的增加。由此可以看出,三色标记也不能完全的摒弃掉STW,只是尽量缩短了STW的时间。

针对漏标的问题,JVM 团队采用了读屏障与写屏障的方案。读屏障是拦截第一步;而写屏障用于拦截第二和第三步。

读屏障

读屏障直接针对的第一步:D d = a.b.d;,当读取成员变量时,一律记录下来。

//伪代码
oop oop_field_load(oop* field) {
    pre_load_barrier(field); // 读屏障‐读取前操作3
    return *field;
}


void pre_load_barrier(oop* field) {
    oop old_value = *field;
    remark_set.add(old_value); // 记录读取到的对象
}

写屏障

给某个对象成员变量赋值时,伪代码如下:

/**
* @param field 某对象的成员变量,如 a.b.d
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) {
    *field = new_value; // 赋值操作
}

写屏障就是指在赋值操作前后,加入一些处理,此处可以参考AOP概念。

void oop_field_store(oop* field, oop new_value) {
    pre_write_barrier(field); // 写屏障‐写前操作
    *field = new_value;
    post_write_barrier(field, value); // 写屏障‐写后操作
}

写屏障分为两种:

  • 增量更新(Incremental Update)-插入操作记录
  • 原始快照(Snapshot At The Beginning,SATB)-删除操作记录

增量更新指的就是当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

void post_write_barrier(oop* field, oop new_value) {
    remark_set.add(new_value); // 记录新引用的对象
}

原始快照就是当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。

void pre_write_barrier(oop* field) {
    oop old_value = *field; // 获取旧值
    remark_set.add(old_value); // 记录原来的引用对象
}

总结

可达性分析的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现不同:比如白色/黑色集合一半都不会出现、灰色集合可以通过栈/队列/缓存日志等方式实现、遍历方式可以是广度/深度遍历等。

对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新

  • G1,Shenandoah:写屏障 + SATB

  • ZGC:读屏障

**为什么G1用SATB?CMS用增量更新?

SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。