G1垃圾收集器

x33g5p2x  于2021-12-18 转载在 其他  
字(4.4k)|赞(0)|评价(0)|浏览(465)

垃圾收集器的考量指标

垃圾收集器也有类似CAP理论的矛盾,具体如下面三个考量指标:

  • 吞吐量(Throughput)
  • 响应能力、延迟(Latency)
  • 占用的内存(Capacity)

上面三个考量指标无法同时全部满足最优,只能满足其中的两个,而牺牲其中一个的部分效率。

吞吐量

吞吐量关注的是,在一个指定的时间范围内,最大化一个应用的工作量。

如下方式来衡量一个系统吞吐量的好坏:

  • 在一个小时内,同一个事务(任务或请求)完成的次数。
  • 数据库一小时内完成多少次查询。

对于关注吞吐量的系统,偶尔卡顿是可以接受的,因为这个系统关注的是长时间大量任务的执行能力,单次快速的响应并不值得考虑。

响应能力

响应能力是一个应用或者一个系统是否能够及时快速的响应,比如:

  • 一个桌面的UI程序能多快的响应一个事件。
  • 一个网站能多快的返回一个页面请求。
  • 一个数据库能多快的返回一个查询结果。

对于这类响应能力敏感,追求低延迟的场景,长时间的卡顿是不能忍受的。

内存的占用率

为了加快内存扫描的数据,GC垃圾收集器通常会在内存中使用一些数据结构如卡表、记忆集等来存储对象直接的引用,而这些数据本身是需要占用堆的内存空间的。记录的信息越多,扫描时就会越快,同时也越占内存。

G1的诞生背景

随着硬件的成本越来越低,机器的内存也越来越大,GC收集器占用的内存基本上可以容忍,而吞吐量可以通过集群(增加机器)来解决,所以STW的时间成为JVM急迫解决的问题,如果还是按照传统的分代模型,使用传统的垃圾收集器,那么STW的时间将会越来越长。

在传统的垃圾收集器中,STW的时间是无法预测的,有没有一种办法,能够首先定义一个停顿时间,然后反向推算收集的内容呢?就像是领导在年初制定KPI一样,分配的任务多就多干些,分配的任务少就少干点。

G1的思路说起来也类似,它不要求每次都把垃圾清理的干干净净,它只是努力做它认为对的事情。

我们要求G1,在任意1秒的时间内,停顿不得超过10ms,这就是在给它制定KPI。G1会尽量达成这个目标,它能够反向推算出本次要收集的大体区域,以增量的方式完成收集。

这也是使用G1垃圾回收器(-XX:+UseG1GC)不得不设置的一个参数:-XX:MaxGCPauseMillis=10,这个参数默认为200ms。

G1的适用场景:

  • 服务端多核CPU、JVM内存占用较大的应用。
  • 应用程序在运行过程中会产生大量的内存碎片,需要经常压缩空间。
  • 想要更可控、可预期的GC停顿周期,防止高并发下的应用雪崩现象。

G1的堆内存划分

为了实现STW的时间可预测,首先要有一个思想上的改变,使用分而治之。G1将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region可能是Eden,也有可能是Survivor,也有可能是Old,另外Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象(大对象直接进入老年代)。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的进行回收大多数情况下都把Humongous Region作为老年代的一部分来进行看待。

G1在逻辑上还是划分Eden(所有Eden region之和)、Survivor(所有Survivor region之和)、OLd(所有Old region之和),但是物理上他们不是连续的,而且同一块region在不同时期的角色可以是不一样的,有可能现在这块region存放的是Eden对象,进行一个YGC之后,下一次分配在上面的对象可能是Old对象。

重要概念

收集集合CSet

收集集合(CSet,CollectionSet):一组可被回收的Region的集合,在CSet中存活的对象会在GC的过程中被复制到另一个region中,CSet中的Region可以来自Eden、Survivor、Eden。

卡表Card Table

卡表是为了解决跨代引用而诞生的,假设在CMS垃圾收集器中,要回收新生代的对象,那么就必须扫描整个老年代,而引入卡表,就只需要扫描卡表中Dirty区域即可,这样避免了扫描整个老年代,大大加快了并发标记的速度。

卡表(Card Table)底层是使用Bitmap实现的,在G1中,大小为1M的Region按512Byte可以划分为2048(1M/512Byte)个卡页(Card Page),Bitmap中的一个bit代表一个Region中的一页,这样1M的Region总共需要2048个bit位来表示,默认bit位为0,表示这一页不引用任何其他Region中的对象,当这一页中引用了其他Region中的对象时,将bit位置为Dirty(1)。

假设现在Region A中的对象第4页引用了Region B中的对象,那么将Region A的卡表的第3(数组索引从0开始)个bit为置为Dirty(1),这就是所谓的point-out

在CMS中,可以将老年代的所有空间使用一个卡表来标记哪些老年代对象所在的页引用了新生代的对象,这样在执行YGC时,只需要扫描卡表中标记为Dirty的区域上的对象即可,而无需扫描整个堆。

而在G1中,单靠卡表无法解决跨Region引用问题,因为G1中划分为多个Region,存在多对多(一个Region中对象引用多个Region中的对象,一个Region中的对象被多个Region引用)的关系,不像CMS中的一对一(老年代对新生代),所以还需要引入下面的数据结构-记忆集。

记忆集RSet

在G1中,每个Region都有一个记忆集(RememberedSet)的数据结构,用来记录其他Region中对象到当前Region中对象的引用关系(point-in)。RSet底层使用HashTable实现,key为引用对象所在Region的内存起始地址,value为引用对象所在Card Table的index。

同样假设现在Region A中的对象第4块引用了Region B中的对象,那么将Region A的卡表的第3个bit为置为Dirty(1),然后将Region B中的RSet中增加一条记录,其中key为Region A的内存起始地址,value为Region A的对象所在卡表中的索引3,这样在回收Region B时,只需要将Region B的RSet作为GC ROOTS对象扫描即可。

RSet的更新并不是同步完成的,G1会把所有的引用关系都先放入到一个队列中,称为Dirty Card Queue(DCQ),然后使用单独的线程消费这个队列来完成更新,这么做是因为对象的引用变更太频繁,使用队列进行异步削峰,可以使用参数-XX:G1ConcRefinementThreads 这个参数来指定消费线程的数目。

使用空间来换时间,用额外的空间来维护引用信息,通常需要消耗5%~10%的空间。

写屏障(write barrier):当对象的引用发生变化时,插入一个写屏障来维护RSet。这个写屏障与Java内存模型中的写屏障不是一个概念。

G1的运行过程

G1的垃圾回收过程可能会出现下面三种模式:

  • YGC
  • MixedGC
  • FullGC

YGC

YGC还是一样,在新生代满了之后触发YGC,新生代的垃圾回收方式还是采用复制算法,将存活的对象复制到Survivor或者老年代。

YGC过程:

  1. 根扫描:扫描静态对象(对应类的静态变量和常量)和本地对象(虚拟机栈中的对象)。
  2. 更新RSET:处理DCQ队列中未处理完的任务。
  3. 处理RSET:检测从老年代指向新生代的对象。
  4. 拷贝对象:采用复制算法将Eden Region中的对象复制到Survivor Region或者Old Region。
  5. 处理引用队列:处理软引用、弱引用、虚引用。

MixedGC

MixedGC的触发时机:达到-XX:InitiatingHeapOccupanyPercent阈值开始并发标记,默认值为45,也就是当已经分配的内存加上即将分配的内存超过堆内存总容量的45%时就会开始并发标记。

MixedGC分为两步:

  • 全局并发标记(global concurrent marking)
  • 拷贝存活对象(evacuation)

全局并发标记又分为以下四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿,初始标记的过程就是YGC的过程,所以可以使用Survivor区的对象作为根对象。
  • 并发标记( Concurrent Marking):这个阶段需要达到上面提到的MixedGC触发的条件时才会执行,从GC ROOTS开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking):对用户线程做一个短暂的暂停,用于处理并发标记阶段仍遗留下来的最后那少量的漏标对象,G1中会使用SATB(Snapshot-At-The-Beginning)算法来解决。
  • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。

TAMS是什么?要达到GC与用户线程并发运行,必须要解决回收过程中新对象的分配,所以G1为每一个Region区域设计了两个名为TAMS(Top at Mark Start)的指针,从Region区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。

FGC

当晋升失败、疏散失败、大对象分配失败、Evac失败时,有可能触发Full GC,在JDK10之前,Full GC是串行的,JDK10之后引入了并行Full GC。

相关文章