JVM基础系列:三色标记算法

  概述
  三色标记算法,应用于JVM并发垃圾回收,为了减少STW,JVM将比较耗时的标记阶段,变成了并发标记,在并发标记的同时,用户线程也可以继续运行。
  并发标记一共会有两个问题:一个就是本来标记不是垃圾,此时用户线程将对象属性赋予null,取消引用,但是根据标记认为它不是垃圾,就是浮动垃圾问题;第二个是本来垃圾回收线程把它标记为垃圾,但是用户线程又有新的对象属性指向它,这个时候就会导致比较严重的漏标记的问题。那出现这些问题,是如何处理的?
  三色标记算法
  我们知道CMS垃圾回收器是通过可达性分析找到存活对象,然后给存活对象打个标记,最终在清理的时候,如果一个对象没有任何标记,就表示这个对象不可达,需要被清理。而标记算法就是使用三色标记算法。一般使用白色、黑色和灰色来表示。
  • 白色:说明这个对象没有被标记过,在初始阶段,所有对象都是白色,整个过程枚举完最后仍是白色的对象会被当做垃圾对象被清理。
  • 灰色:这个对象正在被标记,但是这个对象直接引用的对象中,至少还有一个没有被标记过。
  • 黑色:对象和他直接引用的所有对象都被标记过,对直接引用的对象下一级引用不做要求,比如A只引用了B,B引用了C、D,那么只要A和B都被标记过,A就是黑色,即使B所引用的C或D还没有被访问到,此时B就是灰色。

  根据定义jvm中三色标记算法大致流程是:

  1. 首先从GC Roots开始标记,他们所有直接引用对象变成灰色,自己本身变为黑色(GC Roots对象本身不是垃圾),这里我们用队列去存储灰色对象,把这些灰色对象放到队列中。
  2. 然后从队列中取出灰色对象继续进行分析:将这个对象所有直接引用变成灰色,放入队列中,然后这个对象变为黑色;如果没有直接引用就直接变为黑色。
  3. 继续从队列中取出一个灰色对象,重复第二步,一直到灰色队列为空。
  4. 分析完后,仍然是白色的对象,就是不可大对象,可以作为垃圾被回收。
  5. 最后重置标记状态。

  下面提供一个例子,加深下印象:

  刚开始所有对象都是白色:  三色标记初始阶段(标记的初始解读,不是CMS的初始标记),所有GC Roots直接引用(A、B、E)变成灰色,放入队列中,GC Roots变成了黑色:

 

   然后从灰色队列中取出一个灰色对象进行标记,比如A、将他直接引用C、D变成灰色,放入队列,A因为已扫描完它的直接引用对象,所以变成黑色:

  

 

   继续取出灰色对对象,比如取出B对象,将它的直接引用F标记为灰色,放入队列,B对象此时标记为黑色:

 

 

  继续从队列中取出灰色对象E,但是E没有直接引用其他对象,将E标记为黑色:

  根据上述步骤,取出C 、D 、F 对象进行分析,他们都没有直接引用其他对象,那么就变为黑色:

  最终分析标记结束后,还有一个G对象是白色,说明此G 对象是一个垃圾对象,不可访问,可以被清理掉。

  三色标记的缺陷:

  如果整个标记过程是STW的,那么没有任何问题,但是并发标记过程中,用户线程也在运行,那么对象引用关系很可能发生变化,进而导致前面提到过的两个问题的出现。

  • 浮动垃圾(标记为不是垃圾对象,变成了垃圾)

   比如垃圾回收线程标记回到上面的这个状态:

 

  此时E对象已经被标记为黑色,表示不是垃圾,不会被清除,因为处在并发标记阶段,同一时刻某个用户线将GC Root2和E对象之间的关系断开了(objRoot2.e = null;),如图:

 

   很显然,E对象变为了垃圾对象,但是由于之前被标记为黑色,就不会被当作垃圾回收,这种问题称之为浮动垃圾。

  • 漏标、错杀问题(标记为垃圾对象,变成了非垃圾)

  上面提及到浮动垃圾的问题,影响不大,即使本次不清理,下次GC也会被清理,而且在并发清理阶段也会产生所谓的浮动垃圾,因为用户线程也在不断地断开引用,影响不大。但是如果一个非垃圾对象,变成了垃圾,后果就比较严重,再回到上面地状态:

 

   这里标记线程执行到分析B对象,但是刚好发生线程切换,操作系统调度用户线程来运行,而用户线程先执行A.f = F;那么引用关系变成了:

 

   紧接着执行:B.f = null ;那么关系就变成了:

 

   用户线程做完上述动作,GC线程重新开始运行,按照之前的流程继续走,从对类中取出B对象,发现对象没有直接引用,那么B对象变成了黑色:

 

   接着继续取出 E、C、D 三个灰色对象,他们没有直接引用,那么变为黑色对象:

 

  到现在所有对象分析完毕,从图中也可以明显看出问题,就是还在被黑色对象引用的F被标记为白色,那么此时会判断它是垃圾,会被回收清理掉,那程序运行如果需要用到F对象,将会有问题。 那怎么处理这些问题?

  增量更新和原始快照(SATB)

   上面的两个问题,从结果来说,可以简单归纳为:

  • 一个本应该是垃圾的对象被标记为非垃圾
  • 一个本应该不是垃圾的对象被标记为垃圾

  对于第一个问题,前面也提到了,即使不去处理也无所谓,大不了等下一次GC的时候再清理。第二个问题就比较严重,分分钟发生空指针异常,可以看出来,出现第二个问题必须满足两个条件:

  • 并发标记过程中黑色对象(A)引用了白色对象(F)
  • 灰色对象(B)断开了同一个白色对象(F)引用

  这样导致了这个白色对象被错误的回收,只要打破上述中的其中一个条件,就可以有效的避免这种现象,对应的就有两种方案,分别是增量更新(Incremental Update)和原始快照(SATB, Snapshot At The Beginning)。

  读写屏障

  在讲解如何解决漏标,错杀问题前,先说说读写屏障和并发编程里的内存屏障是两码事,这里的屏障可以简单理解为在读写操作前后插入一段代码,用于记录一些信息,保存某些数据等,其概念类似于AOP。

  增量更新(CMS处理方式)

  增量更新是站在新增引用对象(A)的角度来解决问题(即破坏条件1),所谓增量更新就是在赋值操作前添加一个写屏障,在写屏障中记录新增的引用。上面的例如,并发标记阶段,用户线程执行:A.f = F,那么在写屏障中将新增的这个引用关系记录下来。其实就是,当黑色对象新增一个白色对象的引用时,通过写屏障把这个关系记录下来。然后在重新标记阶段,再以此引用关系的黑色对象为根,再扫描一次,以保证不会漏标。

  要实现也很简单,在重新标记阶段直接把A对象变为灰色,放入灰色队列中,再来一次标记分析过程,但是如果此过程用户线程还在继续运行,那么也会有漏标的情况,所以重新标记需要STW,但是这个时间耗时不会太长,因为在并发标记的时候,已经把大部分对象都正确标记了。如果时间还是太长,可以设置在重新标记前,执行一次Minor GC,这个在CMS垃圾回收器中是可以设置参数-XX:+CMSScavengeBeforeRemark 。

  原始快照(G1 解决方式 SATB)

  原始快照是站在减少对象引用(B)的角度来解决问题(即破坏条件2),所谓原始快照,简单来说,就是赋值操作(B.f = null),那么在写屏障中,首先会把B.f 记录下来,在进行置空操作,记录下来这个对象就可以称之为原始快照。

  记录下来之后呢?很简单,之后直接将他变为黑色,意思就是默认认为它不是垃圾,不需要清理,当然这里的F 有可能是垃圾,也有可能不是,如果是垃圾,就当作浮动垃圾,在下次回收的时候处理掉。

  方案抉择

  为什么G1采用SATB而不是使用增量更新呢?我自己的理解是,因为采用增量更新把黑色对象重新标记为灰色后,之前扫描过的还要再扫描一次,效率太低。

  G1有RSet与SATB相结合,Card Table里记录了 RSet,RSet记录了其他指向自己的对象的引用,这样不需要去扫描其他区域,只要扫描RSet就可以知道是否被引用,这样效率比较高。当然这个是博主的猜测,如果读者朋友有更好的想法,欢迎提出。

  G1、CMS有很多地方可以展开来说说,后续会各自出一篇文章来描述。

posted @ 2022-09-09 11:32  梅晓煜  阅读(1076)  评论(0编辑  收藏  举报