十一、垃圾收集底层算法 - - 三色标记详解

一、并发标记的问题

CMS垃圾收集算法使用了三色标记,我们以CMS垃圾收集为例来说明。CMS垃圾收集的流程如下:

一共有5步:初始标记、并发标记、重新标记、并发清除(包括:并发清除、线程重置)。其中初始标记和重新标记都会STW。在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况有可能发生。

二、什么情况会多标呢 - - 浮动垃圾?

什么情况下会多标呢?来分析多标的情况。如下图:

在并发标记的过程中,从栈出发,找到所有的GC Root,于是我们找到了math对象,此时,math对象在堆中开辟了一块空间,堆中这块空间也都是有引用的,也就是说他们都不是垃圾。然后就在并发标记的过程中,应用程序也在继续执行,这时候可能math这个对象已经没用引用关系了,那么math就变成垃圾了,但堆中的空间却没有标记为垃圾,所以收集的时候就不会被收集走。这就是多标的情况。

多标产生的后果是什么呢?就是产生的浮动垃圾。

当有多标的时候,该如何解决呢?其实可以不用特殊解决,等待下一次触发垃圾收集,重新进行标记,这块空间就会被回收了。

浮动垃圾:在并发标记过程中,会出现由于方法运行结束,导致一部分局部变量(GC Root)被销毁,而这个GC Root引用的对象之前被垃圾收集器已经扫描过,并且被标记为非垃圾对象,那么本轮GC不会回收这部分内存。这部分应该回收但是没有回收的内存,被称之为“浮动垃圾”

浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一轮垃圾回收中才被清除。另外,针对并发标记(还有并发清除)开始产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能也会变为 垃圾,这也算是浮动垃圾的一部分

三、什么情况会少标漏标呢 - - 三色标记?

为了处理多标和漏标的情况,我们引入了“三色标记”,在通过可达性分析遍历对象标记GC Root的过程中所遇到的对象,分为三类。这三类对象分别被标记为不同的颜色,即:“黑色”、“灰色”、“白色”。他们分别代表什么含义呢?

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描上。黑色的对象代表已经扫描过,它是安全存活的对象,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

下面通过案例来分析对象的颜色。

public class ThreeColorRemark {

    public static void main(String[] args) {
        A a = new A();

        // 开始做并发标记
        D dd = a.b.d;
        a.b.d = null;
        a.d = dd;
    }
}

class A {
    B b = new B();
    D d = null;
}

class B {
    C c = new C();
    D d = new D();
}

class C {

}

class D {

}

这里面有四个对象,分别是A,B,C,D。在main方法中,首先new了一个A对象。此时的a对象是一个GC Root,在初始标记的时候会被标记为GC Root。假设,当进入并发阶段的时候,刚刚执行完了A  a = new A();这句话时,A应该是什么颜色的呢?

分析上面的代码,我们要定格时间。

假设:时间定格在执行了A a = new A();B b = new B();D d = null;C c = new C();但是还没有执行D d = new D();的阶段。

我们这里假设一个极端情况。这句话,A对象中的两个成员变量b和d,首先执行b,指向了堆中new B()地址。而d没有指向任何对象的引用,所以不需要实例化。这样a对象中两个成员变量全部都遍历完了,所以a对象会被标记为黑色。黑色的含义是垃圾收集器扫描了这个对象和这个对象里的所有的引用对象,很显然此时a对象不是垃圾。不会被回收。

当执行b对象指向new B()对象的时候,B对象中有两个引用对象,分别是c和d。假设现在程序刚好执行到C c = new C();这句代码,那么此时c对象指向了堆中new C()的引用。就在这一刻,也就是执行了C c = new C();而没有执行D d = new D();这句代码的时候,B是灰色的。灰色表示已经被垃圾收集器扫描过了,但是里面的引用没有被全部扫描完,这时这个对象就应该成为下一个扫描的目标,也就是不能被回收的,而C是黑色的,因为C里面没有对象,被全部扫描完了。

同样是刚刚那个时刻(执行了C c = new C();而没有执行D d = new D();这句代码的时候),此时因为还没有执行D d = new D();这句代码,所以D是白色的,表示还没有被扫描到。最开始所有对象都是白色的,也就是 A,B,C,D都是白色的,当垃圾收集器扫描完所有的对象以后,有些对象还是白色的,就是说明垃圾收集器扫描不到它,那么这就是垃圾,会被回收。

来看看此次时间定格时各个对象的状态。

 

需要注意的是:上面是定格在gc过程中的某一个时刻。整个GC并没有结束,所以,b是灰色,d是白色只是在那定格的一瞬间。

总结:黑色表示GC已经分析完了,灰色对象表示还没有分析完,白色对象表示没有对其进行分析过。当所有的GC都完成了,还是有对象是白色的,那么这些对象就是不能被触达的对象,就是我们要回收的目标对象。

就在这个时候,又执行了另外一句代码a.d=a.b.d; a.b.d=null; 也就是这时候a对象增加了对d的引用。而对象b对d的引用断开了。如下图:

这时候会发生什么呢?垃圾收集器在扫描的时候,黑色对象a是不会被再次扫描的,再次扫描的目标对象是灰色对象b。这时候,b已经不再引用d了,所以b此时所有对象都已经扫描过,也会变成黑色。而d呢?这时候d其实还被a引用着,但是,垃圾收集器不会去扫描黑色对象了,所以,也不会知道d还被a引用着。这时候,d就还是白色对象,一直是白色对象,不会被垃圾收集器扫描到。这样,d会被当做垃圾清理掉。d其实不是垃圾对象啊,被清理掉还能行?这就是误删除。jvm早期版本会有这样的情况发生,现在基本不会出现了。

其实这种漏标的问题也可以通过代码解决:

// 开始做并发标记
D dd = a.b.d;
a.b.d = null;
a.d = dd;

首先,我们先把a.b.d拿出来赋值给一个新的对象,然后再去掉a.b对d的引用关系,并设置a.d=d.这样d就不会被当做一个垃圾对象回收掉了,因为有一个根对象引用了对象d。

上面是从代码层面解决的,有没有办法从jvm底层解决这种漏标的问题呢?

四、从jvm底层解决漏标问题

漏标会导致被引用的对象被当成垃圾给清理掉,这会产生严重的bug,对于这种漏标的问题,jvm底层利用了CPU的读写屏障来实现的解决方案主要有两种:

  • 一种是增量更新(Incremental Update) ;
  • 另一种是原始快照(Snapshot At The Beginning,SATB) 。

4.1 漏标必须要同时满足以下两个条件:

        条件1:赋值器插入了一条或者多条从黑色对象到白色对象的新引用;

        条件2:赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

这两个条件必须全部满足,才会出现对象消失的问题。那么我们只是需要对上面条件进行破坏,破坏其中的任意一个,都可以防止对象消失问题的产生。这样就产生了两种解决方案。

4.2 解决漏标的两种解决方案

4.2.1增量更新:Incremental Update(a --> d)

从名字来看,增量更新, 是对新增引用进行处理。下面来看看定义:

增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用时,就将这个新加入的引用记录下来,待并发标记完成后,重新对这种新增的引用记录进行扫描;

可以简单理解为,当一个黑色对象增加了对白色对象的引用,那么这个黑色对象就被变灰

​ 这样有一个缺点,就是会重新扫描这个黑色对象的所有引用,比较浪费时间

也就是说,在黑色对象新增了一个指向白色对象的引用时,会将这个引用记录下来,会有一个集合,专门用来放黑色对象新增的对白色对象的引用,在并发标记的时候并不处理,等并发标记结束以后,进入重新标记阶段,重新标记过程会Stop The World,在处理这个集合,将集合中引用关系中的黑色对象为根,进行重新扫描一次,这次扫描,白色对象就会变成黑色对象或者灰色对象,不会被垃圾回收掉了。

4.2 原始快照:Snapshot At The Beginning,SATB(b --> d)

原始快照,不是对新增对象的处理,而是对原始对象的处理,下面来看看定义:

原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,也是将这个记录下来,并发标记完成后,对该记录进行重新扫描,并将重新扫描到的白色对象直接标记为黑色,不用一层层遍历。

可以简单理解为,当一个灰色对象取消了对白色对象的引用,那么这个白色对象被变灰

这样做的缺点就是,这个白色对象有可能并没有黑色对象去引用它,但是它还是被变灰了,就会导致它和它的引用,本来应该被垃圾回收掉,但是此次GC存活了下来,就是所谓的浮动垃圾.其实这样是比较可以忍受的,只是让它多存活了一次GC而已,浪费一点点空间,但是会比增量更新更省时间。

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

来看这张图说明:

当扫描到对象b对d的引用删除的之前, 会将这个要被删掉的引用保存一个快照,然后放到集合里。上图b到d的引用时如何被清掉的呢?做了一个赋值操作:

a.b.d = null;

也就是,当执行到这句赋值操作的时候,会先暂停赋值,执行另一个操作--写屏障操作,将这个即将要删除的引用提取出来,保存到一个集合里,然后在执行赋值操作。然后再下一次重新标记的时候,将集合中这些引用关系中的灰色对象作为根,进行重新扫描,这样就可以扫描到白色对象了,将这些白色对象全部标记为黑色对象。标记为黑色对象的目的就是在本轮垃圾回收的时候存活下来,等待下一轮gc的时候重新扫描,这个对象有可能是浮动垃圾。

 4.3 写屏障

无论是增量更新还是原始快照,都是通过写屏障来实现的。

增量更新和原始快照都是对引用的操作,一个是新增引用,一个是删除引用,不管是新增还是删除,最终都要把他们收集到集合里去。那么如何收集呢?其实就是在赋值操作之前或者赋值操作之后,把引用丢到集合中去。在赋值操作的前面或者后面做一些事情,这个过程我们把它叫做代码的操作屏障。

下面来看看赋值屏障的伪代码,以给某个对象的成员变量赋值为例,底层代码大概是这样的::

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

所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):

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

1)、写屏障实现原始快照

原始快照是记录对引用的删除。比如在执行a.b.d = null 的时候,利用写屏障,将原来的B成员变量的引用对象D记录下来

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

2)、写屏障实现增量更新

当对象A的成员变量的引用发生变化时,比如新增引用(a.b = d),我们就可以利用写屏障,将A新的成员变量引用对象D记录下来

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

这两块都是屏障代码,一个是在写前执行,一个是在写后执行。删除操作要在写前执行,赋值操作要在写后执行。

下面来看看hotspot源码是如何实现写屏障的,找到oop.inline.hpp文件

/**
 * c++底层调用的赋值方法 
 */
template <class T> inline void oop_store(volatile T* p, oop v) {
  update_barrier_set_pre((T*)p, v);   // cast away volatile
  // Used by release_obj_field_put, so use release_store_ptr.
  oopDesc::release_encode_store_heap_oop(p, v);
  update_barrier_set((void*)p, v);    // cast away type
}

这就是一个赋值操作。update_barrier_set_pre((T)p, v);是一个写前屏障,update_barrier_set((void)p, v);是一个写后屏障。也就是说在赋值之前和之后增加了一段操作代码。其实可以看出来这段代码和我们的伪代码差不多。名字虽不同,但是含义是一样的。

再看看SATB在hotspot源码中是如何实现写屏障的。

void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
  // Nulls should have been already filtered.
  assert(pre_val->is_oop(true), "Error");

  if (!JavaThread::satb_mark_queue_set().is_active()) return;
  Thread* thr = Thread::current();
  if (thr->is_Java_thread()) {
    JavaThread* jt = (JavaThread*)thr;
    jt->satb_mark_queue().enqueue(pre_val);
  } else {
    MutexLockerEx x(Shared_SATB_Q_lock, Mutex::_no_safepoint_check_flag);
    JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
  }
}

我们看到这句话satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val); 将旧值放到队列里。这时为什么会放到队列里面呢?为了提高效率。因为是写操作,在写操作之前和之后增加逻辑,是会影响原来代码的效率的,为了避免对源代码的影响,放入到队列中进行处理。

4.4 读屏障

oopoop_field_load(oop*field){
	 pre_load_barrier(field); // 读屏障‐读取前操作
   return *field; 
}

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

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

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色 集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可 以是广度/深度遍历等等。  

4.5 为什么G1使用SATB,而CMS使用增量更新?

增量更新:黑色对象新增一条指向白色对象的引用,那么就要进行深入扫描黑色对象及它的所有引用对象。

原始快照:灰色对象删除了一条指向白色对象的引用,但重新标记阶段会直接将对应的白色对象变为黑色对象,不会进行深度扫描,这样实际上就产生了浮动垃圾,好处是不需要像 CMS 那样 remark,再走一遍 root trace 这种相当耗时的流程。

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

我的理解:原始快照只是简单把要可能消失的对象标记为黑色对象,这样有可能会产生浮动垃圾(白色对象有可能并没有黑色对象去引用它,但是它还是被变灰了,就会导致它和它的引用,本来应该被垃圾回收掉,但是此次GC存活了下来,就是所谓的浮动垃圾),而增量更新会把新增的引用关系都重新扫描一遍,在重新标记阶段不会产生浮动垃圾;但原始快照速度快,增量更新速度慢。

五、各种垃圾收集器对漏标的处理方案

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

  • CMS:采用的是写屏障 + 增量更新
  • G1: 采用的是写屏障 + 原始快照(SATB)
  • ZGC:采用的是读屏障

工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并 发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

六、记忆集和卡表

1.记忆集(Remember Set)

在新生代触发Minor GC进行GC Root可达性扫描的时候,可能会碰到跨代引用。比如:新生代的一个对象被老年代引用了,这个时候,在垃圾回收的时候,我们不应该把这块空间回收掉。那怎么办呢?要去扫描一遍老年代么?这显然是不行,效率太低了,为了解这个问题,GC在扫描的时候,会把老年代引用的对象放在一个叫做记忆集的集合中。

垃圾收集过程中,收集器只需要通过记忆集来判读某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨带引用指针的全部细节。

2.卡表(Card Table)

hotspot使用的是卡表(cardtable)来实现记忆集。卡表其实就是记忆集的一个实现,卡表和记忆集的关系就像HashMap和Map的关系。记忆集相当于一个概念,而jdk中是通过卡表来实现的。到底是如何实现的呢?

卡表是使用字节数组实现的,卡表的每一个元素对应着其标志的内存区域里一块待定大小的内存块。这些待定的内存块就是“卡页”,堆空间分为新生代和老年代,卡表会把老年代划分为一块一块小的格子,这些格子就是“卡页”。卡页划分是按照512字节大小进行划分的。如果有有一个卡页引用了新生代的对象,那么将这个卡页标记为“dirty”。卡表是一个数组,里面记录了所有卡页的状态,用010101来标记卡页是否引用了新生代对象,如果是就标记为1,不是就保持原来的0,数组里除了存放卡页的状态,还有卡页的地址。在垃圾收集器进行扫描的时候,除了扫描GC Root之外,还会扫描卡表里那么状态为1的卡页里的对象。卡页是在老年代,维护卡页的卡表是在年轻代。

卡表的维护

卡表变脏(dirty)上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值,如何更新卡表对应的标识1,Hotspot使用写屏障维护卡表状态。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值