JVM(四) G1 收集器工作原理介绍

此篇文章半原创是对参考资料中的知识点进行总结,欢迎评论指点,谢谢!

       部分知识点总结来自R大的帖子,下文有参考资料的链接

概述

G1 收集是相比于其他收集器(可见 上一篇文章),可以独立运行,同时做到了并发和并行。下面看一下它是如何实现的。

之前介绍的几组垃圾收集器组合,都有几个共同点:

  • 年轻代、老年代是独立且连续的内存块;
  • 年轻代收集使用单eden、双survivor进行复制算法;
  • 老年代收集必须扫描整个老年代区域;
  • 都是以尽可能少而块地执行GC为设计原则。

G1垃圾收集器也是以关注延迟为目标、服务器端应用的垃圾收集器,被HotSpot团队寄予取代CMS的使命,也是一个非常具有调优潜力的垃圾收集器。虽然G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。事实上,G1收集与以上三组收集器有很大不同:

  • G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
  • G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
  • G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
  • G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。

使用场景

G1适用于以下几种应用:

  • 可以像CMS收集器一样,允许垃圾收集线程和应用线程并行执行,即需要额外的CPU资源;
  • 压缩空闲空间不会延长GC的暂停时间;
  • 需要更易预测的GC暂停时间;
  • 不需要实现很高的吞吐量

三种收集模式

首先要先认识清楚G1的几种收集模式:

1、young GC(或者叫minor GC):只收集young gen里的所有region,也就是eden和survivor。控制young GC开销的手段是动态改变young region的个数;

2、mixed GC:收集young gen里的所有region,外加若干选定的old gen region。控制mixed GC开销的手段是选多少个、哪几个old gen region。

3、其实没有3了。G1 GC的控制范围内没有full GC。如果mixed GC无法跟上mutator分配的速度,导致没有足够的空region来完成mixed GC,那么就会使用serial old GC( mark-compact)来对整堆收集一次。

原理解析

问题

  1. 浮动垃圾是如何产生的?
  2. young GC , Mixed GC ,Full GC 是如何产生的?
  3. Rset STAB 是什么?有什么作用 ?
  4. Pre/post-write barrier跟SATB有啥关系呢?
  5. refinement threads线程的作用是什么?

提出几个问题,在阅读中找到答案。

多个Region

G1 先是将Heap分成平等的多个 Region . 如下图所示 :

JVM(四) G1 收集器工作原理介绍

然后每个 Region 再细分为多个大小为512字节的 card 并且每个card 都被纪录到一个叫 Remembered Set(RS) 的数据结构。我们从上一篇文章知道,收集器不用全局去扫描节点的原因就在于 RS 发挥了作用。下图为Refion 分为多个cards,并对应于 RS上。

JVM(四) G1 收集器工作原理介绍

Remembered set

下面引用了一下 R大 对 Rst的描述。

Remembered Set是一种抽象概念,而card table可以是remembered set的一种实现方式。
       Remembered Set是在实现部分垃圾收集(partial GC)时用于记录从非收集部分指向收集部分的指针的集合的抽象数据结构。
       分代式GC是一种部分垃圾收集的实现方式。当分两代时,通常把这两代叫做young gen和old gen;通常能单独收集的只是young gen。此时remembered set记录的就是从old gen指向young gen的跨代指针。

G1 GC的heap与HotSpot VM的其它GC一样有一个覆盖整个heap的card table。
逻辑上说,G1 GC的remembered set(下面简称RSet)是每个region有一份。这个RSet记录的是从别的region指向该region的card。所以这是一种“points-into”的remembered set。

用card table实现的remembered set通常是points-out的,也就是说card table要记录的是从它覆盖的范围出发指向别的范围的指针。以分代式GC的card table为例,要记录old -> young的跨代指针,被标记的card是old gen范围内的。
G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。

这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。
举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。

JVM(四) G1 收集器工作原理介绍

那Rst 是如何辅助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的工作量。Rst貌似和Oop有点相似,后者是纪录根节点的。两者的理解可以看这篇文章,JVM 之 OopMap 和 RememberedSet

关于Rst 还有其他的一些知识点,看看这个讨论帖 : [资料] 名词链接帖 [占位ing]

SATB

snapshot-at-the-beginning,是维持并发GC的正确性的一个手段。并发标记的主要问题是collector在标记对象的过程中mutator可能正在改变对象引用关系图,从而造成漏标和错标。错标不会影响程序的正确性,只是造成所谓的浮动垃圾。但漏标则会导致可达对象被当做垃圾收集掉,从而影响程序的正确性。

GC HandBook把对象分成三种颜色:

  1. 黑色:自身以及可达对象都已经被标记
  2. 灰色:自身被标记,可达对象还未标记
  3. 白色:还未被标记

假如存在以下情况

在GC扫描C之前的颜色如下:

JVM(四) G1 收集器工作原理介绍

在并发标记阶段,应用线程改变了这种引用关系

A.c=C
B.c=null

得到如下结果。

JVM(四) G1 收集器工作原理介绍

在重新标记阶段扫描结果如下

JVM(四) G1 收集器工作原理介绍

这种情况下C会被当做垃圾进行回收。Snapshot的存活对象原来是A、B、C,现在变成A、B了,Snapshot的完整遭到破坏了,显然这个做法是不合理。

所以,漏标的情况只会发生在白色对象中,且满足以下任意一个条件:

  1. 并发标记时,应用线程给一个黑色对象的引用类型字段赋值了该白色对象
  2. 并发标记时,应用线程删除所有灰色对象到该白色对象的引用

上面也说了SATB就是来解决这样的问题的,那么如何形成某个时间点的快照呢?该如何实现呢?可以看下面这张图。

JVM(四) G1 收集器工作原理介绍

每个region记录着两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。

G1的concurrent marking用了两个bitmap: 一个prevBitmap记录第n-1轮concurrent marking所得的对象存活状态。由于第n-1轮concurrent marking已经完成,这个bitmap的信息可以直接使用。 一个nextBitmap记录第n轮concurrent marking的结果。这个bitmap是当前将要或正在进行的concurrent marking的结果,尚未完成,所以还不能使用。

(1): [bottom, prevTAMS): 这部分里的对象存活信息可以通过prevBitmap来得知
(2): [prevTAMS, nextTAMS): 这部分里的对象在第n-1轮concurrent marking是隐式存活的
(3): [nextTAMS, top): 这部分里的对象在第n轮concurrent marking是隐式存活的

有了这样的设计就可以解决上面漏标和错标的问题了。

对于第一种情况,利用post-write barrier,记录所有新增的引用关系,然后根据这些引用关系为根重新扫描一遍

对于第二种情况,利用pre-write barrier,将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根重新扫描一遍

pre-write barrier 和 post-write barrier 的介绍。

写前栅栏 Pre-Write Barrrier

即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。

写后栅栏 Post-Write Barrrier

当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。

JVM(四) G1 收集器工作原理介绍

G1采用的是pre-write barrier解决这个问题。简单说就是在并发标记阶段,当引用关系发生变化的时候,通过pre-write barrier函数会把这种这种变化记录并保存在一个队列里,在JVM源码中这个队列叫satb_mark_queue。在remark阶段会扫描这个队列,通过这种方式,旧的引用所指向的对象就会被标记上,其子孙也会被递归标记上,这样就不会漏标记任何对象,snapshot的完整性也就得到了保证。

SATB的方式记录活对象,也就是那一时刻对象snapshot, 但是在之后这里面的对象可能会变成垃圾, 叫做浮动垃圾(floating garbage),这种对象只能等到下一次收集回收掉。在GC过程中新分配的对象都当做是活的,其他不可达的对象就是死的。

栅栏的源码分析

      下面分析来自参考资料

在引用关系被修改之前,插入一层 pre-write barrier

JVM(四) G1 收集器工作原理介绍

pre-write barrier最终执行逻辑:

JVM(四) G1 收集器工作原理介绍

通过G1SATBCardTableModRefBS::enqueue(oop pre_val)把原引用保存到satb mark queue中,和RSet的实现类似,每个应用线程都自带一个satb mark queue.在下一次的并发标记(remark)阶段,会依次处理satb mark queue中的对象,确保这部分对象在本轮GC是存活的。

可以看到上面的图片代码最后只是入队列,那么谁来处理队列里的东西呢?操作队列里的东西,描述为 logging write barrier

logging write barrier
      为了尽量减少write barrier对mutator性能的影响,G1将一部分原本要在barrier里做的事情挪到别的线程上并发执行。实现这种分离的方式就是通过logging形式的write barrier:mutator只在barrier里把要做的事情的信息记(log)到一个队列里,然后另外的线程从队列里取出信息批量完成剩余的动作。
      以SATB write barrier为例,每个Java线程有一个独立的、定长的SATBMarkQueue,mutator在barrier里只把old_value压入该队列中。一个队列满了之后,它就会被加到全局的SATB队列集合SATBMarkQueueSet里等待处理,然后给对应的Java线程换一个新的、干净的队列继续执行下去。
      并发标记(concurrent marker)会定期检查全局SATB队列集合的大小。当全局集合中队列数量超过一定阈值后,concurrent marker就会处理集合里的所有队列:把队列里记录的每个oop都标记上,并将其引用字段压到标记栈(marking stack)上等后面做进一步标记。

而post-write barrier见R大的分析 : https://hllvm-group.iteye.com/group/topic/44381

  1 void post_write_barrier(oop* field, oop new_value) {
2 uintptr_t field_uint = (uintptr_t) field;
3 uintptr_t new_value_uint = (uintptr_t) new_value;
4 uintptr_t comb = (field_uint ^ new_value_uint) >> HeapRegion::LogOfHRGrainBytes;
5
6 if (comb == 0) return; // field and new_value are in the same region
7 if (new_value == null) return; // filter out null stores
8
9 // Otherwise, log it
10 volatile jbyte* card_ptr = card_for(field); // get address of the card for this field
11
12 // in generational G1 mode, skip dirtying cards for young gen regions,
13 // -- young gen regions are always collected
14 // if (*card_ptr == g1_young_gen) return;
15
16 if (*card_ptr != dirty_card) {
17 // dirty the card to reduce the work for multiple stores to the same card
18 *card_ptr = dirty_card;
19 // log the card for concurrent remembered set refinement
20 JavaThread::current()->dirty_card_queue->enqueue(card_ptr);
21 }
22 }

这是logging barrier在G1 write barrier上的又一次应用。
        跟SATB marking queue类似,每个Java线程有一个dirty card queue,也就是论文里说的每个线程的remembered set log;然后有一个全局的DirtyCardQueueSet,也就是论文里说的全局的filled RS buffers。
        实际更新RSet的动作就交由多个ConcurrentG1RefineThread并发完成。每当全局队列集合超过一定阈值后,ConcurrentG1RefineThread就会取出若干个队列,遍历每个队列记录的card并将card加到对应的region的RSet里去。

工作原理

这一部分来自 R大 的总结。出处见参考资料。

从最高层看,G1的collector一侧其实就是两个大部分:
1. 全局并发标记(global concurrent marking)
2. 拷贝存活对象(evacuation)
      而这两部分可以相对独立的执行。

Global concurrent marking基于SATB形式的并发标记。它具体分为下面几个阶段:
1、初始标记(initial marking):暂停阶段。扫描根集合,标记所有从根集合可直接到达的对象并将它们的字段压入扫描栈(marking stack)中等到后续扫描。G1使用外部的bitmap来记录mark信息,而不使用对象头的mark word里的mark bit。在分代式G1模式中,初始标记阶段借用young GC的暂停,因而没有额外的、单独的暂停阶段。

2、并发标记(concurrent marking):并发阶段。不断从扫描栈取出引用递归扫描整个堆里的对象图。每扫描到一个对象就会对其标记,并将其字段压入扫描栈。重复扫描过程直到扫描栈清空。过程中还会扫描SATB write barrier所记录下的引用。

3、最终标记(final marking,在实现中也叫remarking):暂停阶段。在完成并发标记后,每个Java线程还会有一些剩下的SATB write barrier记录的引用尚未处理。这个阶段就负责把剩下的引用处理完。同时这个阶段也进行弱引用处理(reference processing)。
注意这个暂停与CMS的remark有一个本质上的区别,那就是这个暂停只需要扫描SATB buffer,而CMS的remark需要重新扫描mod-union table里的dirty card外加整个根集合,而此时整个young gen(不管对象死活)都会被当作根集合的一部分,因而CMS remark有可能会非常慢。

4、清理(cleanup):暂停阶段。清点和重置标记状态。这个阶段有点像mark-sweep中的sweep阶段,不过不是在堆上sweep实际对象,而是在marking bitmap里统计每个region被标记为活的对象有多少。这个阶段如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。

Evacuation阶段是全暂停的。它负责把一部分region里的活对象拷贝到空region里去,然后回收原本的region的空间。Evacuation阶段可以*选择任意多个region来独立收集构成收集集合(collection set,简称CSet),靠per-region remembered set(简称RSet)实现。这是regional garbage collector的特征。
        在选定CSet后,evacuation其实就跟ParallelScavenge的young GC的算法类似,采用并行copying(或者叫scavenging)算法把CSet里每个region里的活对象拷贝到新的region里,整个过程完全暂停。从这个意义上说,G1的evacuation跟传统的mark-compact算法的compaction完全不同:前者会自己从根集合遍历对象图来判定对象的生死,不需要依赖global concurrent marking的结果,有就用,没有拉倒;而后者则依赖于之前的mark阶段对对象生死的判定。
       论文里提到的纯G1模式下,CSet的选定完全靠统计模型找处收益最高、开销不超过用户指定的上限的若干region。由于每个region都有RSet覆盖,要单独evacuate任意一个或多个region都没问题。

分代式G1模式下有两种选定CSet的子模式,分别对应young GC与mixed GC:
      Young GC:选定所有young gen里的region。通过控制young gen的region个数来控制young GC的开销。
      Mixed GC:选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region。在用户指定的开销目标范围内尽可能选择收益高的old gen region。(这个地方需要注意一下!!!)
      可以看到young gen region总是在CSet内。因此分代式G1不维护从young gen region出发的引用涉及的RSet更新。
分代式G1的正常工作流程就是在young GC与mixed GC之间视情况切换,背后定期做做全局并发标记。Initial marking默认搭在young GC上执行;当全局并发标记正在工作时,G1不会选择做mixed GC,反之如果有mixed GC正在进行中G1也不会启动initial marking。

在正常工作流程中没有full GC的概念,old gen的收集全靠mixed GC来完成。
如果mixed GC实在无法跟上程序分配内存的速度,导致old gen填满无法继续进行mixed GC,就会切换到G1之外的serial old GC来收集整个GC heap(注意,包括young、old、perm)。这才是真正的full GC。Full GC之所以叫full就是要收集整个堆,只选择old gen的部分region算不上full GC。进入这种状态的G1就跟-XX:+UseSerialGC的full GC一样(背后的核心代码是两者共用的)。
顺带一提,G1 GC的System.gc()默认还是full GC,也就是serial old GC。只有加上 -XX:+ExplicitGCInvokesConcurrent 时G1才会用自身的并发GC来执行System.gc()——此时System.gc()的作用是强行启动一次global concurrent marking;一般情况下暂停中只会做initial marking然后就返回了,接下来的concurrent marking还是照常并发执行。

然后G1在mutator一侧需要使用write barrier来实现:
-  SATB snapshot的完整性
-  跨region的引用记录到RSet里。
       这两个动作都使用了logging barrier,其处理有一部分由collector一侧并发执行。
       可以看到在这么多步骤里,G1只有两件事是并发执行的:(1) 全局并发标记;(2) logging write barrier的部分处理。而“拷贝对象”(evacuation)这个很耗时的动作却不是并发而是完全暂停的。那G1为何还可以叫做低延迟的GC实现呢?
       重点就在于G1虽然会mark整个堆,但并不evacuate所有有活对象的region;通过只选择收益高的少量region来evacuate,这种暂停的开销就可以(在一定范围内)可控。每次evacuate的暂停时间应该跟一般GC的young GC类似。所以G1把自己标榜为“软实时”(soft real-time)的GC。

这里上一张图。

JVM(四) G1 收集器工作原理介绍

Evacuation Failure

转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。

G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:

  1. 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
  2. 从老年代分区转移存活对象时,无法找到可用的空闲分区
  3. 分配巨型对象时在老年代无法找到足够的连续分区

由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。

补充

有关分代垃圾回收的概念 PDF 演示文稿

垃圾收集的书籍

阅读参考资料时看到的垃圾回收调优文章

非常不错的总结文,都是文字,耐心阅读会有收获的

可以看看

参考文章

上一篇:springboot项目使用拦截器修改/添加前端传输到后台header和cookie参数


下一篇:dispatch_sync和dispatch_async的区别