BookSea

V1

2023/01/30阅读:8主题:橙心

垃圾收集器必问系列—G1

本文已收录至Github,推荐阅读 👉 Java随想录

微信公众号:Java随想录

CSDN: 码农BookSea

人生下来不是为了拖着锁链,而是为了展开双翼。——雨果

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。设计者们设计G1的时候希望G1能够建立起“停顿时间模型”,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。下文会有所讲述。

基于Region的堆内存布局

首先,介绍下G1基于Region的堆内存布局,这是能够能够建立起“停顿时间模型”的关键。

G1逻辑上分代,但是物理上不分代

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。

G1可以通过参数控制新生代内存大小的参数:-XX:G1NewSizePercent(默认等于5),-XX:G1MaxNewSizePercent(默认等于60)。也就是说新生代大小默认占整个堆内存的 5% ~ 60%

这样就不存在界限,无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂

可以简单算一下,G1能管理的最大内存大约 32MB * 2048 = 64G左右

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象(即超过1.5个region)即可判定为大对象。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

可预测的停顿时间模型

G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

G1收集器会去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来

这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

所以说G1实现可预测的停顿时间模型的关键就是Region布局优先级队列。看起来好像G1的实现也不复杂,但是其实有许多细节是需要考虑的。

跨Region引用对象

G1将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?

解决方案的思路我们已经知道,使用记忆集

但是麻烦的是,G1的堆内存是以Region为基本回收单位的,所以它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。

G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。

由于Region数量较多,每个Region都维护有自己的记忆集,光是存储记忆集这块就要占用相当一部分内存,G1比其他圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

对象引用关系改变

如何处理用户线程改变对象引用关系?

之前说过,G1收集器则是通过原始快照(SATB)算法来实现的。

垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。

用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值。在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

运作过程

  1. 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  4. 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
  • 从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的
  • G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现

G1默认的停顿目标为两百毫秒,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。应用运行时间一长,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

CMS VS G1

相比CMS,G1的优点有很多,较为明显的优点就是G1不会产生垃圾碎片。不过,G1相对于CMS仍然不是占全方位、压倒性优势的,至少G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的每个Region都必须有一份卡表,这导致G1的记忆集可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,全局只有一份。

在执行负载的角度上,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间。

相关参数

参数 描述
-XX:+UseG1GC 手动指定使用G1收集器执行内存回收任务(JDK9后不用设置,默认就是G1)
-XX:G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000
-XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标
-XX:InitiatingHeapOccupancyPercent 简称为IHOP,设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45%
-XX:+G1UseAdaptiveIHOP 自动调整IHOP的指,JDK9之后可用
-XX:GCTimeRatio 这个参数为0~100之间的整数(G1默认是9),值为 n 则系统将花费不超过 1/(1+n) 的时间用于垃圾收集。因此G1默认最多 10% 的时间用于垃圾收集

如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。

分类:

后端

标签:

后端

作者介绍

BookSea
V1