Java 虚拟机原理 (五) G1垃圾收集器深入

821 阅读6分钟

深入了解G1垃圾收集器背后的核心原理。

三色标记

CMS收集器使用了Incremental Update算法,而G1收集器使用的是SATB算法。这两者背后的思想都使用了三色标记算法,标记算法如下:

  • 黑色(black):自己已经被标记,且字段全部标记完毕的对象;
  • 灰色(gray): 自己已经被标记,但尚有字段未被标记的对象(collector正在访问的对象);
  • 白色(white):尚未标记对象。

注意: 标记结束后,被标记的对象是存活对象,没有被标记的对象会被回收。

在并行GC阶段,应用线程和GC形成并行,所以有可能同时发生以下两种情况:

  • 1.mutator把一个白对象的引用存到黑对象的字段里;
  • 2.某个白对象失去所有能从灰对象到达它的引用路径(直接或间接)。

黑对象持有了指向白对象的引用。根据定义,collector已经不会再去遍历黑对象的字段,所以发现不了这里还有一个活引用指向这个白对象。如果还有某个灰对象持有直接或间接引用能到达这个白对象,那就没关系;如果从灰对象出发的所有引用到这个白对象的路径都不幸被切断了,那这个白对象就要被漏扫描了。

所以如果同时发生以上两种情况,会导致对象漏标而被回收掉。

Incremental Update是CMS收集器针对上述问题而采用的算法。该算法的做法是:只要在write barrier里发现要有一个白对象的引用被赋值到一个黑对象的字段里,那就把这个白对象变成灰色的(例如说标记并压到marking stack上,或者是记录在类似mod-union table里)。这样就强力杜绝了上述第一种情况的发生。

而SATB的做法是把标记开始时的逻辑快照里所有的活对象都看作时活的。具体做法是在write barrier里把所有旧的引用所指向的对象都变成非白的(已经黑灰就不用管,还是白的就变成灰的)。

SATB

SATB(snapshot at the beginning)是G1并发理论的基础,用于维护确保回收过程的正确性。从名字上理解,就是GC在开始的时候先对活着的对象保存一个快照;

接下来,在GC过程中新分配的对象都是活的。

很容易知道哪些对象是一次GC开始之后新分配的:每个region记录着两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。并发标记开始时候Top指针和NextTAMS指针是重合的(如下图所示),并发阶段分配的对象对位于[Next TAMS,Top]区间。这个区间内的对象默认都是存活的,这也叫做隐式标记。

g1gc-tams.png

但如果在Next TAMS之前有一个白色对象A被一个灰色对象B作为字段而引用,在并发标记扫描到这个字段之前,被赋值为null,那么B-->A的引用关系被切断,可能会导致白色对象A被漏标。

G1为了解决这个问题,在引用关系修改之前,插入一层pre-write barrier。pre-write barrier会把每次引用关系变化时旧的引用值记录下来。这些引用值会被放置到satb mark queue中,在下一次的并发标记阶段,会依次处理satb mark queue中的对象,确保这部分对象在本轮GC是存活的。

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);
  }
}

但这很可能有对象在snapshot中是活的,但随着并发GC的进行它可能本来已经死了,但SATB还是会让它活过这次GC。这就导致所谓的浮动垃圾。

RSet

该章节摘录自Java Hotspot G1 GC的一些关键技术

全称是Remembered Set,是辅助GC过程的一种结构,典型的空间换时间工具,和Card Table有些类似。还有一种数据结构也是辅助GC的:Collection Set(CSet),它记录了GC要收集的Region集合,集合里的Region可以是任意年代的。在GC的时候,对于old->young和old->old的跨代对象引用,只要扫描对应的CSet中的RSet即可。 逻辑上说每个Region都有一个RSet,RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。而Card Table则是一种points-out(我引用了谁的对象)的结构,每个Card 覆盖一定范围的Heap(一般为512Bytes)。G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。

下图表示了RSet、Card和Region的关系(出处):

Remembered Sets.jpg

上图中有三个Region,每个Region被分成了多个Card,在不同Region中的Card会相互引用,Region1中的Card中的对象引用了Region2中的Card中的对象,蓝色实线表示的就是points-out的关系,而在Region2的RSet中,记录了Region1的Card,即红色虚线表示的关系,这就是points-into。 而维系RSet中的引用关系靠post-write barrier和Concurrent refinement threads来维护,操作伪代码如下(出处):

void oop_field_store(oop* field, oop new_value) {
  pre_write_barrier(field);             // pre-write barrier: for maintaining SATB invariant
  *field = new_value;                   // the actual store
  post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference
}

post-write barrier记录了跨Region的引用更新,更新日志缓冲区则记录了那些包含更新引用的Cards。一旦缓冲区满了,Post-write barrier就停止服务了,会由Concurrent refinement threads处理这些缓冲区日志。 RSet究竟是怎么辅助GC的呢?在做YGC的时候,只需要选定young generation region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation。 而mixed gc的时候,old generation中记录了old->old的RSet,young->old的引用由扫描全部young generation region得到,这样也不用扫描全部old generation region。所以RSet的引入大大减少了GC的工作量。

学习资料