Garbage First(g1)垃圾回收器

cms优秀么?优秀,但是对于日渐庞大的内存,特别是堆内存超过8g之后,stop the world的时间会被无限拉长,cms并不能给与太大帮助,跨代对象的扫描也是问题,更何况final remark是要扫描整个年轻代,这点看是很难接受的,g1是如何解决这些问题,针对这些问题,设计上怎么去下手,下面就介绍下g1垃圾回收器,先介绍几个概念:

Region

cms是有严格意义上的分区,这种划分的特点是各代的存储地址(逻辑地址)是连续的。而g1的内存没有严格意义上内存分区,所有的内存被分为一个个大小的Region,默认是2048个,如果你的内存是4096m,那么每个region的大小为4096/2048=2m,region的内部是一个个的card page(card)组成,类似于计算机的内存分页,每个card的大小为512字节(B),也就是2m * 1024 * 1024/512=4096个card。

RSet

cms采用的是point-out(我引用了谁的对象结构),如果我扫描跨代(老年代引用年轻代)的对象,我不得不遍历老年代的card table,找到所有的dirty card,这种就有点费劲了,而g1采用Rset,这是一种point-into(谁引用了我的对象结构),这样判断跨代(老年代引用年轻代)对象时,只需要扫描自己本身的Rset就可以了,下图表示了RSet、Card和Region的关系:

G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。

CSet

Collection Set(CSet),它记录了GC要收集的Region集合,集合里的Region可以是任意年代的。在GC的时候,对于old->young的跨代对象引用,只要扫描对应的CSet中的RSet即可。对于 Young gc,CSet只包含Young Region,对于混合回收,CSets包含Young Region和Old Region。还有一些概念satb等,这些现在就不说了,后面会详细的说。

g1是一个全代回收算法,但是年轻代的回收和其他收集器没有本质上的不同,这里我就不做介绍了,下面就介绍g1的垃圾回收器过程:

global concurrent marking

这个过程和cms类似,老年代需要占用一定比例的堆空间才能触发,通过-XX:InitiatingHeapOccupancyPercent指定,默认值45%,也就是老年代占堆的比例超过45%。如果Mixed GC周期结束后老年代使用率还是超过45%,那么会再次触发全局并发标记过程,这样就会导致频繁的老年代GC,影响应用吞吐量。同时老年代空间不大,Mixed GC回收的空间肯定是偏少的。可以适当调高IHOP的值,当然如果此值太高,很容易导致年轻代晋升失败而出发Full GC,所以需要多次调整测试。

1:初始标记(Initial Marking)

初始标记这个过程需要stop the world,这个过程伴随着一次young gc,这个就是g1相较于cms优化的地方,省去了cms沿着gc root去trace整个年轻代的过程,省去了一部分标记的时间,这个过程的工作不仅如此,还设置stab快照的二个TAMS变量的值(NTAMS和PTAMS),所有在TAMS之上的对象在这个并发周期内会被识别为隐式存活对象。这个我一会会详细的说。

2:根分区扫描(root-region-scan)

初始标记过程之后,对象被复制到survivor当中,这个时候我们需要标记整个survivor的对象作为老年代的根,这点来看和cms区别并不大,这个过程不需要stop the world;但是如果发生新生代的回收,就证明现在堆的参数有问题,需要调优。

3:并发标记阶段(concurrent-mark)

这个阶段会沿着上个阶段标记到的根对象,遍历整个的老年代,当然这个过程也不例外,仍然是三色标记法,cms用的是写后屏障,而g1采用的是写前屏障,在灰色对象删除白色对象的时候就记录下白色对象,这个是极具优化性的一个个设计,这样在final remark的时候我就不用扫描整个年轻代的对象来确定年轻代到老年代的那些对象关系发生了变化,但是有利必然有弊,有一些真正要删除的对象可能因此而存活了下来,因此可能存在更多的浮动垃圾,只能期望下一次的gc能够把他们标记上,而整个的这个过程的基础依赖于stab(Snapshot-at-beginning),对象的标记,有内部的标记和外部的标记,cms采用的是内部的对象头标记,g1采用的外部的bitmap标记,我们现在看下这个东西如何实现的,又如何起到这个作用。

这个可以说是g1最核心的地方了,需要重点讲一下:

A:Initial Marking第一次的初始标记,prevBitMap为空(第一次即n-1次,上次的肯定没有),开始启用nextBitMap(实际上就是第n次,当前这一次),并设置PTAMS(prevTams)和NTAMS(nextTams),这个图片的遗憾是没有把concurrent mark的标记列举出来,可能处于不同的考虑吧,这个过程以A-B表示。

A-B:这个过程在这里说一下,concurrent mark过程就是沿着root trace,并在nextBitMap上标记这个对象是存活的,而新增加的对象并不在此次标记的范围内,为什么这样,stab的原则就是新增加的对象默认存活。这个时候随着Top指针偏移,新增加的对相就是[NTAMS,Top]这段的对象,这段对象本次gc默认存活,[PTAMS,NTAMS]表示第n轮(当前轮的对象),[Bottom,PTAMS]表示n-1轮(上一轮),而[top,...]就是空闲的内存空间。因为有了nextBitMap这个快照,当对象的引用发生变化的时候,变化的引用就会被放到SATBMarkQueue这个队列当中去,在remark阶段会做处理。

B:remark再次标记也叫最终标记,正是因为satb的nextBitMap快照形成了SATBMarkQueue,我们不需要再扫描整个的年轻代,cms必须要扫描整个年轻代,我这里不多说了,可以看我的cms那篇的介绍,这个阶段就是处理SATBMarkQueue里变化的引用。

C:清理阶段,这个阶段就是清理region中空闲的的分区,放到free list当中,以期再次使用,这个阶段就是nextBitMap复制到prevBitMap,NTAMS和PTAMS互换位置,这个阶段结束。

D:又开始一次的Initial Marking,设置NTAMS和Top指针重合,启用nextBitMap。然后重复上面的过程,大家可能有疑问为什么会有这个过程,很大的原因是整个阶段的标记结束,如果老年代内存比率不是很高,-XX:InitiatingHeapOccupancyPercent指定,默认值45%,就不会进入mixed gc,而是新一轮的标记,这也是为什么会有二个bitMap的原因,不能丢掉上一次标记的结果,当然功能不仅仅如此。

4:重新标记阶段(remarking)

上面的stab也简单介绍了这个过程,不过稍微有些简单,这个阶段也是进行最后的标记,也需要stop the world,主要的作用,就是清除掉SATBMarkQueue记录的的所有引用变化的对象,找到所有未被标记的存活对象,为下个阶段的对象复制和清除做准备。

5:清理阶段(Cleanup Phase

这个阶段主要的工作:识别出所有空闲的分区、RSet梳理、将不用的类从metaspace中卸载、回收巨型对象等等。这个阶段还会根据每个region的垃圾比率进行排序, -XX:G1MixedGCLiveThresholdPercent指定,不同版本默认值不同,有65%和85%,当垃圾比率超过这个值的时候才会放到CSet当中,怎么排序?排序的标准是什么?这个都是依靠stab完成的,准确的说事prevBitmap和nextBitMap来做的统计。

拷贝存活对象(Evacuation,Mixed gc)

这个过程需要stop the world,这个过程也不是一次完成的,他是伴随年轻代(minor gc)一块发生,所以叫做mixed gc,同时回收老年代(Cset里的)和年轻代的对象。每轮Mixed GC回收的Region最大比例:-XX:G1OldCSetRegionThresholdPercent默认10%,也就是每次回收Mixed GC附加的Cset的Region不超过全部Region的10%,最多10%,和一个周期内触发Mixed GC最大次数:G1MixedGCCountTarget,默认值8,共同使用,也就是8轮是的mixed gc最多回收80%的region,一般不需要额外调整这二个参数,但是这只是理想状态,-XX:G1HeapWastePercent(Cset内可被回收的垃圾占整个堆的比例值)指定,默认值5%,如果垃圾的比率提前降到5%,mixed gc就会提前结束。回收的过程就是把一部分region里的活对象拷贝到空region里去,然后回收原本的region的空间。

说道这里基本就结束了,还有最重要的一点,g1作为一个可调整的回收算法,是如何预测时间的,这里就需要说一下g1的Pause Prediction Model停顿预测模型,这个模型的代码:

//  share/vm/gc_implementation/g1/g1CollectorPolicy.hpp
double get_new_prediction(TruncatedSeq* seq) {
    return MAX2(seq->davg() + sigma() * seq->dsd(),
                seq->davg() * confidence_factor(seq->num()));
}

其实我们实际上也不关心这个,我们关系的是怎么实现的,g1的典型特征就是内存块被分为一个个的region,每个Region的大小是相等的,这就为我们计算做了保证,可控的范围包括minor gc,global concurrent mark,mixed gc,每次的上面的操作都会有一个统计值,每次的所耗费的时间,这些都是在不发生fgc的前提下,fgc是没办法控制时间的,根据这些时间,为了更趋近于-XX:MaxGCPauseMillis:暂停时间,默认值200ms,jvm会尽力的调整年轻代Region的多少,为什么是年轻代?因为不管是minor gc还是miexd gc都是stop the world的,最耗时间的就是年轻代的回收了,但是你也不能吧这个值设置太小,时间越小,年轻代就越小,那么你会频繁的进行minor gc,对系统反而没好处,如果你对停顿时间仍旧不满意,你需要调 G1MixedGCCountTarget和G1OldCSetRegionThresholdPercent来实现你所需要的停顿时间,调优是一个很缓慢的过程,不肯能一下就成功了,至于用不用g1垃圾回收器,取决于你的生产环境,我们大多数都用的cms,因为内存大多在4g,太大了并不好,g1在4g的表现并不是很突出,当然在elasticsearch这种吃内存的东西上,你想怎么用就是你自己的选择了。

Full GC

G1为分配担保预留的空间比例:通过-XX:G1ReservePercent指定,默认10%。也就是老年代会预留10%的空间来给新生代的对象晋升,如果mixed gc回收空间的速度很慢,而对象增长的很快,甚至超过了10%这预留的配额,那么就会触发Full gc,Full gc由serial old执行,回收年轻代和老年代。

整个的过程可能与下面的图(hllvm的R大)有些类似:

到这里也基本结束了,端午三天因为疫情的原因,写了三篇关于jvm的帖子,每篇基本上都需要一天的时间,有些东西从别的地方借来的,但是里面很多都有我自己的理解,gc的知识我大概用了3年左右的时间一点点的认识,源码也看过一些,只是理解的时候可能片面,希望对于看到这个帖子的人有所帮助,其中的一些东西我理解的可能也存在问题,真的写的有点累,jvm的知识也到此结束了,还有几个案例,时间太久了,我拿不出当时的快照了,没法分析了,下个我是介绍网络模型和netty还是lucene呢,我真的感觉lucene是我看过最优秀的东西了,彻底改变我的编程三观,希望我能坚持写完我所有学习的东西吧,每个东西都是花费了我不少于3个月的时间一点点摸索的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章