深入浅出 JVM 之垃圾回收 - G1垃圾回收器

深入浅出 JVM 之垃圾回收 - G1垃圾回收器

文章目录

  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。

系统环境:

  • JDK 1.8

参考地址:

深入浅出 JVM 系列文章

一、三色标记法概念

1.1 三色标记法是什么

在之前介绍过 "可达性分析" 这一概念,可达性分析是一种判断 JVM 堆内存中对象是否存活的一种算法,而这里要介绍的三色标记法则可以认为是可达性分析的并发版本。

在使用可达性分析标记对象过程中,会频繁触发 Stop The World (STW) 造成应用线程停顿,这无疑会对应用的性能造成很大的影响。并且现在部署的应用,动辄就 8GB 或者 16GB 内存空间,在这种内存空间非常大、对象数量非常多的环境下,触发 STW 后对存活的对象进行标记的过程是非常缓慢的,所以就有了三色标记法,用于解决在并发环境下尽可能的减少 STW 停顿的发生,加快 GC 扫描标记对象的过程。

三色标记法顾名思义,就是使用 "黑"、"灰"、"白" 三种颜色,从 GC Roots 出发,对堆内存中的对象进行扫描与标记,等扫描完成后再通过一些手段解决 "漏标" 和 "多标" 的对象,然后将没有被标记的对象进行回收。

1.2 黑灰白三种颜色的定义

在三色标记法中对象的标记可以分为 "黑"、"灰"、"白" 三种颜色,并且不同颜色的标记有着不同的定义,分别是:

  • 黑色: 当前对象和其所引用的对象,都已经被 GC 扫描过,这时候该对象会被标记为黑色;
  • 灰色: 当前对象已经被 GC 扫描过,但是其所引用的对象还没有被扫描,这时候该对象会被标记为灰色;
  • 白色: 当前对象和其所引用的对象都没有被 GC 扫描过,所以当前对象可能是一个不可达 (已死亡) 的对象;

1.3 三色标记算法缺陷

使用三色标记法标记对象的主要有点就是,执行 GC 时 GC 线程和应用线程可以并行执行,从而避免触发 STW 造成应用线程停顿,但是也正因为在标记对象期间 GC 线程和应用线程可以并行执行,这就可能会导致发生 "多标" 和 "漏标" 的问题,比如:

多标问题

如下图所示,存在 A、B、C 三个对象,其中对象间的引用关系为 A->B->C,在经过 GC 扫描后:

  • A 对象被标记为黑色;
  • B 对象被标记为灰色;
  • C 对象没有被标记所以为白色;

这时候由于在三色标记法中,GC 线程和应用线程可以并行执行,如果应用线程删除掉了 A->B 的引用关系,那么正常来说 B 和 C 对象应该是已死亡对象,不应该被标记,但是由于 B 对象已经被标记为灰色,由于灰色对象的特殊性,GC 会对 B 对象进行二次扫描,所以 B 和 C 对象在经过二次扫描后被标记为了黑色,这就造成了 "多标" 问题。

漏标问题

如下图所示,存在 A、B、C、D 四个对象,其中对象间的引用关系为 A->B->C 和 A->B->D,在经过 GC 扫描后:

  • A 对象被标记为黑色;
  • B 对象被标记为灰色;
  • C 对象没有被标记所以为白色;
  • D 对象没有被标记所以为白色;

这时候由于在三色标记法中,GC 线程和应用线程可以并行执行,如果应用线程增加了 A->D 的引用关系,并且删除掉了 B->D 的引用关系,这时只有 A 对象引用了 D 对象,不过由于 A 对象已经被标记为了黑色,GC 不会对黑色对象进行二次扫描,所以到扫描结束,D 对象始终不会被扫描到,所以一直为白色,在 GC 执行清理时就会被清除掉。但是事实情况是存在着 A->D 的引用关,所以 D 对象是一个存活的对象,不应该被清除掉,这就造成了 "漏标" 问题。

1.4 三色标记法缺陷的解决办法

在多标和漏标俩个问题中,其实多标问题影响并不大,因为在执行 GC 回收垃圾对象时,如果本该回收过程中多标的对象没有被回收,那么大不了下次再执行 GC 扫描时不再标记该对象,将其定义成一个不可达的对象,使 GC 对其进行回收即可。

但是,漏标问题影响就比较严重了,该问题会直接影响应用程序的正常运行。试想一下,本来是一个可达的存活对象,但是因为漏标导致其被定义成一个不可达的对象,致使其被 GC 给回收掉了,这是在 GC 垃圾回收过程中不可接受的。所以,漏标问题是使用三色标记法实现的 GC 重点需要解决的问题。

经研究表明,只有同时满足以下两个条件时才会发生漏标问题:

  • ① 至少有一个对象被标记为黑色,然后这个黑色对象又与一个白色对象建立了引用关系;
  • ② 引用该白色对象的一个或多个灰色对象,在 GC 扫描完成前,这些灰色对象都删除了与该白色对象的引用关系;

上面俩个条件可能让人理解起来不是很清晰,所以下面将使用俩个场景图来描述上面俩个条件,如下:

知道了当满足上面所述的俩个条件时就会发生漏标问题,那么思考一下,如果将这俩个条件中的任意一条破坏掉是不是就可以解决漏标问题了?答案是肯定的,只要破坏其中任意一条就可以解决漏标问题,由此分别产生了两种常用的解决方案,分别是屏障机制 (Incremental Update) 和 原始快照 (Snapshot At The Beginning, SATB)。

  • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为 GC Root,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
  • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将记录过的引用关系中的灰色对象作为 GC Root,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

而 Java 团队也是使用了 增量更新原始快照 结合 屏障机制 的方式来解漏标问题的。比如,在 CMS 垃圾回收器中采用的就是 写屏障 + 增量更新 方式来解决漏标问题的,而在 G1 垃圾回收器中采用的是 写屏障 + 原始快照 的方式解决的。关于 写屏障 的概念,后面内容中会对其进行介绍。

二、记忆集与卡表

2.1 为什么需要记忆集

现在大部分 Java 虚拟机中的垃圾回收设计方案都是基于分代理论,将对象存放在不同的分代区域中。比如,将新创建的对象存放于新生代,将长期存活的对象放置到老年代。采用分代理论设计的垃圾回收方案可以极大的提升回收效率,不过这种方案也存在着一些问题,比如对象跨代引用问题。

跨代引用问题简单来说,就是在新生代中的一些对象,其可能引用了老年代中的对象,这就导致 GC 在进行垃圾回收时,为了确认老年代中的对象是否仍然存活,最笨的办法就是遍历整个老年代,找出存在跨代引用的对象。不过这种做法存在极大的性能浪费,仅仅为了找到一些存在跨代引用的一小部分对象是不值得这么做的,所以为了避免这种性能开销,通常常见的分代垃圾回收器会引入一种称为 记忆集 的技术。

2.2 什么是记忆集

记忆集 (Remembered Set,简称 Rset) 是一种用于记录从非回收区域指向回收区域指针集合的数据结构。如果我们不考虑效率和成本的话,记忆集数据结构最简单的实现,可以用非回收区域中所有含跨代引用对象的数组来实现,如下代码所示:

1Class RememberedSet {
2    Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
3}

在代码中创建了一个存储对象的数组,用于记录全部含跨代引用对象,这种实现方案的实现非常简单,但是这种方案所占用的空间会非常大,及维护成本非常高,所以这种方案并不是一种理想方案。

而且,在实际情况中垃圾回收的场景下,回收器只需要通过记忆集判断出某一块非回收区域是否存在有指向了回收区域的指针,就可以判断是否存在跨代引用,并不需要了解这些跨代指针的具体细节。那设计者在实现记忆集的时候,便可以选择更为粗粒度的记录方式来实现,以便节省记忆集的存储空间和维护成本。

记忆集有多种不同粗细粒度的实现方案,比如:

  • 字长精度: 每个记录精确到一个机器字长,该字包含跨代指针。
  • 对象精度: 每个记录精确到对象,该对象里有字段含跨代指针。
  • 卡精度: 每个记录精确到一块内存区域,该内存区域有对象含有跨代指针。

2.3 记忆集和卡表的关系

刚刚在上面提到过,记忆集中不同粒度的实现方案大致可以分为 字长精度对象精度卡精度 三种,其中 卡精度 这种方案所指的是,使用一种称为 "卡表" (Card Table) 的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。

前面介绍记忆集的时候也提到过,记忆集其实是一种抽象的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。而卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度与堆内存的映射关系等。两者关系简单理解来说就像 Java 中的 Map 与 HashMap 的关系,Map 是接口定义,而 HashMap 是具体实现。

2.4 什么是卡表

卡表其实是一种特殊的记忆集,最简单的实现可以使用一个字节数组来记录那些存在跨代引用的对象,如在 HotSpot 虚拟机中就是使用字节数组来实现的卡表,如下代码所示:

1CARD_TABLE [this address >> 9] = 0

在上面示例代码中,字节数组 CARD_TABLE 中的每一个元素,都相应对应着其标识的内存区域中的一块特定大小的内存块,这个内存块被称作为 "卡页" (Card Page)。

一般来说卡页大小都是 2N 次幂的字节数,通上面的 HotSpot 虚拟机中的代码可以看出,其规定的卡页大小是 29 次幂,即 512 字节 (地址右移 9 位,相当于用地址除以 512)。所以,如果卡表标识内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 012 号元素分别对应了地址范围为 0x0000~0x01FF0x0200~0x03FF0x0400~0x05FF 的卡页内存块,如下图所示:

一个卡页的内存中通常包含着多个对象,只要其中一个对象中的字段存在着跨代指针,那就将对象所在卡页对应的卡表中的数组元素值标识为 1,表示这个元素变脏 (Dirty),如果元素对应的卡页中不存在跨代指针的对象,那么该卡表元素值将一直保持为默认值 0

在垃圾回收进行时,回收器只要筛选出卡表元素值为 1,即变脏的元素,就能轻易得出哪些卡页内存块中包含着跨代指针,之后只需要将内存块中的对象加入 GC Roots 中一并扫描,就可以使用较低的成本解决跨代引用问题。

2.5 卡表与卡页示例

假如我们现在要对新生代区域进行垃圾回收,那么就可以把老年代区域看成是一个卡页一个卡页划分好的,如下图所示:

如上图所示,从不同的角度分析有不同的看法,比如:

  • 从卡表角度分析,因为卡页 page1page4 中存在被新生代实例对象所引用的对象,所以卡表对应的第 14 的位置标记为 1,表明这些卡页变脏 (Dirty)。
  • 从内存回收角度分析,因为卡表的第 14 的位置被标记为 1,表明卡表该位置对应的 page 区域中存在跨代应用的对象,垃圾回收的时候需要扫描该区域。

三、写屏障概念

3.1 为什么需要写屏障

在上面提到过可以使用卡表以较低的成本解决跨代引用问题,但其中并没有提及卡表元素如何维护问题,比如说卡表中的元素何时变脏、谁来把它们变脏等。

关于卡表元素什么时候变脏这个问题,在介绍卡表时已经提到过,只要其它分代区域中的对象引用了本区域中对象时,对应的卡表元素就应该被变脏 (Dirty),变脏的时间点原则上应该和发生变动对象中引用类型字段赋值的时刻一致,但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表?

假如是在解释执行的字节码中,那相对比较好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间,但如果在编译执行的场景中如何处理呢?因为经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段来解决,把维护卡表的动作放到每一个赋值操作之中方可,于是就必须提及写屏障 (Write Barrier) 这个概念,并且在 HotSpot 虚拟机中就是使用的写屏障来维护的卡表状态的。

3.2 什么是写屏障

写屏障其实可以看作是在虚拟机层面中对引用类型字段前后赋值的动作,就类似于 Spring 框架中的 AOP 的 Around 类型 Advice 一样,可以在代码前后嵌入指定动作。也就是说,可以在对字段赋值的代码前后,指定统一动作来维护卡表中元素的状态。

而且 "写屏障" 也分为 "写前屏障" (Pre-Write Barrier) 和写后屏障 (Post-Write Barrier),在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障。在 G1 回收器出现之前的回收器都只用到了写后屏障,只有在 G1 及以后的 ZGC 等回收器使用到了写前和写后俩个屏障。

"屏障" 这个词汇在很多地方都有这个概念,比如延迟回收器中会提到的 "读屏障",解决并发乱序执行问题中的 "内存屏障" 等,这里的写屏障的概念要和它们区分开来,避免混淆。

下面是一段更新卡表状态的写后屏障的简化代码逻辑:

1void oop_field_store(oop* field, oop new_value) {
2    // 写前屏障,SATB 使用,在这里记录旧对象及对象的引用
3    pre_write_barrier(field);
4    // 引用字段进行赋值 
5    // (意思是当前对象中的当前字段发生变动,引用了一个新的对象)
6    *field = new_value;
7    // 写后屏障,在这里完成卡表状态更新
8    post_write_barrier(field, new_value);
9}

虚拟机在应用写屏障后,就会为所有赋值操作生成相应的指令。不过,一旦回收器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次都会对引用进行更新,这样做就会造成一些额外的性能开销,不过这个开销与 Minor GC 执行时扫描整个老年代的代价相比较,还是低得多的。

四、G1 中的分区

4.1 划分多 Region 区域

G1 回收器使用了一种名为 Region (分区) 的概念,将整个堆空间在逻辑上划分为多个大小相等的 Region (默认情况下会划分为 2048 个区域)。每个 Region 都是连续的一段内存区域,整体被控制在 1M - 32M 之间,且为 2 的 N 次幂 (即 1M、2M、4M、8M、16M 或 32M),具体大小根据堆的实际大小而定,比如可以通过调整 JVM 参数 -XX:G1HeapRegionSize 来手动指定每个 Region 大小,如果没有配置该参数的话 JVM 就会根据实际情况动态决定。

而且,每个 Region 扮演者不同的角色,有的可以作为新生代中的 Eden 区或 Survivor 区,的可以作为老年代的 Old 区,还有的可以作为存储大对象的 Humongous 区等等,这些区域可以被垃圾回收器进行精细化的管理和回收,从而避免了全局垃圾回收所带来的长时间停顿。

4.2 不同分代区域的占比

我们知道在 G1 之前的回收器如 ParNew、Parallel 或者 CMS 等,新生代和老年代的默认比例是 1:2,但是使用 G1 回收器后有所改变。在 G1 中默认新生代对堆内存的初始占比是 5%,这个比例值可以通过 JVM 参数 -XX:G1NewSizePercent 来设置初始占比,不过一般情况下无需设置。

新生代的大小不是固定的,而是随着应用程序的运行动态变化的,G1 GC 会根据堆内存的使用情况和应用程序的运行情况,动态地调整新生代 Region 的数量,以达到更好的垃圾回收效果,但是最多新生代的占比不会超过 60%,这个可以通过 JVM 参数 -XX:G1MaxNewSizePercent 来设置。

4.3 新生代晋升老年代规则

G1 回收器对象晋升的规则与其他垃圾回收器有些不同。在 G1 中,每个 Region 都有一个年龄计数器,用来记录对象在新生代中存活的时间。当一个对象在 Eden 区被第一次分配时,它的年龄为 1,每次经过一次垃圾回收后如果对象没有被回收,就使对象年龄加 1。当一个对象的年龄达到一定阈值时,就会被晋升到老年代中。

G1 回收器中的晋升规则有以下几点:

  • 晋升阈值: G1 回收器中可以通过参数 -XX:MaxTenuringThreshold 来设置晋升年龄的最大值,缺省值为 15。如果对象的年龄达到了该值,就会被晋升到老年代中。
  • 空间占用: 如果一个 Region 中的对象占用的空间大于该 region 的 50%,即一半后,那么这个 Region 中的对象也会被晋升到老年代中。
  • 晋升失败: 如果老年代没有足够的空间来存储晋升过来的对象,那么这些对象就会被存放在一个特殊的 ToSpace 区域中,待下一次垃圾回收时再进行处理。

不过 G1 GC 中的晋升规则是动态变化的,会根据当前堆内存使用情况和 G1 GC 算法的运行状态等因素来调整晋升的策略,以达到更好的垃圾回收效果。

4.4 对大对象的处理

在 G1 回收器中,如果一个对象超过了一个 Region 大小的 50% 就会被认定为是大对象,由于大对象的分配和回收比小对象更加耗时,因此 G1 对大对象采取了特殊的处理方式,并不是直接存入老年代中,而是将其存入专门用于存储大对象的 Humongous 区域。

G1 中的 Humongous 区域是专门用于存储超大对象的内存区域,因为当一个对象太大时,无法完整地被存储在一个 Region 中,因此 G1 会将它们存储在一个或多个 Humongous 区域中。

其次,在回收大对象时,G1 会先进行并发标记阶段,标记所有存活的对象,然后再进行整理阶段。在整理阶段,G1 会将存活的大对象移到 Humongous 区域的头部,然后清空原来的空间。由于 Humongous 区域是被多个 Region 共享的,因此 G1 需要在移动大对象时更新指向该对象的所有引用,以确保对象移动后仍然可以正确访问。

需要注意的是,G1 对大对象的处理方式可能会引起内存碎片的问题。如果 Humongous 区域中的大对象在回收后无法被完全清空,那么这些空间就会成为内存碎片,影响后续的内存分配。因此,如果应用程序中有大量的大对象,建议采用更合适的垃圾回收策略,或者调整 Humongous 区域的大小,以便更好地管理内存碎片。

4.5 注重吞吐量也兼顾停顿时间

G1 回收器是一个既注重吞吐量,同时也兼顾重停顿时间的回收器。其采用了分代垃圾回收的思想,同时也利用了可预测的停顿时间来保证垃圾回收的效率。

G1 回收器与其他垃圾回收器不同的是,其并不是按照固定的分代比例来进行垃圾回收的,而是根据应用程序的实际运行情况来动态地调整垃圾回收策略,这使得 G1 回收器的停顿时间是可预测的,可以在可控制的停顿时间内进行垃圾回收,从而避免了应用程序出现长时间的停顿,提高了应用程序的响应速度。

除此之外,G1 垃圾收集器提供了一系列的配置参数,可以让用户根据应用程序的实际情况来调整垃圾回收器的性能表现。同时,G1 垃圾收集器也支持并发处理,可以在多个 CPU 核心上并行执行垃圾回收任务,提高了垃圾回收器的并发性能。

五、G1 中的几个阶段

5.1 G1 阶段简单概述

在 G1 垃圾回收器中,回收的执行过程大概可以划分为四个阶段,分别是 Young GC (新生代回收)、Globle Concurrent Marking (全局并发标记)、Mixed GC (混合回收) 以及 Full GC (整堆回收)。

5.2 Young GC (新生代回收)

Young GC 指的是新生代垃圾回收,也就是我们常说的 Minor GC,该回收模式下仅对新生代 Region (分区) 中的对象进行回收操作。

在常见的其它 GC 回收器中的新生代,大部分都是会划分 Eden 区和 Survivor 区,而在 G1 垃圾回收器中也是这样,不过由于 G1 中的堆空间由许多 Region 构成,新生代同样如此。并且,新生代中的 Rregion 有的会设置为归属于 Eden 区,有的会设置为归属于 Survivor 区,当其中归属于 Eden 的 Region 空间满了后,就会触发一次 Young GC,对整个新生代中的 Region 进行清理。

Young GC 执行时会触发 STW 造成应用线程停顿,但是停顿的最大时长不会超过指定的最大停顿时间。而且,新生代回收逻辑相对而言比较简单,且回收效率比较高,所以在大部分情况下新生代回收实际耗时通常低于最大停顿时间。

除此之外,在 G1 回收器中存在 CSet (回收集合),其主要作用就是记录可以被回收的 Region,在 Young GC 执行开始时,就会将新生代中所有的 Region 加入到 Cset 中,表示这些空间需要被回收掉。

再之后的 Young GC 执行过程和其它 GC 执行过程比较类似,即从 GC Roots 开始执行扫描,并且会将其直接引用的对象复制到 Survivor 区,然后对这些对象及引用的其它对象进行深度遍历扫描即可。

不仅如此,还存在一种特殊情况,即老年代中对象引用新生代中对象的这种问题 (跨代引用问题),而 G1 回收器为了解决这种情况,设置了 RSet (记忆集) 来处理,在新生代中的每个 Region 中都会存在一个 RSet,其会记录哪些老年代 Region 中的对象对该新生代 Region 中的对象有引用关系。有了 RSet 后我们在判断新生代对象是否被老年代对象所引用时,只需要将 RSet 中记录的指定 Region 卡页中的对象作为 GC Roots,将其同样复制到 Survivor 区中,然后对该对象及其引用执行深度遍历扫描即可。

等到整个新生代中的对象都被扫描完成后,将被标记为存活的对象复制到新的 Region 中,复制完成后会在新的 Region 中重构记忆集 RSet,并且将新的 Region 设置为属于 Survivor 区,再之后就会将存在于 CSet 中的 Region 空间释放掉,即把这些 Region 放入空闲列表 (Free List)。

在 Young GC 执行到最后,还会根据配置的 GCTimeRatio 和 G1ExpandByPercentOfAvailable 参数进行判断是否扩展新生代内存空间,如果满足扩展条件,就会尝试根据 GC 执行时间和期望的停顿时间,来对下次 GC 发生时能接受的最大分区 Region 数量进行预测,根据预测结果来调整新生代 Region 的数量,这样就可以实现动态调整新生代空间大小。

5.3 Globle Concurrent Marking (全局并发标记)

http://www.itsharecircle.com/articles/150

Globle Concurrent Marking (全局并发标记) 的流程主要是将 Mixed GC 时要收集的垃圾对象先进行标记,然后根据 Region 中存活对象占用空间大小进行排序,同时其会在标记的最后阶段直接将没有存活对象的 Region 加入到空闲列表 FreeList,表示该 Region 可以用于对象分配。等到全局并发标记指向完成后,GC 会判断 CSet 中的 Region 占总空间的比例进行判断是否触发 Mixed GC (混合回收)。

并发标记阶段可细分为 初始标记阶段根分区扫描并发标记阶段最终标记阶段清理阶段 五个阶段,接下来将对每个阶段进行简介。

初始标记阶段 (Initial Mark)

初始标记 (Initial Mark) 主要用于将并发标记过程中使用到的数据进行初始化、重置,以及标记与 GC Roots 直接关联根对象,扫描根对象所在的 Region,扫描完成后就能确定这些 Region 中的 bottom 和 top 指针所在位置,然后使用 nextTAMS 指针记录 top 指针所在位置。

除此之外还要说明的是,由于根对象的数量并不是很多,所以初始标记过程执行速度非常快,而且为了保证标记的正确性,会触发 STW 使应用线程停顿。

不过有一点需要说明,初始标记是在 Young GC 过程中执行的,利用 Young GC 的 STW 时间段完成初始标记。但是并不是每次 Young GC 过程都会执行初始标记,只有当老年代空间占用率达到指定的阈值,即 -XX:InitiatingHeapOccupancyPercent 配置的值时 (默认45%) 才会开启全局并发标记,下次执行 Young GC 结束前才会开始执行初始标记。

根分区扫描 (Root Region Scanning)

在初始标记结束后,Young GC 也完成了将存活对象复制到 Survivor 分区的工作,这时会使应用线程从停顿状态恢复继续运行。

除此之外,还会将新生代中属于 Survivor 区的所有非空 Region 作为根分区 (Root Region),然后使用多线程循环对根分区中的全部的 Region 进行扫描,扫描每个 Region 中处于 bottom 到 top 位置区间的对象,将这些对象标记为根,这样这些对象就成为了根对象 (GC Root)。而对这些根对象扫描的过程,常被称之为根分区扫描 (Root Region Scanning)。

不过需要注意的是,根分区扫描必须在下一次 Young GC 启动前完成,因为并发标记过程依赖于新生代中的 Survivor,且在并发标记的过程中可能会执行若干次 Young GC,每次执行完后就会产生一批新的存活对象存入 Survivor 区,这样就造成不能准确地标记对象。所以,在全局并发标记时,一定会要求完成 Survivor 分区扫描后才能再进行一次新的 YGC。

并发标记阶段 (Concurrent Marking)

并发标记 (Concurrent Marking) 阶段,根据名字中的"并发"俩字也能猜出该阶段是多个线程并发执行的,且应用线程和 GC 线程可以并行执行,所以在该阶段中不会触发 STW 造成应用线程停顿。

并发标记阶段执行时会启动多个线程并发的进行扫描标记,从堆的起始位置开始扫描标记各个 Region,标记 Region 中存活对象,期间会使用 SATB (snapshot-at-the-beginning,原始快照) 结合写前屏障 (pre-write-barrier) 机制解决漏标问题。

并发标记阶段主要会执行以下操作:

  • 处理 SATB 中 prevTAMS 至 nextTAMS 指针之间对象的标记位图,扫描标记对象中的引用字段,递归对引用的对象进行标记;
  • 并发标记指向期间引用发生变化的对象会通过写前屏障机制加入到 SATB 缓冲区 (队列),然后定期检查和处理 STAB 缓冲区中的记录,更新对象引用信息;
  • 计算存活对象 (被标记对象) 的数量,计算占用的字节数,并计入分区空间;

注: 由于该阶段中由于并发标记应用线程和 GC 线程可以并行执行,期间可能新增对象或者改变对象的引用关系,G1 回收器采用 SATB 机制处理并发标记过程中发生的问题:
  ● 在 SATB 中会将并发标记期间新创建的对象存放到特定位置。
  ● 引用发生变化的对象 SATB 中会采用写前屏障 (pre-write-barrier) 将旧的对象(包含对其它对象的引用)记录到一个本地缓冲区 (队列) 中,并将该对象标记为灰色,保证该对象的存活。当本地队列满了后就会将本地缓冲区加入全局缓冲区 (队列),当全局缓冲区中的成员数量达到一定比例后就会将这些旧对象作为 GC Roots,从新扫描标记这些对象和对象的引用。

重新标记阶段 (Remark)

在并发标记阶段执行完成后,每个 Java 线程还会有一些剩下通过 SATB 写前屏障 (Pre Write Barrier) 记录到本地缓冲区 (队列) 中的对象的引用尚未处理,而重新标记 (Remark) 阶段就是负责处理这些对象。

在并发标记阶段中,会暂停所有应用线程从而避免发生引用更新,然后强行将 SATB 本地缓冲区加入到 SATB 全局缓冲区 (队列),然后将全局缓冲区中记录的对象作为 GC Roots 中的根对象,从新扫描与标记这些对象和对象的引用。

除此之外,该阶段还执行一些额外的清理操作,比如卸载不可达的类,处理引用对象 (弱引用、软引用、虚引用、最终引用) 等。

清理阶段 (Cleanup)

清理 (Cleanup) 阶段是全局并发标记中的末尾阶段,该阶段会使用单线程执行清理任务,过程主要包括:

  • 统计各个 Region 的信息,包括记录下一次 GC 时每个 Region 的期望空间使用率和已使用率、最近一次 GC 时的空间使用率和已使用率等,便于用于后续过程可以根据统计的数据分析制定回收计划;
  • 处理 SATB 标记位图,使 prevMarkBitMap 与 nextMarkBitMap 进行交互;
  • 执行一些清理工作为下一次全局并发标记做准备,比如:
    • 重置 SATB 的一些指针,使 prevTAMS 指针指向 nextTAMS 指针指向的位置,使 nextTAMS 指针指向 bottom 指针指向的位置;
    • 重置计数变量 next_marked_bytes 值为 0;
    • 重置记忆集 RSet;
    • ......(略, 其它的清理工作)
  • 回收完全没有存活对象的 Region,将 Region 加入到空闲列表中,表示该 Region 已经被回收,可以被重新使用;
  • 将部分回收价值高的老年代 Region 加入到回收集合选择器 (CSetChooser),这样在下次执行 Young GC 结束后,就有可能会将 CSetChooser 中的 Region 存入回收集合 CSet 中,然后进行存活对象转移与清理 Region 工作;

5.4 Mixed GC (混合回收)

在并发标记结束后,就可能会执行混合回收 (Mixed GC),为什么这里说的是可能呢?这主要是因为触发混合回收需要两个前提条件,分别是:

  • 全局并发标记已经执行完成;
  • 回收集合选择器 (CSetChooser) 中记录的可回收 Region 空间占总空间的比例大于参数 -XX:G1HeapWastePercent 指定的值,默认为 5%;

因此,在全局并发标记执行完成后会执行几次 Young GC,每次 Young GC 结束后都会判断上面的条件是否成立,如果满足条件则会执行混合回收,将 CSetChooser 中记录的 Region 加入到回收集合 CSet 中,然后将这些 Region 中的存活对象转移到新的空闲的老年代 Region 中,再对这些 Region 空间进行清理。并且 GC 会为了保证停顿时间,可能会将混合回收过程拆分为多次执行,每次只会清理部分 Region。

5.5 Full GC (整堆回收)

什么时候会触发 Full GC

当通过执行 Young GC 和 Mixed GC 回收空间后,剩余的堆空间不足导致新创建的对象分配失败时,G1 会尝试对失败进行处理,再次尝试进行分配,如果这次仍然分配失败,就会触发 Full GC 对整个堆空间进行清理。

串行 Full GC

在 JDK 10 之前的版本中触发 Full GC 时,会触发 STW 使暂停应用线程,然后使用单线程串行执行垃圾回收工作,整个扫描与回收过程非常耗时,而且只有完成清理任务后才会使应用线程恢复。

在 Full GC 执行串行回收之前需要做一些预处理工作,主要有停止并发标记、停止增量回收等。预处理执行完成后,将对整个堆空间中的 Region 中的对象进行扫描与标记,然后计算存活对象需要转移的新位置,之后复制存活对象到计算好的新位置,最后清理掉各个 Region 中没有被标记的对象。

除了以上步骤外,Full GC 执行到最后还会尝试对整个堆空间的大小进行调整,以及遍历堆中的 Region 重构 RSet,调整新生代空间大小等操作。

并行 Full GC

在 Java 运行过程中需要极力避免 Full GC 发生的,但是由于 JVM 的不可控,加上应用长时间运行过程中对象的积累,所以 Full GC 基本上不可避免。因此,如何对 JVM 进行调优,从而避免发生 Full GC 是每个程序员努力的目标之一。

上面说过在 JDK10 版本前的 Full GC 发生之后都是串行执行回收,执行过程基本上和之前的 Serial GC 类似,执行的效率非常差。不过好在 JVM 团队在 JDK10 版本中提出了 JEP 307,其中提到了在 G1 回收器中 Full GC 单线程执行性能问题,推荐 G1 的 Full GC 使用多线程并行执行来改善 G1 执行时的最坏情况的等待时间。于是,在 JDK10 版本中移除了串行的 Full GC 改成了并行。

不管是串行的还是并行的 Full GC,其执行过程都比较类似,只不过有些步骤由串行改成了多线程并行执行。比如,并行标记存活对象,并行计算存活对象新地址,并行调整指针与复制对象等等。

针对 Full GC 的建议

Full GC 是所有 G1 回收器调优人员应尽力避免发生的情况,其会对整个堆中的对象进行扫描标记与回收,所以执行过程将不再受最长停顿时间约束,一旦出现此情况,意味着应用的响应时间将无限的变长。

当应用不定期进入 Full GC 状态时,与其任由它缓慢重塑堆内存空间,不如采用冗余策略,在流量低谷时刻逐一重启应用,主动重塑堆内存空间。

六、G1 原始快照 SATB

6.1 什么是 SATB

原始快照 SATB (Snapshot At The Beginning) 可以被认为是一种用于 JVM 垃圾回收中,为了维持并发 GC 正确性的一种快照机制。

在 G1 GC 执行之前,会对堆内存中的部分区域 (Region) 进行一次并发标记,并且在并发标记期间认为,只要当前时间内仍然存活的对象,那么在本次 GC 执行过程中,这些对象都是存活状态的,即使在执行过程中这些对象的引用关系发生了变化,也不会回收这些对象,而只是将发生变化的对象通过写前屏障 (Pre Write Barrier) 的方式,将对象的引用关系记录下来,等到下一次 GC 执行时再进行清理。

除此之外,并发标记执行过过程中,也会记录在这期间新增的对象,这些新增的对象也会被标记为存活对象,它们被称为"隐式对象"。

所以,原始快照 SATB 机制就像给指定区域中的对象进行了一份快照,执行时只回收快照那一瞬间没有被标记的对象。

6.2 为什么需要 SATB

在上面介绍三色标记法时已经提到过,使用三色标记法并发标记对象过程中可能发生多标和漏标的问题,多标问题影响不是很大,可以在下次 GC 执行时解决多标的对象,而漏标问题则非常验证,直接影响着应用的正常运行,是必须要解决的。而在上面也提到,解决漏标问题常用的有两种办法,即 "增量更新" 和 "原始快照"。

而在 G1 垃圾回收器中,就是通过使用原始快照 (Snapshot At The Beginning,简称 SATB) 结合写前屏障机制 (Pre Write Barrier) 的方式来解决对象漏标问题,并且还解决了并发标记期间新创建的对象如何控制等问题。

6.3 SATB 中的概念

在 SATB 中定义了如下几个概念:

  • bottom: 指某个 Region 区域中,所有被扫描对象的最低地址的指针。
  • top: 指某个 Region 区域中,所有被扫描对象的最高地址的指针。
  • nextTAMS: nextTAMS 指针表示的是本次开始标记前 top 位置的位置,该指针会随着标记的进行会不断移动。
  • preTAMS: preTAMS 指针表示的是上次完成标记标记的位置,也是上次标记结束时 top 指针的位置。
  • prevMarkBitmap: prevMarkBitmap (prev-top-at-mark-start) 用于记录上次标记的位图,保存上次标记的结果 (对象的存活状态),由于上次标记已经完成,所以里面的标记结果可以应用于当前 GC 回收。
  • nextMarkBitmap: nextMarkBitmap (next-top-at-mark-start) 用于记录本次标记的位图,用于保存本次标记的结果 (对象的存活状态),由于本次标记尚未完成,所以里面的标记结果还不能应用于当前 GC 回收。

6.4 G1 SATB 写前屏障机制

SATB 中采用了写前屏障机制 (Pre-Write-Barrier) 来解决并发标记过中对象漏标问题。写前屏障有点类似大家耳熟能详的 Spring AOP 机制中的前置 Advice,在改变对象的引用关系前会先执行 Pre-Write-Barrier 中的代码逻辑,将当前对象所引用的对象记录下来,最后再进行处理。

类似于下面代码:

1void oop_field_store(oop* field, oop new_value) {
2    // 写前屏障,SATB 使用,在这里记录旧对象及对象的引用
3    pre_write_barrier(field);
4    // 引用字段赋值操作
5    *field = new_value;
6}

在 HotSpot 虚拟机中,写前屏障码逻辑执行时会将引用关系发生改变的对象中所引用的对象,加入到当前执行线程的本地队列 SATBMarkQueue 中,当队列满了或者达到某一阶段后,就会使当前本地队列加入到全局队列集合 SATBMarkQueueSet 中,然后再给线程一个新的本地队列使用。

并发标记过程中会定期检查全局 SATBMarkQueueSet 队列集合的大小,当队列集合中的队列数量达到一定阈值,或者到了某一阶段后,就会执行以这些引用发生变化的旧对象为 GC Root,重新进行扫描操作。

6.5 概述 SATB 执行过程

假设第 n 轮并发标记开始,执行过程大体如下:

(1) 初始标记 (Initial Marking)

  • 初始标记会扫描 GC Roots 所引用对象所在的 Region,当初始标记扫描完成后,就能确定每个 Region 中 bottom 和 top 指针的位置。
  • 使 bottom 指针指向 Region 中对象开头所在位置,top 指针指向 Region 中对象结尾所在位置。
  • 使 prevTAMS 指针指向 bottom 指针所记录的位置,nextTAMS 指针指向 top 指针记录的位置。


(2) 根分区扫描 (Root Region Scanning)

  • 使新生代 Survivor 区作为根分区 (Root Region),使其中的对象作为 GC Roots 中的根对象。

(3) 并发标记 (Concurrent Marking)

扫描标记每个分区中的对象

  • 从堆的起始位置开始扫描标记各个 Region,标记 Region 中存活的对象,这里选一个 Region 进行描述。
  • GC 开始执行扫描标记,从 GC Roots 开始扫描标记与 GC Roots 直接关联的对象。
  • GC 扫描过程中发现与 Roots 直接关联的对象有 C 和 G,按照三色标记法,将对象 C 和 G 标记为灰色。

继续扫描并标记

● GC 继续扫描与标记,将对象 C 和 G 标记为黑色,将对象 A、E 和 H 标记为灰色。

继续扫描并使用写前屏障记录引用产生变化旧对象

  • GC 继续扫描与标记,将对象 A 和 E 标记为黑色。
  • 由于并发标记过程中 GC 线程和应用线程并行执行,这时应用线程执行操作:
    • 使对象 G 引用了对象 J,H;
    • 使对象 H 删除了与对象 I 的引用关系;
  • 由于 SATB 中存在写前屏障 (Pre-Write-Barrier) 机制:
    • 当对象引用关系发生变化时就会触发,记录引用关系发生变化的对象,从而解决漏标问题;
    • 将引用发生变化的对象 G 新增的引用对象 J,以及对象 H 所删除掉引用关系的对象 I,记录到各自线程 SATBMarkQueue 队列中;

继续扫描并记录新创建的对象

● GC 继续扫描与标记,将对象 H 标记为黑色。 ● 由于对象 G 引用对象 J 之前已经被标记为黑色,所以 GC 不会对 G 对象的引用再次扫描,因此对象 J 保持未标记状态。 ● 在并发标记期间新创建的对象,会加入到 top 指针之后,并且使 top 指针后移,使新创建的对象处于 nextTAMS 到 top 之间。 ● 在 SATB 中会默认新创建的对象都是存活对象,被标记为黑色,这些对象被称之为 "隐式对象",隐式对象由于没有在 nextMarkBitMap 中创建位图,所以不会被记录到 nextMarkBitMap 中。


(3) 重新标记阶段 (Remark)

● 在最终标记过程中会强制将 SATBMarkQueue 队列加入到 SATBMarkQueueSet 集合 (即使这些队列未满也会强制加入到集合中)。 ● 开始对 SATBMarkQueueSet 集合中全部队列里的对象进行处理,扫描这些对象及对象的引用。 ● 扫描处于 SATBMarkQueue 队列中的对象 I 和 J,将其标记为灰色,确保在本次 GC 中不会被清理,但是由于对象 I 和 J 都没有引用其它对象,所以再将对象 I 和 J 标记为黑色。


(4) 清理阶段 (cleanup)

计算存活对象大小并回收空分区

● 统计 nextMarkBitMap 中已经标记的对象所占用的位图数量 (隐式对象不在统计范围内),然后计算对象的占用空间,方便 Mixed GC 执行时制定回收计划。 ● 如果发现完全没有活对象的 Region,就会将该 Region 加入到可分配 Region 列表中供使用。

交换 BitMap 并重置指针

● 将 nextMarkBitMap 和 prevMarkBitMap 进行交换。 ● 使 prevTAMS 指针指向 nextTAMS 指针所指向的位置,记录本次标记结束的位置。 ● 使 nextTAMS 指针指向 bottom 指针所指向的位置,表示本次标记结束。

七、G1 记忆集 RSet

7.1 G1 中的记忆集是什么

在上面介绍"记忆集与卡表"时提到过什么是记忆集 (RSet),在那里说过记忆集是用于解决跨代引用问题的一种方案,其是一种用于记录非回收区域指向回收区域指针集合的数据结构,这句话在 G1 回收器中其实并不完全适用。

因为,在 G1 回收器中的记忆集实现比较复杂,可以说是一个双向卡表,不单单是一种用于记录从非回收区域指向回收区域指针集合,而且还是一种用于记录从回收区域指向非回收区域指针集合。

7.2 为什么 G1 中记忆集实现更为复杂

在 G1 回收器中的记忆集实现比较复杂,这主要是因为在 G1 回收器的堆空间被划分成了若干个 Region,有的 Region 属于 Eden、Survivor,有的属于 Old、Humongous 等,如果这时候再使用在传统回收器中的"卡表"结构的话,就需要对整个堆进行扫描 (因为归属于老年代的 Region 是不是固定的,会动态变化),这显然会增大时间成本,这对于追求低延迟的垃圾回收器来说简直是不可忍受的。

所以,在 G1 回收器的设计者为了保证低延迟,采用了"空间换时间"的思想,重新设计记忆集结构,使记忆集结构变得比较复杂,但是却能够清楚地标明存在跨代指针的区域,在执行垃圾回收时可以直接扫描记忆集找到存在跨代指针的老年代所在 Region,从而避免了扫描整个堆空间。

7.3 Point Out 和 Point In 模式

一般记录引用关系常用有俩种方式,即记录谁引用了我或者是记录我引用了谁,而在记忆集中这也是类似如此,即:

  • Point In: Point In 记录模式就是用于标识谁引用了我,将引用了我的对象的位置给记下来;
  • Point Out: Point Out 记录模式就是用于记录我引用了谁,将我引用了的其它对象的位置给记下来;

这两者的区别在于处理有所不同,Point Out 记录操作简单,但是需要对 RSet 做全部扫描,而 Point In 则记录操作复杂,但是在标记扫描时直接可以找到有用和无用的对象,不需要额外的扫描,因为 RSet 里面的对象可以认为就是根对象。

7.4 G1 记忆集记录引用关系的范围

分区之间引用关系的类型

我们知道记忆集是用于记录分区之间的引用关系,总体可以分为:

  • 分区内部有引用关系;
  • 新生代分区到新生代分区之间有引用关系;
  • 新生代分区到老年代分区之间有引用关系;
  • 老年代分区到新生代分区之间有引用关系;
  • 老年代分区到老年代分区之间有引用关系;

分区之间的引用关系有这么多种类型,如果每个分区中的记忆集将这些引用关系都记录的话将,需要使用额外内存空间来存储这些数据,大概在 1%~20% 之间,所以在 G1 设计者限定了记忆集中记录的分区之间的引用关系。

不同分区之间引用关系

针对不同类型的分区直接的引用关系是否记录,分析如下:

分区内部有引用关系

无论是新生代分区还是老年代分区中的内部的引用,都无需记录引用关系,因为在进行垃圾回收时,是针对一个分区而言。即这个分区要么被回收,要么不回收,回收的时候会遍历整个分区,所以无需记录这种额外的引用关系。

新生代分区到新生代分区之间有引用关系

新生代分区到新生代分区之间有引用关系,这个无需记录,因为在 G1 执行过程中无论哪种回收模式,都会对新生代中的全部对象进行处理,新生代分区中的对象都会被遍历,所以无需记录新生代到新生代之间的引用。

新生代分区到老年代分区之间有引用关系

新生代分区到老年代分区之间有引用关系,这个无需记录,因为执行 Mixed GC 混合回收时,都会先执行一遍新生代回收,以新生代中的对象作为 GC Roots 开始执行扫描部分老年代分区,所以遍历新生代分区中对象的时候,自然能够通过新生代中对象的引用关系,找到老年代分区中的对象,所以记忆集中不需要记录这个引用。而且,对于 Full GC 来说更不需这个引用关系,因为 Full GC 执行时所有的分区都会被处理。

老年代分区到新生代分区之间有引用关系

老年代分区到新生代分区之间有引用关系,这个需要记录,因为存在在新生代分区中的对象被老年代分区中的对象所引用,在执行新生代垃圾回收时,如果记忆集中不记录的话,那么如果需要确定哪些对象被老年代所引用,就要遍历整个老年代,所以记忆集中需要记录年代分区到新生代分区之间有引用关系。

老年代分区到老年代分区之间有引用关系

老年代分区到老年代分区之间有引用关系,这个需要记录,因为在 Mixed GC 混合回收时,老年代中可能只有部分分区被扫描与回收,所以必须记录,便于再扫描当前老年代分区中的对象时,知道该对象被另一个老年代分区中的对象所引用,从而加快扫描标记速度,快速确认存活对象。

具体来分区之间的引用关系是否记录,汇总到表如下所示:

引用关系 是否需要 Rset
分区内部有引用关系 不需要
新生代分区到新生代分区之间有引用关系 不需要
新生代分区到老年代分区之间有引用关系 不需要
老年代分区到新生代分区之间有引用关系 需要
老年代分区到老年代分区之间有引用关系 需要

7.5 G1 记忆集的实现

集合 RSet

在 G1 垃圾回收器新生代中,每一个 Region 都会维护一个集合 RSet,记录一下老年代指进来的跨代引用 (Point-In模式),这样做的目的是提高跨待引用查找效率,查找跨代引用时只需要根据 RSet 中的记录,就可以知道有哪些老年代中的 Region 对当前正在执行扫描的新生代 Region 中的对象存在引用关系。所以,在 Young GC 在执行时,只需要将 Point-In 模式记录对象作为 GC Roots 进行扫描,就可以解决跨代引用问题。

不过需要注意的是,每次 Mixed GC 执行时只会回收部分年老代中的 Region,因此如果老年代中的 Region 如果不使用集合 RSet 的话,就会存在和新生代扫描时同样的问题,需要扫描整个年老代中的 Region,所以老年代中的 Region 也需要维护一个 Point-In 模式的集合,来记录其它老年代 Region 对其 Region 中有引用关系的对象,而对于新生代对老年代中 Region 的引用则可用忽略。

卡表 CardTable

谈起卡表 CardTable,其实在 G1 之前的 CMS 垃圾回收器中也有卡表结构。卡表本质上是一种 Point-Out 数据结构,表示某一区域自己有指向别的区域的引用。在 G1 中卡表由 byte 数组构成,数组中的每个元素称之为 CardPage (卡页/卡片)。卡表会映射到整个堆的空间,每个 CardPage 会对应堆中的 512B 空间。

如下图所示,在一个大小为 8GB 的堆中,那么卡表的长度为 16777215(8GB/512B),假设 -XX:G1HeapRegionSize=2MB,即每个 Region 大小为 2MB,则每个 Region 都会对应 4096 个 CardPage。卡表将占用 16MB 额外内存空间。

当要查找一个对象所在的 CardPage 时,只需要根据如下公式计算即可:

1CardPageIndex = (对象的地址-堆开始地址) / 512

RSet 具体实现

说完卡表,再来说一下 RSet 的具体实现,其实 RSet 实际是通过 HashMap 结构实现的,该 HashMap 的 key 是引用了当前 RSet 所在 Region 的其它 Region 的起始地址,而 value 则是一个数组,数组中的元素是引用方的对象所对应的 CardPage 在卡表中的下标位置 CardPage Index。

如上图所示,Region B 中的 对象Y 引用了 Region A 中的 对象X,这个引用关系跨了两个分区。所以在 Region A 的 RSet 中,以 Region B 的起始地址 作为 key,以 CardPage 下标 79 作为 value,这样就完成了这个跨区域引用的记录。不过这个 CardTable 的粒度有点粗,毕竟一个 CardPage 有 512B,在一个 CardPage 内可能会存在多个对象。所以在扫描标记时,需要扫描 RSet 中关联的整个 CardPage,上图的例子是要把 CardTable 下标为 79 的 CardPage 都扫描一遍。

实际上 HotSpot VM 中 G1 的 RSet 具体实现要比上面说的更加复杂 (上面说的只是其中的一种 Sparse 粒度的场景)。应用程序中可能存在频繁的更新引用情况,这会使得某些区域的 RSet 变成 popular Region。G1 采用不同粒度的方式来处理 RSet Popularity,RSet 可分为 Sparse、Fine、Coarse 三种粒度。

不同粒度时 RSet 内部采用不同的数据结构记录其它 Region 中 Point-In 模式进来的引用,上面介绍的便是 Sparse 粒度时的情况,下面是 《Evaluating and improving remembered sets in the HotSpot G1 garbage collector》 论文中给出的 G1 中 RSet 数据结构的简化定义。

1struct g1_rset {
2    hash_map<region_id>, card_list> sparse;
3    hash_map<region_id>, bitmap<MAX_CARD>> fine_grained;
4    bitmap<MAX_REGION> coarse;
5    // ...  functions omitted
6};

(1) Sparse Grained (上面 g1_rset 数据结构中的 saprse)

稀疏粒度情况时,采用 HashMap 实现,该 HashMap 的 key 是引用了当前 RSet 的所在 Region 的其它 Region 的起始地址,而 value 则是一个数组,数组中的元素是引用方的对象所对应的 CardPage 在卡表中的下标位置 CardPage Index。

(2) Fine Grained (上面 g1_rset 数据结构中的 fine_grained)

细粒度情况时,同样采用 HashMap 实现,该 HashMap 的 key 是引用了当前 RSet 所在 Region 的其它 Region 的起始地址,而 value 是一个位图,位图的最大位数代表一个 Region 最多能被拆分为多少 CardPage,位图上值为 1 则代表 Region 上 CardPage 内有对象引用了 RSet 所属 Region 的对象。

(3) Coarse Grained (上面 g1_rset 数据结构中的 coarse)

粗粒度情况时,采用位图实现,位图的最大位数代表整个 Heap 能被拆分为多少个 Region。位图上值为 1 则代表其他 Region 内有对象引用了 RSet 所属 Region 的对象。因为 Region 的大小是一样的,可以通过 Heap 的起始地址计算出位图中每个 Region 的起始地址。

Log Buff 机制

G1 通常利用 Refinement Threads 异步维护 RSet,每个线程会利用前面介绍的 post-write barrier 将跨代引用与老年代到老年代的引用记录到各自的 local log buffer 中,当 local log buffer 满了之后会刷新到全局的 Log Buffer 中,Refinement Threads 专门处理全局的 Log Buffer 来维护 RSet,当 Refinement Threads 不能有效地处理全局的 Log Buffer 时,应用线程将一起处理 log buffer,但这对应用线程的性能有损耗。当垃圾回收过程中如果全局的 Log Buffer 还未处理完,GC 线程将处这些 Log Buffer。

1void oop_field_store(oop* field, oop new_value) {
2  pre_write_barrier(field);             // 写前屏障: 也能够与保持引用正确性,避免漏标
3  *field = new_value;                   // 实际的引用
4  post_write_barrier(field, new_value); // 写后屏障: 用于追踪跨分区的引用
5}

八、G1 回收集 CSet

回收集 (Collection Set,CSet),其代表每次 GC 暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是新生代代回收,还是混合回收,工作的机制都是一致的。新生代回收 CSet 只容纳新生代分区,而混合回收会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到 CSet 中。

--- END ---


  !版权声明:本博客内容均为原创,每篇博文作为知识积累,写博不易,转载请注明出处。