在并发标记阶段,使用三色标记算法进行可达性分析性存在漏标问题,了解读写栅栏在解决该问题中具体应用有助于进一步加深并对发标记过程理解。
停止业务线程,只运行垃圾回收线程(STW, stop-the-world)会导致单次垃圾回收时间过长,影响用户体验,使用三色标记法(Tri-color marking)进行并发标记,并发标记结束后,再进行STW,对并发标记过程中较少记录对象进行判断回收,缩短单次垃圾回收STW时间。使用三色并发标记算法执行过程中,会出漏标的情况,漏标导致原本应该存活的对象被认为已经死亡,进而被回收内存,导致程序无法正常执行,使用读写栅栏可以解决该问题。
三色标记算法
顾名思义,三色标记算法使用三种颜色进行标记,常用三色为黑色、灰色和白色,
白色:该对象还没有被遍历,开始时,所有对象都是白色。
灰色:该对象已经被遍历,但该对象直接引用至少还有一个还没有被遍历,表示该对象正在遍历中。
黑色:对象和对象直接引用都已经被遍历标记。
三色标记算法标记过程
1、对所有GC Roots添加到待遍历集合,将GC Roots中所有对象的直接引用标记为灰色,遍历过的GC Roots变成黑色,可以将所有灰色对象添加到一个容器中,容器可以是对列、栈。
2、然后从灰色队列里面取出一个对象进行分析,将遍历到灰色对象的直接引用添加到灰色队列中,如果没有直接饮用,直接将灰色对象标记为黑色。
3、重复步骤2,直到灰色队列为空。
4、标记结束,仍然为白色的对象为不可达对象,需要进行内存回收。
5、重置标记状态。
note:对于最后「仍为白色对象为不可达对象」,实现层面可以将所有存货对象的所占用内存块进行连接起来,然后可以计算出空闲内存块,空白对象所在内存块就被空闲链表连接起来,作为空闲内存待分配使用。
、
误标和漏标
误标
在下面左图时刻,对象objA断开了对象D的引用,并且由于D已经被标记了黑色,所以标记结束回被任务存活,D被称为浮动垃圾。误标对象不会产生太大问题,可以放到下次垃圾回收进行清理。
var G = objA.filedD
objA.fieldD = null
漏标
程序运行到在下面左图时刻,对象objE断开对对象F的引用,对象objD开始对对象F进行引用,由于对象D已经为黑色,不会再本轮重新遍历,而对象F还是白色还没有被遍历,整个标记结束,F会当作垃圾被回收。
var F = objE.fieldF // 1
objD.filedF = objD.filedF // 2
objE.fieldF = null // 3
上面再内存中执行过程进一步可以解析为:
var F = objE.fieldF // ① 读取fileF的值 oop_field_load(oop* fieldF){return *fieldF}
objD.filedF = F // ② D连接上F对象,oop_field_store(oop* fieldF, oop new_valueE) {*fieldF = new_valueE}
objE.fieldF = null // ③ E对象断开对F的引用,oop_field_store(oop* filedF, oop null) {*fieldF = null}
如果能够在步骤1,加载对象时将F记录下来,或者2、3步骤修改元素引用时,记录下E对F的引用或者,记录下D对F的引用,都可以保证F不会被漏标,总结引起并发标记过程中引起漏标的两种情况:
情形1,黑色对象新增了对白色节点引用。
情形2, 灰色节点断开了对白色节点引用。
针对可能发生漏标两种情形,分别提出了强弱三色不变式进行预防:
强三色不变式,强制性不允许黑色对象引用白色对象。
弱三色不变式,黑色对应可以引用白色对象,但是白色对象同时还存在灰色对象对它的直接或者间接引用。强三色不变式破坏了情形1,弱三色不变式破坏了情形2,强弱三色不变式通过写屏障实现。写屏障又分为原始快照SATB(也叫删除屏障)和增量更新(插入屏障)。
方法1:写屏障+SATB(Snapshot At The Begging,SATB)
void oop_filed_store(oop *field, oop new_value) {
pre_write_barrier(field);
*field = new_value;
}
void pre_write_barrier(oop* field) {
if($gc_phase == gc_concurrent_mark && !isMarked(field)) {
oop old_value = *filed;
remark_set(old_value);
}
}
删除屏障在堆栈中都会开启。在语句③之前之后加入写屏障,记录删除之前之前旧对象,再次标记的时候,STW,对加入集合中的元素进行遍历。缺点,内存回收率比较低,因为重新标记阶段没有从根对所有对象进行深度遍历,所以可能会产生浮动垃圾,一些不再使用的对象可能需要下次GC才能回收,优点是重新标记阶段STW比较短。
方法2:写屏障+增量更新(incremental update)
oid oop_field_store(oop *field, oop new_value) {
*filed = new_value
post_write_barrier(field, new_value);
}
post_write_barrier(oop *field, oop new_value(NULL)) {
if($gc_phase == gc_concurrent_mark && isMarked(new_value)) {
renark_set(new_value);
}
}
增量更新只应用在堆内存中,栈操作因为新能要求比较高,而插入屏障是一个比较耗时操作,并且栈中对象比较少,所以只会在重新标记STW时,才会对堆栈中对象进行遍历标记。在语句② 之前加入插入屏障,记录下新建立引用的对象,重新标记时,扫描遍历堆栈中所有对象和堆剩余灰色对象中进行遍历,相比原始快照,增量更新的STW时间更长。
方法3:读屏障
oop_filed_load(oop *filed) {
pre_load_barrier(*fieldE);
return *fieldE;
}
pre_load_barrier(oop* field) {
// 将并发标记阶段,还没有被标记的对象记录下来
if ($gc_phase == gc_concurrent_mark && isMarked!(field)) {
remark_set(*filed)
}
}
在语句①之前使用写屏障,当在并发标记阶段,将未标记对象F加载到内存时,就记录下F(标记未灰色),等并发标记结束,进行STW时,对remark_set中剩余节点进行遍历标记,这种方式比较保守。
Conclusion
并发标记算法实现的垃圾回收器,采用了不同漏标预防策略:
原始快照: G1
增量更新: CMS,shenadoah
读屏障:ZGC
原始快照,虽然会产生浮动垃圾,但相对增量更新效率更高,原始快照在重新标记阶段,不需要对栈中所有对象进行深度遍历更新,只需要对集合中剩余对灰色象进行扫描标记。
混合标记