写点什么

JVM G1GC 的算法与实现

用户头像
Yano
关注
发布于: 2021 年 03 月 23 日

G1GC 是什么?

G1GC(Garbage First Garbage Collection)是在 OpenJDK 7 中引入的 GC 算法,其最大的特点就是非常重视实时性

一些基本概念

实时性

程序具有实时性,是指程序必须能在最后期限(deadline)之前完成,其中最后期限可以自由指定。实时性分为两种:

  • 硬实时性(hard real-time):每次处理的时间都不能超过最后期限,比如医疗机器人控制系统、航空管制系统。

  • 软实时性(soft real-time):稍微超出几次最后期限也没有什么问题的系统,例如网络银行系统。

G1GC 具有软实时性,为了实现软实时性,必须具备以下功能:

  • 设置期望暂停时间(最后期限)

  • 可预测性:预测下次 GC 会导致应用程序暂停多长时间。根据预测出的结果,G1GC 会通过延迟执行 GC拆分 GC 目标对象等手段来遵守上面设置的期望暂停时间。

G1GC 有什么特点?

Java 中已经有很多种 GC 算法了,为什么还要增加 G1GC 算法呢?

  • 以往的 GC 都是尽可能缩短最大暂停时间,但是缩短最大暂停时间很容易导致吞吐量下降。

  • 以往的 GC 无法预测暂停时间,GC 时可能会使应用程序长时间暂停的风险。

  • G1GC 的目的就是高效地实现软实时性,能够让用户设置期望暂停时间。在确保吞吐量比以往的 GC 更好的前提下,实现了软实时性。

  • G1GC 能最大程度利用服务器上多处理器的优势,而且在处理巨大的堆时,也不会降低 GC 的性能。

G1GC 的堆结构是什么样的?

G1GC 堆的内部被划分为大小相等的区域,所有区域排成一排。G1GC 以区域为单位进行 GC。用户可以随意设置区域大小,但是内部会将用户设置的值向上调整为 2 的指数幂,并以该正数作为区域的大小(如下图)。

图 1.1

G1GC 的执行过程是什么样的?

  • 并发标记(concurrent marking):和应用程序并发执行,针对区域内所有的存活对象进行标记。

  • 转移(evacuation):释放堆中死亡对象所占的内存空间。

白色区域是空闲区域,灰色区域是使用中的区域。

  • 左图表示的是在选中区域后开始将存活对象复制到空闲区域的操作

  • 右图表示的是转移后堆的状态。

为了方便演示,图中的区域以二维的方式排列,但是在内存中其实如下图是排列成一排的。

并发标记

并发标记是什么

简单标记,所有可从根直接触达的对象都会被添加标记。带标记的是存活对象,不带标记的是死亡对象。

在并发标记中,存活对象的标记和应用程序几乎是并发进行的,步骤更加复杂。并发标记并不是直接在对象上添加标记,而是在标记位图上添加标记。

标记位图

下图表示堆中的一个区域,位图中黑色表示已标记,白色表示未标记。

每个区域有两个标记位图:

  • next:本次标记的标记位图。

  • prev:上次标记的标记位图,保存了上次标记的结果。

标记位图中的每个比特都对应关联区域内的对象的开头部分。图中区域部分:

  • bottom:区域内众多对象的末尾

  • top:区域中对象的开头

  • nextTAMS:本次标记开始时的 top(TAMS-Top At Marking Start)

  • prevTAMS:上次标记开始时的 top

执行步骤

  1. 初始标记阶段:暂停应用程序,标记可由根直接引用的对象。

  2. 并发标记阶段:与应用程序并发进行,扫描 1 中标记的对象所引用的对象。

  3. 最终标记阶段:暂停应用程序,扫描 2 中没有标记的对象。本步骤结束后,堆内所有存活对象都会被标记。

  4. 存活对象计数:对每个区域中被标记的对象进行计数,并发执行。

  5. 收尾工作:暂停应用程序,收尾工作,并为下次标记做准备。

步骤 1——初始标记阶段

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

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

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

步骤 2——并发标记阶段

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

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

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

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

STAB

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

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

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

步骤 3——最终标记阶段

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

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

步骤 4——存活对象计数

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

存活对象计数结束后区域的状态

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

步骤 5——收尾工作

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

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

  • GC 线程逐个扫描每个区域,将标记位图 next 的并发标记结果移动到标记位图 prev 中,再重置标记,为下次并发做准备。

  • 在扫描过程中,计算每个区域的转移效率,并按照该效率对区域进行降序排序。

收尾工作完成后区域的状态

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

转移效率

指转移 1 个字节所需的时间。通俗理解就是,区域内死亡对象越多,存活对象就越少;而存活对象越少,那么转移所需的时间就越少。

计算公式为:死亡对象的字节数 / 转移所需时间

并发标记总结

并发标记结束后,可以得到:

  • 并发标记完成时,存活对象和死亡对象的区分(此时在标记位图 prev)

  • 存活对象的字节数(prev_marked_bytes)

如果新的对象是在并发标记结束后被创建的,因为新对象是分配在 prevTAMS 和 top 之间的,所以后被当成存活对象处理。

转移

转移是什么?

将所选区域内的所有存活对象都转移到空闲区域,因此被转移区域就只剩下死亡对象。重置之后,该区域就会成为空闲区域。

转移专用记忆集合

上节介绍的SATB 队列集合是记录标记过程中对象之间引用关系的变化,这里的转移专用记忆集合记录区域间的引用关系,这样不用扫描所有区域的对象,也能查到待转移对象所占区域内的对象被其他区域引用的情况。

G1GC 是通过卡表(card table)来实现转移专用记忆集合的。

卡表

是元素大小为 1B 的数组,堆中大小适当的一段存储空间(通常是 512B)对应卡表中的 1 个元素。在堆大小是 1GB 时,卡表大小为 2MB。

卡表的构造

堆中对象所对应的卡片在卡表的索引值 = (对象的地址 - 堆的头部地址) / 512

因为卡片的大小是 1B,所有可以表示很多状态,状态有很多,在后面只介绍两种:

  • 净卡片

  • 脏卡片

转移专用记忆集合的构造

转移专用记忆集合的构造

每个区域都有一个转移专用记忆集合,是通过散列表实现的:

  • 键:引用本区域的其他区域的地址

  • 值:数组,数组元素是引用方的对象所对应的卡片索引

在上图中,区域 B 中的对象 b 引用了区域 A 中的对象 a。因为对象 b 不是区域 A 中的对象,所以必须记录这个引用关系。在转移记忆集合 A 中,以区域 B 的地址为键记录了卡片的索引 2048(对象 b 对应的卡片索引),此时对象 b 对对象 a 的引用被准确记录了下来。

转移专用写屏障

那 GC 是如何感知域的变化呢?是通过转移专用写屏障,当对象修改时,会被转移专用写屏障记录到转移专用记忆集合中。

每个应用程序线程都持有一个转移专用记忆集合日志的缓冲区,其中存放的是卡片索引的数组。当对象 b 的域被修改时,写屏障就会感知,并会将对象 b 所对应的卡片索引添加到转移专用记忆集合日志中。

转移专用记忆集合日志及其集合

转移专用记忆集合维护线程

是和应用程序并发执行的线程,是基于上述日志维护转移专用记忆集合。主要步骤:

  • 从转移专用记忆集合日志的集合中取出转移专用记忆集合日志,从头开始扫描

  • 将卡片变为净卡片

  • 检查卡片所对应存储空间内的所有对象的域

  • 向域中地址所指向的区域的记忆集合中添加卡片

热卡片

频繁发生修改的存储空间所对应的卡片就是热卡片。热卡片可能会多次进入转移专用记忆集合日志,被多次处理成脏卡片,增加维护线程的负担。

可以通过卡片计数器,发现热卡片,当某个卡片变成脏卡片的次数超过阈值,可以等到转移的时候再处理。

转移的执行步骤

  • 选择回收集合:参考并发标记提供的信息,选择要转移的区域。

  • 根转移:将回收集合内由根直接引用的对象,及被其他区域引用的对象转移到空闲区域中。

  • 转移:以根转移的对象为起点,扫描子孙对象,将所有存活对象一并转移。此时回收集合内所有存活对象都转移完成了。

步骤 1——选择回收集合

选择待回收区域的标准:

  • 转移效率要高

  • 转移的预测停顿时间在用户的容忍范围内

在并发标记阶段结束时,堆中区域已经按照转移效率降序了。这里就是按照排好的顺序依次计算各个区域内的预测暂停时间,当所有已选区域预测的暂停时间和快要超过用户的容忍范围时,后续区域的选择就会停止,当前所选的区域就是 1 个回收集合。

步骤 2——根转移

根转移的对象包括:

  • 由根直接引用的对象

  • 并发标记处理中的对象

  • 由其他区域对象直接引用的回收集合内的对象

对象转移

  1. 对象 a 转移到空闲区域。

  2. 对象 a 在空闲区域中的新地址写入到转移前所在区域中的旧位置。

  3. 将对象 a 引用的所有位于回收集合内的对象,都添加到转移队列中。转移队列临时保存待转移对象的引用方。因为对象 a 引用了对象 b,两个都是要转移的对象,地址都会变化。

  4. 针对对象 a 引用的位于回收集合外的对象,更新转移专用记忆集合。对象 c 所在区域不在回收集合内,但是区域 C 的转移专用记忆集合记录了 a 对应的卡片,在 a 转移之后,需要更新区域 C 的转移专用记忆集合。

  5. 针对对象 a 的引用方,更新转移专用记忆集合。

步骤 3——转移

完成根转移后,被转移队列引用的对象会依次转移。当转移队列清空后,转移就完成了。此时回收集合内所有存活对象都转移完成了。

分代 G1GC 模式

G1GC 有 2 中模式:

  • 纯 G1GC 模式:pure garbage-first mode

  • 分代 G1GC 模式:generational garbage-first mode

本文上面讲的都是纯 G1GC 模式。

两种 GC 的区别

和纯 G1GC 模式相比,分代 G1GC 模式主要有以下两个不同点。

  • 区域是分代的

  • 回收集合的选择是分代的

在分代 G1GC 模式中,区域被分为新生代区域老年代区域两类。 和其他分代 GC 算法一样,分代 G1GC 的对象也保存了自身在各次转移中存活下来的次数。新生代区域用来存放新生代对象,老年代区域用来存放老年代对象。

G1GC 中新生代 GC 是完全新生代 GC,老年代 GC 是部分新生代 GC。二者区别在于完全新生代 GC 将所有新生代区域选入回收集合,而部分新生代 GC 将所有新生代区域,以及一部分老年代区域选入回收集合。

新生代区域

新生代区域可以进一步分为两类:

  • 创建区域:存放刚刚生成,一次也没有转移过的对象

  • 存活区域:存放至少转移过一次的对象

转移专用写屏障不会应用在新生代区域的对象上。为什么这样做是可以的呢?因为转移专用记忆集合维护的是区域之间的引用关系,所以在转移时不用扫描整个区域就能找到待转移对象所在区域的存活对象。而在分代 G1GC 模式中,所有新生代区域都会被选入回收集合,所有对象的引用都会被检查,这些信息就没有记录在转移专用记忆集合中了。

分代对象转移

存活对象保存了自己被转移的次数,这个次数就是对象的年龄

  • 年龄<阈值:转移到存活区域

  • 年龄>=阈值:转移到老年代区域

执行过程

完全新生代 GC 的执行过程

如上图,完全新生代 GC 不会选择老年代区域,而是将所有新生代区域都选入回收集合,然后统一转移回收集合的对象。晋升的对象会被转移到老年代区域,其余的转移到存活区域。

部分新生代 GC 的执行过程

如上图,部分新生代 GC 除了所有新生代区域外,还会选择一些老年代区域进入回收集合。其余都和完全新生代 GC 一样。

GC 的切换

如果新生代的区域数太多,可能导致 GC 暂停时间上限的增加,无法保证软实时性。分代 G1GC 模式需要计算出合理的最大新生代区域。该值的设置是在并发标记结束后。

参考并发标记中标记出的死亡对象个数,预测出下次部分新 生代 GC 的转移效率。然后,根据过去的完全新生代 GC 的转移效率, 预测出下次完全新生代 GC 的转移效率。如果预测出完全新生代 GC 的 转移效率更高,则切换为完全新生代 GC。

GC 的执行时机

当新生代区域数达到上限时,会触发转移的执行。,当转移完成并通过以下 4 项检查,会执行并发标记:

  • 不在并发标记执行过程中

  • 并发标记的结果已被上次转移使用完

  • 已经使用了一定量的堆内存

  • 相比上次转移完成后,堆内存的使用量有所增加

G1 算法总结

关系图

图中并列的箭头表示可能会并行执行。

优点

  • 软实时性

  • 充分发挥高配置机器的性能,缩减 GC 暂停时间

  • 区域内不会产生内存碎片

缺点

  • 被限定为“搭载多核处理器、拥有大容量内存的机器”,适用受限。

  • 尽管区域内不会出现碎片化,但是会出现以区域为单位(整个堆)的碎片化。

参考

《深入 Java 虚拟机-JVM G1GC 的算法与实现》

GitHub LeetCode 项目

Java 编程思想-最全思维导图-GitHub 下载链接,需要的小伙伴可以自取,欢迎 star 和交流~


发布于: 2021 年 03 月 23 日阅读数: 21
用户头像

Yano

关注

还未添加个人签名 2017.12.06 加入

码农一枚,专注于各种敲键盘😝

评论

发布
暂无评论
JVM G1GC的算法与实现