【JAVA核心知识】4.3: G1垃圾收集器

您所在的位置:网站首页 垃圾收集器图片 【JAVA核心知识】4.3: G1垃圾收集器

【JAVA核心知识】4.3: G1垃圾收集器

2023-10-10 14:17| 来源: 网络整理| 查看: 265

前言:

G1垃圾收集器是JAVA7引入的一款垃圾收集器,全称Garbage-First Garbage Collector。G1是一个分代的,增量的,并行与并发的标记-复制算法垃圾回收器。G1设计的目的是为了应对越来越大的内存以及越来越多的处理器数量,复制算法能很好的解决空间碎片化的问题,其分区与部分回收机制又使得它能有效减少大内存下复制算法所导致的单次GC停顿时间(清除算法可以并发处理,复制算法只能STW进行)。但是其分区特性也使得其不适合小内存场景,在小内存场景回收效率低下。 G1还有一个极其重要的特性-软实时(soft real-time):实时回收是指每一次垃圾回收所造成的停顿时间都在限定时间之内。软实时是指G1允许设置一个限定值,G1会努力控制每一次GC所造成的停顿都在限定时间之内,但是并不保证每一次GC造成的停顿都能满足要求。停顿时间的限定可以通过-XX:MaxGCPauseMillis参数设置。如果需要设置此参数,要慎重,过大的话没有意义,过小则会导致频繁触发回收,降低回收效率。

通常设到100ms、250ms之类的都可能是合理的。设到50ms就不太靠谱,G1可能一开始还跟得上,跑的时间一长就开始乱来了。 这也提醒大家:如果您的程序要长时间运行,那么在技术选型评估GC性能的时候要让测试程序跑足够长时间才能看清状况。多久才够长取决于实际应用要连续运行多久。不然一个要运行一个月才重启一次的程序,如果测试的时候只测了两个小时就觉得没问题,实际上线跑起来可能正好两个半小时的时候来了一次几分钟的full GC暂停,那就纱布了⋯ G1需要暂停来拷贝对象,而CMS在暂停中只需要扫描(mark)对象,那算法上G1的暂停时间会比CMS短么? 其实CMS在较小的堆、合适的workload的条件下暂停时间可以很轻松的短于G1。在2011年的时候Ramki告诉我堆大小的分水岭大概在10GB~15GB左右:以下的-Xmx更适合CMS,以上的才适合试用G1。现在到了2014年,G1的实现经过一定调优,大概在6GB~8GB也可以跟CMS有一比,我之前见过有在-Xmx4g的环境里G1比CMS的暂停时间更短的案例。 ------ RednaxelaFX(https://hllvm-group.iteye.com/group/topic/44381)

G1垃圾收集器 前言:1. Region2. CSet3. RSet4. 年轻代收集5. 混合收集周期5.1 Previous/Next Bitmap与PTAMS/NTAMS5.2 并发标记周期5.2.1 初始标记5.2.2 根分区扫描5.2.3 并发标记5.2.4 重新标记5.2.5 清除 6. 担保机制Full GC7. 总结

1. Region

不同于传统分代垃圾收集器,将堆空间固定的划分为新生代与老年代两个内存区域。G1采用分区的概念,将整个堆空间分为若干个大小相等的内存区域。每个内存区域就是一个Region(分区)。这些Region不再是固定的属于新生代或者老年代,它既可能是新生代,也可能是老年代。Region是G1的最小回收单元。 每一块Region的大小是固定的,可以通过参数XX:G1HeapRegionSize设置大小(1MB-32MB且必须是2的幂)。 使用中的Region根据其角色可以分为Eden Region, Survivor Region, Old Region以及Humongous Region。其中Eden Region,Survivor Region,Old Region顾名思义分别对应分代收集算法的中的Eden区,Survivor区,Old区,每个区都含有若干个Region。在这里插入图片描述 Humongous Region是G1对巨大对象的特殊处理。当一个对象的大小超过一个阈值(HotSpot中是分区大小的50%),该对象就会被认为是一个巨型对象。一个巨型对象会占据一个或者多个连续Region(如果没有找到连续Region则会进行担保Full GC)。其中起始分区被标记为起始巨型(Start Humongous),后续分区被标记为连续巨型(Continues Humongous)。巨型分区被认为是老年代的一部分,但是巨型对象的晋升成本很高,因此对于巨型对象,G1在年轻代收集时就会探测其存活性,一旦死亡就会在当次回收释放掉。另外在混合收集周期,因为巨型对象的独占性,巨型对象死亡便是整个分区的死亡,分区就不会再被放入CSet等待回收,而是在清理阶段直接释放掉。

2. CSet

CSet全称Collection Set。CSet中保存着每次GC中要回收的目标分区。回收分区内的存活对象会被复制到新分配的空闲分区中。因为年轻代是整体回收,所以无论是年轻代收集还是混合收集,所有的年轻代垃圾分区都会被放入CSet。老年代分区的回收只会在混合收集中涉及,在混合收集过程中,根据收益率将回收收益率较高的候选年老代分区放入CSet等待回收[注1]。 G1的分区回收是根据CSet来的,未完全死亡的分区要想被回收,首先要被放入CSet,然后在Evacuation阶段[注2],G1会针对CSet中的Region进行回收。 年轻代收集的CSet中都是年轻代Region,混合收集周期的CSet中既有年老代Region又有年轻代Region。通过CSet控制每次回收的分区数量使得G1的回收过程变的可控。 注1: 1.分区的回收收益率是该分区的垃圾占有率,垃圾占有率越高意味着收益率也就更高。因为垃圾占有率越高意味着要复制的存活对象就越少,那复制的过程也就越快,同时分区回收后获得的空闲空间也就越多。 2.并不是所有的老年分区都是候选分区,只有那些收益率达到标准的分区才会被列为候选分区,这个标准通过参数-XX:G1MixedGCLiveThresholdPercent进行设置,默认为85,意味着一个老年代分区的存活率低于85%才会被列为候选分区。 3.并不是所有的候选分区都会被放入CSet,-XX:G1OldCSetRegionThresholdPercent设置了可以放入CSet的老年代分区数量上限,默认为10,即默认最大有整个堆空间10%大小的老年代分区可以被放入CSet中(相当于10%总堆Region数)。 注2:G1的收集过程可分为两个阶段:Concurrent Marking(并发标记)与Evacuation Pauses(空间回收)。

3. RSet

RSet全称Remember Set(记忆集合)。 每个Region的RSet都记录着引用当前分区对象的外部老年代分区对象所在的Region_Card[注3]。 在这里插入图片描述 RSet的目的是为了获取收集区域来自非收集区域的引用来源。即非CSet分区对CSet分区的引用关系。这样在Evacuation Pauses阶段对CSet分区进行复制回收时,在新Region中完成CSet分区存活对象的复制后,就可以精准知道该复制对象的引用来自于哪里,从而在复制完成后精确的找了引用方进行引用对象更新。而不用进行全堆扫描确定引用关系再进行引用地址更新。 为什么RSet仅记录来自外部老年代分区的引用呢?一个分区要么是CSet分区,要么不是CSet分区,所以来自本分区的引用无需记录。而无论是young GC还是混合GC,新生代总是在CSet中的(原因见下文年轻的收集和混合收集),所以来自年轻代分区的引用无需记录也无需记录。 其他采用复制算法的分代GC收集器无需记录是因为回收整代,不进行部分回收。相当于每次收集整代都在CSet中。 RSet还有一个作用是在年轻代收集时,通过RSet确定年老代对年轻代的引用,确定根节点。因为年老代的引用对年轻代的收集属于外部引用,因为存在年老代对象引用的年轻代对象直接认为存活,这诚然会有将一些死亡对象误判为存活,但是却能避免年轻代收集时的全堆扫描。 注3:对于Region,G1采用了Card Table的进行分割,Card Table将Region分为一个个Card Page,这样对于引用就可以扫描对应的Card Page,而不用扫描整个Region。CMS垃圾收集器通用采用了类似的理念,详见【JAVA核心知识】4.2: CMS垃圾收集器 的注2。

4. 年轻代收集

当年轻代满[注4]时就会触发年轻代收集,回收年轻分区,并将存活次数达到晋升标准的对象移入老年代。 年轻代分区的收集是STW的,用户线程暂停,运用可达性分析法扫描所有的年轻代分区,标记GC Roote直接关联引用链中的存活对象,除此之外会扫描所有年轻代分区的RSet,标记出老年代分区引用的对象对根节点进行补充,根据完整的根对象进行引用分析就获得了年轻代分区的所有存活对象。标记完成后Eden Region中的存活对象会被复制到新的Survivor Region,而Survivor Region的存活对象满足晋升标准的晋升入老年代,不满足晋升标准的依然被复制到新的Survivor Regiong。之后释放所有的年轻代分区对内存空间进行回收。 此外,如果设置了软实时标准,G1还会根据GC的耗时进行分析,并根据设置的软实时标准调整年轻代尺寸(分区数量),以此来保证大多的年轻代收集都能满足软实时标准的要求。。 注4:年轻代大小会在初始空间-XX:G1NewSizePercent(默认整堆5%)与最大空间-XX:G1MaxNewSizePercent(默认整堆60%)之间动态变化。具体大小由G1根据软实时目标(如果设置的话),回收效率计算得到。不过G1依然允许通过参数-XX:NewRatio、-Xmn设置固定的新生代大小,但是此时软实时的特性也就失效了。

5. 混合收集周期

随着程序的运行,越来越多的对象晋升入老年代,当老年代堆占比达到IHOP阈值(老年代占整堆比,可以通过-XX:InitiatingHeapOccppancyPercent设置,默认45%)时就会触发混合收集,但是混合收集不会立即执行,而是会等待下一次年轻代收集。另外为了保证停顿时间,混合收集可能只会收集老年代分区的一部分,接着就继续让应用程序运行,并在接下来的连续多次年轻代收集时触发混合收集。整个连续多次的混合收集过程被称为混合收集周期(Mixed Collection Cycle)。 通过参数-XX:G1MixedGCCountTarget控制混合周期中混合收集的最大总次数,默认为8,即整个混合收集周期会进行8次混合收集,每次至少有1/8的候选老年代分区[注5]被放入CSet中。另外通过参数-XX:G1HeapWasteParcent来设置混合收集周期的堆废物百分比,默认为5,即当老年代垃圾堆占比低于5%时,即使没达到最大总次数,混合收集周期也会停止,因为过低的垃圾堆占比会使得回收效益变的很低。 注5:目标是候选老年代分区,而不是整个堆分区。且每一次加入的分区并不是说是遍历式的,比如第一次是0/8-1/8,那下次就是1/8-2/8这样的。而是根据回收收益率来的,每次都是将回收收益率最高的那一批候选分区加入CSet。(且也不是严格的1/8,因为候选老年代分区可能在程序执行过程中继续产生。具体的数量由G1通过回收效率计算获得。) G1的活动周期,图片来源https://blog.csdn.net/fedorafrog/article/details/104503829/

G1的活动周期,图片来源https://blog.csdn.net/fedorafrog/article/details/104503829 5.1 Previous/Next Bitmap与PTAMS/NTAMS

在整个混合收集周期内,用户线程并不是处于暂停状态的,这也就意味着会不断的有新对象产生,也老对象死亡,G1也就无法实时获得整个Region的完整的存活性标记以及进行回收收益率计算。 对于这个问题,G1为每一个Region创建与其对应的位图(Bitmap)来标记对象存活情况。 在这里插入图片描述

每一个Region拥有两个位图,一个是Previous Bitmap,用来表示上一个混合收集过程并发标记的结果(Region的对象存活情况)。另一个是Next Bitmap,用来记录此次混合收集过程并发标记的结果(Region的对象存活情况),在此次并发标记结束时,Next Bitmap替换原有的Previous Bitmap成为下一轮回合混合收集过程的Previous Bitmap。 除了Bitmap之外,G1还通过顶部标记TAMS(top at mark start)来记录标记过的内存范围: 在这里插入图片描述 同样的,每个Region还有两个TAMS。PTAMS表示上次混合收集标记到的位置,NTAMS表示此次混合收集标记到的位置,并发标记结束时NTAMS也会替换PTAMS。 通过位图G1便可以在清理阶段很快的获得回收收益率,通过顶部标记,G1可以知道新进的老年代对象应该从哪里进行内存分配。

下图,便是一个完整的位图与顶部标记的变动周期: 在这里插入图片描述

位图与顶部标记,图片来源https://www.cnblogs.com/thisiswhy/p/12388638.html 5.2 并发标记周期

混合收集过程中为了缩短停顿时间G1采用了并发标记的方式来确定对象存活性。但是G1的并发标记不同于CMS的并发标记方式。G1的并发标记过程分为:初始标记(Initial Mark),根分区扫描(Root Region Scanning),并发标记(Concurrent Mark),重新标记(Remark),清除(Cleanup)。

5.2.1 初始标记

初始标记阶段与CMS类似,这个阶段需要STW,负责标记出所有的GC Roots对象。但是并不是堆空间达到IHOP阈值之后立刻就进行初始标记STW,而是会等待下一次年轻代收集,利用年轻代收集的STW完成初始标记阶段。

5.2.2 根分区扫描

年轻代收集之后,年轻代存活的对象都会被放入新的Survivor Region,之后应用线程就会恢复运行。为了保证标记结果的准确性[注6],根分区扫描阶段需要将Survivor Region内的所有对象都标记为GC Roots对象。这些存活的Survivor Region也可以被称为Roots Region。此阶段不需要STW,但是这个阶段必须要在下一次年轻代收集触发之前完成,因为每次年轻代收集触发都会使得Survivor Region发生改变。 注6:Survivor区域的对象在初始标记完成阶段肯定时存活的,对于老年代分区来说,它们和初始标记时标记出来的GC Roots对象是一致的,GC Roots对象加上它们才是完整的根对象集。

5.2.3 并发标记

并发标记阶段与CMS的并发标记阶段类似,对根对象进行引用链分析,标记引用链上的对象为存活。此阶段不需要STW,且此阶段可能会经历年轻代回收,这意味着分析过程中对象的引用关系可能会发生改变。但是不同于CMS通过Dirty Card来记录标记期间的改变,G1通过SATB(起始快照算法 Snapshot at the beginning)来记录标记期间引用关系的改变。因为SATB不同于DirtyCard的全堆扫描,SATB解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。 5.2.3.1 SATB(起始快照算法 Snapshot at the beginning) CMS的DirtyCard和G1的SATB的目的都是为了解决并发过程中的引用变动导致的漏标,避免存在有效引用的对象被当作垃圾回收掉。为了解决这个问题,首先要知道漏标是如何出现的。在这里插入图片描述 如图,存在两条引用链,A->B->C与X->Y->Z,根据三色标记法可以看到A->B->C这条引用链以及被分析完毕,A,B,C被标记为存活,X->Y->Z这条引用链则尚未分析,在并发标记过程中引用关系发生改变,Y->Z引用断开,新增C->Z的引用,到达阶段2,当后续并发标记对X->Y->Z这条链分析时,X和Y被标记为存活,但是因为Y->Z已断开,所以无法找到Z也就不会标记Z为存活,此时进入阶段3。这时可以发现,明明Z拥有有效引用A->B->C->Z,但是却没有被标记为存活,那么它就会在后续的回收中被当作垃圾回收掉,影响到程序的运行,这就漏标现象。漏标有两个条件组成:一个条件是Y->Z的引用断开即灰色对象对白色对象的引用链断开,另一个条件是C->Z新引用的建立,即黑色对象对白色对象的引用链建立,两个条件都满足才会出现漏标。CMS和G1采用不同的方式解决这个问题: CMS关注点在黑色对象对白色对象引用的引用链建立,称为增量更新方式:CMS通过DirtyCard标记黑色对象C,在重新标记阶段对C的所有引用(因为DirtyCard只标记了C有变动,但是却不知道是那条链变动了)再进行一次引用链分析来解决漏标问题,这样的好处是保证标记为黑的都是存活的,不会出现错标,最大程度的清除垃圾,因为CMS采用清除算法,要清理更多的空间以保证空间空闲区间充足,缺点是C已经分析过的引用链还要重新分析,进行重复工作耗费时间。 G1关注点则在灰色对象对白色对象的引用链断开(确实是记录的Z,不过是引用链建立标记Z还是断开就标记Z这里还需要再求证) ,即SATB(起始快照算法):通过SATB来标记Z,将Z变为灰色对象,并把引用记录收集下来,在重新标记阶段从Z开始进行引用链分析。这样做的好处是能精准定位到变动的链,不会像增量更新那样出现有重复标记的工作,缺点是如果一个对象仅仅是断开链接,没有新的链接建立。即上面仅仅是Y->Z的引用断开,没有C->Z新引用的建立,此时SATB依然会把Z标记为灰色。那么此时本该成为垃圾的Z及其后续链就成为了存活对象,这部分对象就成为下次扫描时才能处理的浮动垃圾。 采用这种方式一是因为G1一般适用于大内存场景,大内存场景下card表的扫描,重复分析都会用更长的时间,而为了保证软实时要尽可能的节省时间(因为Y所在的region可能本身就不属于本次回收的区域,同时这样并发扫描时只需要扫描CSet的region即可,不需要扫描其他的region,采用增量更新的话就需要全扫描)。二是因为G1是在混合收集周期会进行多轮收集,只有最后一轮的浮动垃圾才会留到下一个GC,三是因为G1采用复制算法,空闲空间不是碎片化的,因此可以容忍部分浮动垃圾占用少量空间。 还有一种错标的情况:A->B->C这条引用链以及被分析完毕,A,B,C被标记为存活。但是在后续的并发标记过程中B->C的引用断开,导致C失去有效引用,此时虽然C已经死亡,但是因为之前被标记为存活,所以并不会被回收,便产生了错标情况,这样C就成为了浮动垃圾,等待下次回收时进行回收。

5.2.4 重新标记

并发标记阶段由于和用户线程一起执行,那么引用关系也会一直改变,G1在并发标记阶段使用SATB记录变化。重新标记阶段则会STW,使得引用不再变化,并解析SATB对对象存活性进行修正以获得完整的存活性标记。

5.2.5 清除

清除阶段也是STW的,但是不同于CMS的并发清理阶段,G1的清除阶段并不会直接清理垃圾对象,而是会根据Previous Bitmap与RSet分析出回收收益,并根据回收收益进行排序,将回收收益率最高的Region放入CSet,等待下次年轻代收集触发时在Evacuation阶段对CSet中的Region进行回收[注7]。 特别的,对于无存活对象的Region,不会放入Region,而是在清除阶段直接回收。也就是说最坏的情况下(所有候选Region都还有存活对象),经历了清除阶段堆内存也不会有任何空间被释放。 清除阶段还会做另一个重要的操作:用Next Bitmap覆盖Previous Bitmap(相当于分配一个新的符合长度的Bitmap),NTAMS覆盖PTAMS,以便下一次并发标记过程使用。 注7:每次混合收集周期都是年轻代收集触发的,CSet中Region的回收也是随着年轻代收集的STW完成的,年轻代收集结束后开始并发标记, 并发标记过程将需要回收的Old Region被放入CSet,下次年代收集时年轻代的Region也会被放入CSet,然后STW时将CSet中的Old Region与年轻代Region一起回收。接着开启下一次并发标记。这就是混合收集周期。可以说混合收集=年轻代收集+并发标记。

6. 担保机制Full GC

在GC过程中 ,如果无法申请到足够的空闲分区时,G1会首先尝试增加堆空间,如果扩容失败,G1就会触发担保机制:进行一次STW的Full GC,对堆空间进行清除和压缩,只保留存活对象。 导致无法申请到足够的空闲分区的原因有:

需要空闲Region时无空闲空间可用,如:年轻代新对象申请Region失败,年纪代晋升申请Region失败,回收CSet执行复制算法时申请Region失败。而造成无空闲空间可用的原因有:①混合收集过程中,因为用户线程是和GC线程并发进行的,如果混合收集回收空间的速度跟不上用户线程分配新对象的速度。使得空闲分区越来越少,最终导致无空闲空间可用。因此G1才会在老年代分区达到一定比例(IHOP阈值)时就会触发混合收集,而不是等堆空间被占满时才触发。②各个分区内部碎片化严重,虽然都有空闲空间,但是都达不到回收阈值成为候选分区,分区一直不被回收。那么就会导致空闲分区越来越少,最终导致分区回收时无空闲空间可用。堆空间较为碎片化,此时有一个超大对象需要多个连续分区,无法申请到连续分区也会导致Full GC的发生。

在Java10之前,G1的担保Full GC都是单线程的。但是G1使用于多核大内存的场景,这样的话单线程无法利用多核优势,回收性能较差。因此在JAVA10,G1引入了多线程的Full GC。

7. 总结

总的来说G1在大内存多核场景下有着很良好的表现,解决了大内存下一次GC因需要处理的空间更大而造成的停顿时间变长的问题。同时软实时的特性也使得GC对用户线程的影响可控。但是G1也存在着内存空间浪费的问题(Region垃圾占用率未达到回收标准时即使已识别出其内有垃圾对象,也依然不会回收),G1通过Garbage-First原则优先回收垃圾占用率最高的一批分区,使得空间浪费率处于一个可接受的范围。 PS: 【JAVA核心知识】系列导航 [持续更新中…] 上篇导航:4.2: CMS垃圾收集器 下篇导航:5: JVM的类加载 欢迎关注… 参考资料 G1垃圾收集器之RSet G1垃圾回收器详解 G1垃圾回收器详解 三色标记算法 SATB 【原创】面试官问我G1回收器怎么知道你是什么时候的垃圾? CMS与G1的最终标记 RednaxelaFX回复



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3