【大厂突击】七、全面的 G1 学习资料

207 阅读16分钟

背景

在 G1 收集器之前 CMS 算的上是一款优秀的收集器,它主要的优点有:并发收集、低停顿等。但是它的缺点也很明显比如:

CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4 CMS收集器无法处理浮动垃圾,由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自然会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,称为“浮动垃圾”,CMS 无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。 由于垃圾收集阶段会产生“浮动垃圾”,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以参数 -XX:CMSInitiatingOccupancyFraction 设置的过高将会很容易导致 “Concurrent Mode Failure” 失败,性能反而降低。 CMS是基于“标记-清除”算法实现的收集器,会产生大量不连续的内存碎片。当老年代空间碎片太多时,如果无法找到一块足够大的连续内存存放对象时,将不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程。 JVM 设计团队为了规避这些缺点设计出了 G1 收集器取代 CMS 收集器

G1 和 CMS 对比

在这里插入图片描述

G1 特点

在G1的实现过程中,引入了一些新的概念,对于实现高吞吐、没有内存碎片、收集时间可控等功能起到了关键作用。与其他收集器相比它有以下几个特点:

并行与并发,G1 能够充分利用多 CPU 、来缩短 STW 的时间 分代收集,G1 保留了分代的概念,并且 G1 可以不需要其他收集器配合就能够独立管理这个 GC 堆 空间整合,G1 收集器是大体上是基于【标记-整理】算法实现的收集器,但是两个 Region 之间上来看是基于 【复制】算法来实现的。这两种算法都不会再内存产生内存空间碎片。 可预测停顿时间,这也是 G1 的一大优势,G1 除了追求低停顿之外还建立了可预测时间模型。

各个概念

G1 分区

G1之前的垃圾收集器都会将堆分成三个部分,新生代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation),根据不同的分区类型,采用不同的策略进行回收。如下所示:

在这里插入图片描述

而G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。G1把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以作为新生代的Eden空间、Survivor空间,或者老年代空间。G1堆和操作系统交互的最小管理单位称为分区(Heap Region,HR)或称堆分区如下图所示:

在这里插入图片描述

G1的分区类型(HeapRegionType)大致可以分为四类:

  • 自由分区(Free Heap Region,FHR)
  • 新生代分区(Young Heap Region,YHR)
  • 大对象分区(Humongous Heap Region,HHR)
  • 老生代分区(Old Heap Region,OHR)

其中新生代分区又可以分为Eden和Survivor;大对象分区又可以分为:大对象头分区和大对象连续分区。

Humongous Heap Region专门用来存储大对象,只要大小超过了一个Region容量一半的对象即可判定为大对象。Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂,默认值为0。

超过了整个 Region 容量的超级大对象,将会被存放在N个连续的 Humongous Region 之中,G1 的大多数行为都把Humongous Region 作为老年代的一部分来进行看待。

卡片(Global Card Table)

有个场景,老年代的对象可能引用新生代的对象,那标记存活对象的时候,需要扫描老年代中的所有对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为GC Roots。那不是得又做全堆扫描?成本太高了吧。 HotSpot给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行Minor GC的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。

想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么Java虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。

卡表能用于减少老年代的全堆空间扫描,这能很大的提升GC效率。

以下是官方描述:

Future: G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS). Comparing G1 with CMS, there are differences that make G1 a better solution. One difference is that G1 is a compacting collector. G1 compacts sufficiently to completely avoid the use of fine-grained free lists for allocation, and instead relies on regions. This considerably simplifies parts of the collector, and mostly eliminates potential fragmentation issues. Also, G1 offers more predictable garbage collection pauses than the CMS collector, and allows users to specify desired pause targets.

RSet(记忆集合)

每一个 Region 都会划出一部分内存用来储存记录其他 Region 对当前持有 Rset Region 中 Card 的引用,这个记录就叫做 Remember Set。RSet 的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。 在这里插入图片描述

CSet(收集集合)

也叫Collection Set,这个set中装着需要被回收的region,在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。

三色标记法 要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象。GC 垃圾回收器其主要的目的是为了实现内存的回收,在这个过程中主要的两个步骤就是:内存标记,内存回收,三色标记法,主要是为了高效的标记可被回收的内存块。

三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

三色标记过程

假设现在有白、灰、黑三个集合(表示当前对象的颜色),初始时,所有对象都在 【白色集合】中 在这里插入图片描述

然后将GC Roots 直接引用到的对象 挪到 【灰色集合】中 在这里插入图片描述

从灰色集合中获取对象,将本对象 引用到的 其他对象 全部挪到 【灰色集合】中,将本对象 挪到 【黑色集合】里面

在这里插入图片描述

重复步骤3,直至【灰色集合】为空时结束 在这里插入图片描述

结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收 在这里插入图片描述

当Stop The World (以下简称 STW)时,对象间的引用 是不会发生变化的,可以轻松完成标记。

而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。

多标记-浮动垃圾

在这里插入图片描述

如上图所示,对象 E/F/G 是“应该”被回收的。但是这个时候应用程序把 E 的引用断开了,可是因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。

这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除

针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

漏标记 浮动垃圾会在下一轮回收被清除,但是如果是漏标的话可能影响程序的正确性,比如以下 在这里插入图片描述

如上图,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

灰色对象 断开了 白色对象的引用(直接或间接的引用);即灰色对象 原来成员变量的引用 发生了变化。 黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了 新的引用。 从代码角度:

var G = objE.fieldG; // 1.读
objE.fieldG = null;  // 2.写
objD.fieldG = G;     // 3.写
  • 读取 对象E的成员变量fieldG的引用值,即对象G
  • 对象E 往其成员变量fieldG,写入 null值
  • 对象D 往其成员变量fieldG,写入 对象G

我们只要在上面这三步中的任意一步中做一些“手脚”,将对象G记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),该集合的对象 遍历即可(重新标记)

重新标记通常是需要STW的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记STW的时间,这个是优化问题了。

写屏障用于拦截第二和第三步;而读屏障则是拦截第一步,它们的拦截的目的很简单:就是在读写前后,将对象G给记录下来。

写屏障(Store Barrier)

给某个对象的成员变量赋值时,其底层代码大概长这样: 在这里插入图片描述

所谓的写屏障,其实就是指在赋值操作前后,加入一些处理: 在这里插入图片描述

SATB(Snapshot-At-The-Beginning)

STAB(Snapshot At The Beginning,初始快照)是将并发标记阶段开始时对象间的引用关系,以逻辑快照的形式保存起来。标记过程中新生成的对象是“已完成扫描和标记”的,其子对象不会被标记。那如何区分是标记过程中新生成的对象呢?初始标记阶段记录的 nextTAMS 和 当前 top 之间的对象,所以并不需要专门为新生成的对象创建标记位图。

还有个很重要的问题,在并发标记过程中,对象的域发生了写操作怎么办?此时必须以某种方式记录被改写之前的引用关系。

G1GC 使用SATB 专用写屏障。在一个对象的域发生写操作时,这个对象会被放入 SATB 本地队列(SATB 本地队列满后,会被添加到全局的 SATB 队列结合)。在并发标记阶段,GC 线程会定期检查 SATB 队列集合的大小,对队列中的全部对象进行标记和扫描。如果获取到已经被标记的对象,这些对象不会再次被标记和扫描。

G1收集的运作过程

执行步骤

  • 始标记阶段:暂停应用程序,标记可由根直接引用的对象。
  • 并发标记阶段:与应用程序并发进行,扫描 1 中标记的对象所引用的对象。
  • 最终标记阶段:暂停应用程序,扫描 2 中没有标记的对象。本步骤结束后,- 堆内所有存活对象都会被标记。
  • 存活对象计数:对每个区域中被标记的对象进行计数,并发执行。
  • 收尾工作:暂停应用程序,收尾工作,并为下次标记做准备。

初始标记阶段

在这里插入图片描述

在初始标记阶段,GC 线程首先创建标记位图 next。其中 nextTAMS 是标记开始时,top 所在的位置。位图的大小也和 top 对齐,是 (top-botton)/8 字节。

等所有区域的标记位图都创建完成后,标记由根直接引用的对象(根扫描)。此时是需要暂停应用程序的,这是为了防止扫描过程中根被修改。

如果一个对象本身被标记,但是子对象没有被扫描,我们称之为未扫描对象,上图用灰色标识,C 持有子对象 A 和 E,但是 A 和 E 并未被扫描。

并发标记阶段

在并发标记阶段,GC 线程扫描在 1 阶段标记过的对象,完成对大部分存活对象的标记。

在这里插入图片描述

上图表示并发标记结束的状态,对象 C 的子对象 A 和 E 都被标记了。E 对应了标记位图中多个位,只有起始的标记位(mark bit)会被涂成黑色。

因为并发标记是和应用程序并发执行的,所以在这个阶段可能会产生的对象,上图中 J 和 K 就是在并发标记期间新创建的对象,直接会被 GC 当成存活对象。

同时因为是并发执行,应用程序可能会改变了对象之间的引用关系,需要使用写屏障技术来记录对象间引用关系的变化。并发标记阶段也会标记和扫描被写屏障感知变化的对象。

最终标记阶段

主要扫描 SATB 本地队列(队里里仍然存放了待扫描对象)。因为 SATB 本地队列会被应用程序操作,所以需要暂停应用程序。 在这里插入图片描述

上图中 SATB 本地队列中还有对象 G 和 H 的引用,扫描后对象 G 和 H,以及对象 H 的子对象 I 都会变成黑色。

存活对象计数

扫描各个区域的标记位图 next,统计区域内存活对象的字节数,存到区域内的 next_marked_bytes 中。下图中存活对象 A、C、E、G、H 和 I,一共 6 个对象,其中 E 真实大小是 16 个字节,其余 5 个对象分别是 8 个字节,所以 next_marked_bytes 是 56 个字节。 在这里插入图片描述

在计数的过程中,又新创建了对象 L 和 M,nextTAMS 和 top 之间的对象都会被当做存活对象处理,没有特意进行计数。

收尾工作

收尾工作所操作的数据中有些是和应用程序共享的,所以需要暂停应用程序。

收尾阶段主要做了两件事情:

GC 线程逐个扫描每个区域,将标记位图 next 的并发标记结果移动到标记位图 prev 中,再重置标记,为下次并发做准备。 在扫描过程中,计算每个区域的转移效率,并按照该效率对区域进行降序排序。 在这里插入图片描述

上图中 prevTAMS 被移动到了 nextTAMS 原来的位置,表示“上次并发标记开始时 top 的位置”。next.next_marked_bytes 也会被重置,同时 nextTAMS 移动到 bottom 的位置,其会在下次并发标记开始时,移动到 top 的最新位置。

G1的垃圾收集模式

G1中有两种回收模式:

完全年轻代GC(fully-young collection),也称年轻代垃圾回收(Young GC)2.部分年轻代GC(partially-young collection)又称混合垃圾回收(Mixed GC) 关于垃圾回收可以看我另一篇文章:地址