java垃圾收集器-CMS G1 ZGC

CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。这是因为CMS收集器工作时,GC工作线程与用户线程可以并发执行,以此来达到降低收集停顿时间的目的。
CMS收集器仅作用于老年代的收集,是基于标记-清除算法的,它的运作过程分为4个步骤:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要Stop-the-world。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短
具体参考:https://www.jianshu.com/p/2a1b2f17d3e4
CMS收集器优点:并发收集、低停顿。
CMS收集器缺点:
CMS收集器对CPU资源非常敏感。
CMS收集器无法处理浮动垃圾(Floating Garbage)。因为重新标记之后,并行清除的时候用户线程和gc线程同时运行,还会导致一部分对象不可达但是不会被回收
CMS收集器是基于标记-清除算法,该算法的缺点都有。
CMS收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面篇章介绍过标记-清除算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供CMS版本。

安全点

用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准 进行选定的
“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

G1

G1重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成。即G1提供了接近实时的收集特性

1.young gc

G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。全过程stw,会根据预期停顿时间决定回收多少region(其实是eden区占用多少的region,会回收所有eden区的region)。笔者服务堆12g,eden区分配了8G,young gc耗时15ms。

2.mixed gc

选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

初始标记

此阶段需要stop the world。这一阶段会触发新生代垃圾回收。标记可能引用了老年代对象的survivor区域。在日志中以 GC pause (young)(inital-mark)标识。

根区域扫描

扫描引用了老年代的survivor区域。此阶段和用户线程并发执行。并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。(可以理解为初始标记的延续,不过是并发执行的)

并发标记

在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会在Remark阶段被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。

重标记

会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。

Copy/Clean up

多线程清除失活对象,G1选择“活跃度(liveness)”最低的区域, 这些区域可以最快的完成回收,会有STW。
G1将回收区域的存活对象拷贝到新区域,清除Remember Sets

大对象处理

在G1中,对于超过0.5个region size的对象是大对象,对于大对象不会移动,只会清理,并且直接分配到old中。

ZGC

image.png

具体参考:https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html
https://juejin.cn/post/6844903957492563975

Remembered Set

在CMS中,也有RSet的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种point-out,在进行Young GC时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
但在G1中,并没有使用point-out,这是由于一个分区太小,分区数量太多,如果是用point-out的话,会造成大量的扫描浪费,有些根本不需要GC的分区引用也扫描了。于是G1中使用point-in来解决。point-in的意思是哪些分区引用了当前分区中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次GC时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。
就内存占用而言,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗 )可能会占生个堆容量的20%乃至更多;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的(代价就是当CMS发生Major GC时,要把整个新生代作为GC Roots来进行扫描)。
更详细信息:https://blog.csdn.net/z69183787/article/details/108558963
https://www.jianshu.com/p/8d37a07277e0

G1 参数调优

-XX:MaxGCPauseMillis

暂停时间,默认值200ms。这是一个软性目标,G1会尽量达成,如果达不成,会逐渐做自我调整。
对于Young GC来说,会逐渐减少Eden区个数,减少Eden空间那么Young GC的处理时间就会相应减少。对于Mixed GC,G1会调整每次Choose Cset的比例,默认最大值是10%,当然每次选择的Cset少了,所要经历的Mixed GC的次数会相应增加。
减少Eden的总空间时,就会更加频繁的触发Young GC,也就是会加快Mixed GC的执行频率,因为Mixed GC是由Young GC触发的,或者说借机同时执行的。频繁GC会对对应用的吞吐量造成影响,每次Mixed GC回收时间太短,回收的垃圾量太少,可能最后GC的垃圾清理速度赶不上应用产生的速度,那么可能会造成串行的Full GC,这是要极力避免的。所以暂停时间肯定不是设置的越小越好,当然也不能设置的偏大,转而指望G1自己会尽快的处理,这样可能会导致一次全部并发标记后触发的Mixed GC次数变少,但每次的时间变长,STW时间变长,对应用的影响更加明显

-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent

新生代比例有两个数值指定,下限:-XX:G1NewSizePercent,默认值5%,上限:-XX:G1MaxNewSizePercent,默认值60%。G1会根据实际的GC情况(主要是暂停时间)来动态的调整新生代的大小,主要是Eden Region的个数。最好是Eden的空间大一点,毕竟Young GC的频率更大,大的Eden空间能够降低Young GC的发生次数。但是Mixed GC是伴随着Young GC一起的,如果暂停时间短,那么需要更加频繁的Young GC,同时也需要平衡好Mixed GC中新生代和老年代的Region,因为新生代的所有Region都会被回收,如果Eden很大,那么留给老年代回收空间就不多了,最后可能会导致Full GC。

-XX:G1MixedGCLiveThresholdPercent

通过-XX:G1MixedGCLiveThresholdPercent指定被纳入Cset的Region的存活空间占比阈值,不同版本默认值不同,有65%和85%。在全局并发标记阶段,如果一个Region的存活对象的空间占比低于此值,则会被纳入Cset。此值直接影响到Mixed GC选择回收的区域,当发现GC时间较长时,可以尝试调低此阈值,尽量优先选择回收垃圾占比高的Region,但此举也可能导致垃圾回收的不够彻底,最终触发Full GC。

-XX:InitiatingHeapOccupancyPercent

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

空的区域被移除并回收。并计算所有区域的活跃度(Region liveness).
G1具备如下特点:
并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-the-world停顿的时间,部分其他收集器原来需要停顿Java线程执行的GC操作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
分代收集
空间整合:与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
可预测的停顿:这是G1相对于CMS的一个优势,降低停顿时间是G1和CMS共同的关注点。
G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1会通过一个合理的计算模型,计算出每个Region的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。

G1与CMS对比

G1在压缩空间方面有优势。
对于CMS是采用标记清除算法,不带压缩功能的,所以肯定会有内存碎片的产生,而G1采用的是拷贝复制算法,所以肯定不会产生内存碎片问题,所以压缩起来空间利用率也大大提升了。
G1通过将内存空间分成区域(Region)的方式避免内存碎片问题。
Eden、Survivor、Old区不再固定,在内存使用率上来说更灵活。
因为G1的Eden、Survivor、Old的个数是可以动态伸缩的,而不像CMS,这些区域就已经定好大小了不能被调整。
G1可以通过设置预期停顿时间(Pause Time)来控制垃圾收集时间,避免应用雪崩现象。
G1在回收内存后会马上同时做合并空闲内存的工作,而CMS默认是在STW(stop the world)的时候做。
G1会在Young GC中使用,而CMS只能在Old区使用。
在执行负载的角度上,同样由于两个收集器格子的细节实现特点导致了用户程序运行时的负载不同。譬如二者都使用到了写屏障,CMS用写后屏障来更新维护卡表,而G1除了使用写后屏蔽来进行同样的卡表维护外,为了实现原始快照搜索,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索算法能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

对比

image.png

堆外内存跟踪

image.png

G1场景

参考:
https://www.jianshu.com/p/2a1b2f17d3e4
https://blog.csdn.net/liwenshui322/article/details/88866564?spm=1001.2014.3001.5501
https://blog.csdn.net/z69183787/article/details/108558963
https://zhuanlan.zhihu.com/p/181305087
G1日志:https://github.com/bingoogolapple/bingoogolapple.github.io/issues/186
G1:https://segmentfault.com/a/1190000039411521

推荐阅读更多精彩内容