JVM基础 (6) -- G1 收集器详解

1. 简介

G1 的主要关注点:在达到可控的停顿时间的基础上尽可能提高吞吐量,这一点非常重要。

它可以指定: 在任意 xx 毫秒的时间范围内, STW 停顿不得超过 x 毫秒。 如: 任意 1 秒暂停时间不得超过 5 毫秒。Garbage-First GC 会尽力达成这个目标(有很大的概率会满足,但并不完全确定,具体是多少将是硬实时的[hard real-time])

G1 被设计用来长期取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间。
如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择。

JDK 1.9 开始,G1 替换了 CMS,成为 Hotspot 虚拟机的默认垃圾收集器

2. 总览

首先是内存划分上,之前介绍的分代收集器将整个堆分为年轻代、老年代和永久代(存在于堆之外),每个代的空间是确定的。
在这里插入图片描述
而 G1 将整个堆划分为一个个大小相等的小堆区(每一块称为一个 Region),每个小堆区的内存都是连续的。和分代算法一样,G1 中每个小堆区也会充当 Eden、Survivor、Old 三种角色之一,但它们不是固定的,这使得内存使用更加地灵活。
这样的划分使得 GC 不必每次都去收集整个堆空间, 而是以增量的方式来处理:每次只处理一部分小堆区,称为此次的回收集(collection set)。 每次暂停都会收集所有年轻代的小堆区,但可能只包含一部分老年代小堆区。
在这里插入图片描述

整个堆被分为约 2000 个小堆区,每个小堆区大小一致 (1MB~32MB)

H 表示大的对象。当分配的对象大于等于 Region 大小的一半的时候就会被认为是巨型对象。H 对象默认分配在老年代,可以防止 GC 的时候大对象的内存拷贝。如果发现堆内存容不下 H 对象,会触发一次 Full GC 。
GC 时,和 CMS 一样,G1 收集线程在标记阶段和应用程序线程并发执行,并且 G1 会在并发标记周期 (具体来说应该是清除垃圾阶段)估算每个小堆区存活对象的总数。
构建回收集(collection set)的原则是:垃圾最多的小堆区会被优先收集。这也是 G1 (Garbage-First) 名称的由来。

3. 参数

要启用 G1 收集器, 使用的命令行参数为:java -XX:+UseG1GC com.mypackages.MyExecutableClass
指定期望的停顿时间:java -XX:MaxGCPauseMillis=200
整堆使用达到这个比例后,触发并发并发标记周期,默认 45%:java -XX:InitiatingHeapOccupancyPercent=45
老年代/年轻代,默认值 2,即 1/3 的年轻代,2/3 的老年代:java -XX:NewRatio=n

不要设置年轻代为固定大小,否则 G1 不再需要满足我们的停顿时间目标,也不能再按需扩容或缩容年轻代大小

Eden/Survivor,默认值 8,这个和其他分代收集器是一样的:java -XX:SurvivorRatio=n
从年轻代晋升到老年代的年龄阈值,也是和其他分代收集器一样的:java -XX:MaxTenuringThreshold =n
并行收集时候的垃圾收集线程数:java -XX:ParallelGCThreads=n
并发标记阶段的垃圾收集线程数:java -XX:ConcGCThreads=n

增加这个值可以让并发标记更快完成,如果没有指定这个值,JVM 会通过以下公式计算得到:ConcGCThreads=(ParallelGCThreads + 2) / 4^3

堆内存的预留空间百分比,默认 10,用于降低晋升失败的风险,即默认地会将 10% 的堆内存预留下来:java -XX:G1ReservePercent=n每一个 Region 的大小,默认值为根据堆大小计算出来,取值 1MB~32MB,这个我们通常指定整堆大小就好了:java -XX:G1HeapRegionSize=n

4. G1 处理跨代引用

具体了解它
在进行 Minor GC 的时候,年轻代的对象可能还存在老年代的引用, 这就是跨代引用的问题。为了避免 Minor GC 的时候,扫描整个老年代,G1 引入了 Card Table 和 Remember Set 的概念,基本思想就是用空间换时间。这两个数据结构是专门用来处理 老年代到年轻代的引用。年轻代到老年代的引用则不需要单独处理,因为年轻代中的对象本身变化比较大,没必要浪费空间去记录下来。

  1. RSet:全称 Remembered Sets, 用来记录外部指向本 Region 的所有引用,每个 Region 维护一个 RSet。
  2. Card: JVM 将内存划分成了固定大小的 Card。这里可以类比物理内存上 page 的概念。

下图展示的是 RSet 与 Card 的关系。每个 Region 被分成了多个 Card,其中绿色部分的 Card 表示该 Card 中有对象引用了其他 Card 中的对象,这种引用关系用蓝色实线表示。RSet 其实是一个 HashTable,Key 是 Region 的起始地址,Value 是 Card Table (字节数组),字节数组下标表示 Card 的空间地址,当该地址空间被引用的时候会被标记为 dirty_card。
在这里插入图片描述

5. SATB 算法

G1 的并发标记阶段使用了 Snapshot-At-The-Beginning (开始时快照) 的方式,即在标记阶段开始时记下所有的存活对象 (需要注意的是,在标记期间可能有一些变成了垃圾,也就是浮动垃圾)。
通过对象是存活的信息,可以构建出每个小堆区的存活状态,以便回收集能高效地进行选择。

1. 三色标记算法

CMS 在并发标记阶段也是采用这个算法,不过 CMS 在三色标记的基础上使用了 Incremental Update,这个东西有很大的问题,而 G1 在三色标记的基础上使用了 SATB。
在这里插入图片描述

  1. 黑色:根对象,或者该对象与它的子对象都被扫描。
  2. 灰色:对象本身被扫描,但还没扫描完该对象中的子对象。
  3. 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象。

在 GC 扫描 C 之前的颜色如下:
在这里插入图片描述
在并发标记阶段,用户线程改变了这种引用关系:A.c=C;B.c=null
得到如下结果:
在这里插入图片描述
在重新标记阶段扫描结果如下:
在这里插入图片描述
这种情况下 C 会被当做垃圾进行回收。Snapshot 的存活对象原来是 A、B、C,现在变成了 A、B,Snapshot 的完整性遭到了破坏,显然这个做法是不合理的。
G1 采用的是 pre-write barrier 解决这个问题。简单说就是在并发标记阶段,当引用关系发生变化的时候,通过 pre-write barrier 函数会将旧的引用值(也就是 C 的引用值)记下来并保存在一个队列里,在 JVM 源码中这个队列叫 satb_mark_queue。在 remark 阶段会扫描这个队列,通过这种方式,旧的引用所指向的对象就会被标记上,其子孙也会被递归标记上,这样就不会漏记任何对象,snapshot 的完整性也就得到了保证。

当然,很可能有的对象在 snapshot 中是活的,但随着并发 GC 的进行它可能已经死了,但 SATB 还是会让它活过这次 GC,这些对象就是浮动垃圾,他们只能等待下一次 GC 时才能被回收掉。
CMS 的 incremental update 设计使得它在 remark 阶段必须重新扫描所有线程栈和整个年轻代作为 root;G1 的 SATB 设计在 remark 阶段则只需要扫描剩下的 satb_mark_queue ,解决了 CMS 垃圾收集器重新标记阶段长时间 STW 的潜在风险。

2. TAMS 指针

在 GC 过程中新分配的对象都当做是活的,其他不可达的对象就是死的。如何知道哪些对象是 GC 开始之后新分配的呢?G1 在 Region 中通过 top-at-mark-start (TAMS) 指针来解决这个问题,分别使用 prevTAMSnextTAMS 来记录新分配的对象。示意图如下:
在这里插入图片描述
每个 Region 记录着两个 top-at-mark-start (TAMS) 指针,分别为 prevTAMSnextTAMS。在 TAMS 以上的对象就是新分配的,因而被视为隐式 marked。
G1 的 concurrent marking 用了两个 bitmap:

  1. 一个 prevBitmap 记录第 n-1 轮 concurrent marking 所得的对象存活状态。由于第 n-1 轮 concurrent marking 已经完成,所以这个 bitmap 的信息可以直接使用。
  2. 一个 nextBitmap 记录第 n 轮 concurrent marking 的结果。这个 bitmap 是当前将要或正在进行的 concurrent marking 的结果,尚未完成,所以还不能使用。

其中 top 是该 Region 的当前分配指针,[bottom, top) 是当前该 Region 已用的部分,[top, end) 是尚未使用的可分配空间。

  1. [bottom, prevTAMS):这部分里的对象存活信息可以通过 prevBitmap 来得知。
  2. [prevTAMS, nextTAMS):这部分里的对象在第 n-1 轮 concurrent marking 是隐式存活的。
  3. [nextTAMS, top):这部分里的对象在第 n 轮 concurrent marking 是隐式存活的。

6. 与 ParallelOld 、CMS 的比较

G1 比起 ParallelOld 和 CMS 需要消耗更多的内存,因为 G1 有部分内存消耗于簿记(accounting)上,如以下两个数据结构:

  1. Remembered Sets:每个小堆区都有一个 RSet,用于记录进入该区块的对象引用(如区块 A 中的对象引用了区块 B,区块 B 的 Rset 需要记录这个信息),它用于实现收集过程的并行化以及使得区块能进行独立收集。总体上 Remembered Sets 消耗的内存小于 5%。
  2. Collection Sets:将要被回收的小堆区集合。GC 时,在这些小堆区中的对象会被复制到其他的小堆区中,总体上 Collection Sets 消耗的内存小于 1%。

7. G1 收集器工作过程

1. 年轻代收集

就是 Minor GC,只不过是在一个个等大小的小堆区上进行 GC。
年轻代 GC 回收的是所有年轻代的 Region。当 E 区不能再分配新的对象时就会触发。
在这里插入图片描述

年轻代由几个不连续的区块组成,这样需要的时候可以很容易地扩容、缩容。
为了下一次 Minor GC ,G1 会基于历史的 Minor GC 统计信息和用户定义的停顿时间,动态地调整 Eden 区和 Survivor 区的大小。

2. 并发标记周期

并发标记伴随着多次 Minor GC。
大概可以分为五个阶段:

  1. 初始标记 (initial mark,STW):伴随着一次 Minor GC,它标记了所有从 GC Root 开始直接可达的对象。对于该阶段,在 CMS 中需要一次 STW 暂停,但 G1 里面通常是在 Minor GC 暂停的同时处理这些事情,因而开销非常小,没有额外的、单独的暂停阶段。
  2. 扫描根引用区 (Root Region Scan):因为先进行了一次 Minor GC,所以当前年轻代只有 Survivor 区有存活对象,它被称为根引用区。扫描 Survivor 区到老年代的引用,该阶段必须在下一次 Minor GC 发生前结束。因为在并发标记的过程中迁移对象会造成很多麻烦,所以这个阶段不能发生年轻代收集,如果中途 Eden 区真的满了,也要等待这个阶段结束才能进行 Minor GC。
  3. 并发标记 (Concurrent Marking):扫描并标记整个堆中所有存活的对象,GC 线程与用户线程同时执行,并且收集各个 Region 的存活对象信息。使用 pre-write barrier 来记录那些发生变化的旧引用的值。
  4. 最终标记 (Remark,STW):扫描剩下的 satb_mark_queue,标记那些在并发标记阶段发生变化的对象。
  5. 清除垃圾 (Cleanup,部分STW):这个阶段是为后面的混合回收周期做准备的,该阶段会统计小堆区中所有存活的对象,并将小堆区进行排序,以提升 GC 的效率。如果发现完全没有活对象的 Region,就会将其整体回收到可分配 Region 列表中,清除空 Region。这个阶段有一部分是并发的,例如空堆区的回收,还有大部分用于存活率计算,这部分需要一个短暂的 STW,以不受用户线程的影响。

3. 混合回收周期

Evacuation 阶段是全线暂停的,不仅进行年轻代垃圾收集,而且回收之前标记出来的老年代的垃圾最多的部分小堆区。
它负责把一部分 Region 里的活对象拷贝到空 Region 里去(并行拷贝),然后回收原本的 Region 的空间。Evacuation 阶段可以自由选择任意多个 Region 来独立收集以构成回收集(collection set,简称 CSet),CSet 中 Region 的选定依赖于停顿预测模型,该阶段并不拷贝所有包含活对象的 Region,只选择收益高的少量 Region 来拷贝,这种暂停的开销是(在一定范围内)可控的。
在这里插入图片描述
首先,我们应该把老年代 GC 当做并发标记周期来理解,虽然它确实会释放出一些内存。
并发标记结束后,G1 也就知道了哪些区块是最适合被回收的,那些完全空闲的小堆区(没有存活对象的小堆区)会在这个阶段被回收掉。如果这个阶段释放了足够的内存出来,其实也就可以认为结束了一次 GC。
我们假设并发标记结束了,那么下次 GC 的时候,还是会先回收年轻代,如果从年轻代中得到了足够的内存,那么结束 GC;过了几次后,年轻代垃圾收集不能满足需要了,那么就需要利用之前并发标记的结果,选择一些活跃度最低的老年代区块进行回收。直到最后,老年代会进入下一个并发周期。
那么什么时候会启动并发标记周期呢?这个是通过参数控制的,此参数默认值是 45,也就是说当堆空间使用了 45% 后,G1 就会进入并发标记周期。

8. 注意 Full GC

Mixed GC 包括了上面说的并发标记周期和混合回收周期。
G1 的垃圾回收过程是和应用程序并发执行的,当 Mixed GC 的速度赶不上应用程序申请内存的速度的时候,Mixed GC 就会降级到 Full GC,使用的是 Serial GC,也就是说 Full GC 是单线程的,Full GC 会导致长时间的 STW,应该要尽量避免。

导致 G1 Full GC 的原因:

  1. concurrent mode failure:并发模式失败,CMS 收集器也有同样的概念。G1 并发标记期间,如果在标记结束前,老年代被填满,G1 会放弃标记。

堆需要增加了,或者需要调整并发周期,如增加并发标记的线程数量,让并发标记尽快结束,或者就是更早地进行并发周期,默认是整堆内存的 45% 被占用时就开始进行并发周期。

  1. 晋升失败:并发标记周期结束后,是混合回收周期,伴随着年轻代垃圾收集,进行清理老年代空间,如果这个时候清理的速度小于消耗的速度,导致老年代不够用,那么会发生晋升失败。

说明混合垃圾回收需要更迅速完成垃圾收集,也就是说在混合回收阶段,每次年轻代的收集应该处理更多的老年代已标记区块。

  1. 疏散失败:年轻代垃圾收集的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象。这种情况肯定是非常致命的,因为基本上已经没有多少空间可以用了,这个时候会触发 Full GC 也是很合理的。

最简单的就是增加堆大小

  1. 大对象分配失败:我们应该尽可能地不创建大对象,尤其是大于一个小堆区大小的那种对象。

9. 其他垃圾收集器

Java 11 的时候还推出了 ZGC,但目前 G1 还是主流,ZGC 处于一个试用的阶段,不过经过测试,ZGC 在性能上相比 G1 有了大幅度的提升,将来随着 JDK 11 成为主流版本,ZGC 也将会替换 G1 成为主流垃圾收集器。

JDK 11 是 Oracle 公司在 2018 年发布的一个 长期版本的 JDK

  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值