深入解析G1垃圾回收器

发布时间 2023-08-29 17:33:30作者: Booksea

本文已收录至GitHub,推荐阅读 ? Java随想录

微信公众号:Java随想录

原创不易,注重版权。转载请注明原作者和原文链接

上篇文章我们聊了CMS,这篇就来好好唠唠G1。

CMS和G1可以说是一对欢喜冤家,面试问你CMS,总喜欢把G1拿进来进行比较。

G1在JDK7中加入JVM,在JDK9中成为了默认的垃圾收集器,如果在JDK8中使用G1,我们可以使用参数 -XX:+UseG1GC 来开启。

G1和CMS相比有哪些优缺点?G1为什么能够建立可停顿的时间模型?

别着急,本篇文章告诉你答案。

G1,全名叫:Garbage First。是垃圾收集器技术发展历史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

这句话啥意思?

在G1之前的垃圾回收器,如Parallel Scavenge、Parallel Old、CMS等,主要针对Java堆内存中的特定部分(新生代或老年代)进行操作。然而,G1将Java堆划分为多个「小区域」,并根据每个区域中垃圾对象的数量和大小来优先进行垃圾回收。

称之为「基于Region的内存布局」。

另外设计者们设计G1的时候希望G1能够建立起「停顿时间模型」,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

所以我们能够总结出G1身上的两个标签:

  • 基于Region的内存布局
  • 停顿时间模型

先来说说基于内存布局是怎么个事儿。

基于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作为老年代的一部分来进行看待。

分配大对象的时候,因为占用空间太大,可能会过早发生GC停顿。G1在每次分配大对象的时候都会去检查当前堆内存占用是否超过初始堆占用阈值IHOP(The Initiating Heap Occupancy Percent),缺省情况是Java堆内存的45%。当老年代的空间超过45%,G1会启动一次混合周期。

可预测的停顿时间模型

基于Region的停顿时间模型是G1能够建立可预测的停顿时间模型的前提。

G1将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

G1收集器会去跟踪各个Region里面的垃圾堆积的「价值」大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表。

每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是「Garbage First」名字的由来。

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

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

跨Region引用对象

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

本质上还是我们之前提过的「跨代引用」问题,解决方案的思路我们已经知道,使用「记忆集」。

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

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

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

这可以说是G1的缺陷之一。

除了跨代引用外,对象引用关系改变,如何解决?

对象引用关系改变

解决的办法我们之前在讲「三色标记算法」的时候提过,G1使用「原始快照」来解决这一问题。

垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建。

G1为每一个Region设计了两个名为「TAMS(Top at Mark Start)」的指针。

把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

与CMS中的「Concurrent Mode Failure」失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间Stop The World。

G1可以通过-XX:MaxGCPauseMillis参数设置垃圾收集的最大停顿时间的JVM参数,单位为毫秒。

在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。

然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

G1收集器会根据这个设定值进行自我调整以尽量达到这个暂停时间目标。例如,如果设定了-XX:MaxGCPauseMillis=200,那么JVM会尽力保证大部分(但并非全部)的GC暂停时间不会超过200毫秒。

运作过程

  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之间)上看又是基于「标记-复制」算法实现。

这时候有些点子王可能会想,如果我把-XX:MaxGCPauseMillis,调的非常小,那是不是就回收的更快了?

G1默认的停顿目标为两百毫秒,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。

应用运行时间一长,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

CMS VS G1

相比CMS,G1的优点有很多,较为明显的优点就是G1不会产生垃圾碎片

But,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之间。

文章的最后放一组G1的常用参数:

参数 描述
-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% 的时间用于垃圾收集

最后吐槽一句,JVM真的很难,垃圾收集器的内部原理实在太复杂,如果要深究需要长时间的积累。

当然我们不是JVM的专业人员,不需要学的那么深入,这篇文章讲到的内容能基本应付面试和工作场景了。

那本篇文章到这结束啦,我们下篇再见??。


感谢阅读,如果本篇文章有任何错误和建议,欢迎给我留言指正。

老铁们,关注我的微信公众号「Java 随想录」,专注分享Java技术干货,文章持续更新,可以关注公众号第一时间阅读。

一起交流学习,期待与你共同进步!