写点什么

🏆「作者推荐」【JVM 原理探索】深入理解 G1 垃圾收集器的原理和运行机制

发布于: 21 小时前
🏆「作者推荐」【JVM原理探索】深入理解G1垃圾收集器的原理和运行机制

本文首先简单介绍了垃圾收集的常见方式,然后再分析了 G1 收集器的收集原理,相比其他垃圾收集器的优势,最后给出了一些调优实践。

什么是垃圾回收

首先,在了解 G1 之前,我们需要清楚的知道,垃圾回收是什么?简单的说垃圾回收就是回收内存中不再使用的对象

G1 收集器

G1 收集器(或者垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于 4GB)时产生的停顿相对于 CMS 的优势而言是内存碎片的产生率大大降低。

开启 G1 收集器的方式

-XX:+UseG1GC

G1 的发展原则

在 2012 年才在 jdk1.7u4 中可用。Oracle 官方计划在【jdk9】中将 G1 变成默认的垃圾收集器,以替代 CMS。为何 Oracle 要极力推荐 G1 呢,G1 有哪些优点?

首先,G1 的设计原则就是简单可行的性能调优

开发人员仅仅需要声明以下参数即可:


-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200


  • -XX:+UseG1GC为开启 G1 垃圾收集器

  • -Xmx32g:**设计堆内存的最大内存为 32G

  • XX:MaxGCPauseMillis=200设置 GC 的最大暂停时间为 200ms


如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。

其次,G1 将新生代,老年代的物理空间划分取消了

这样我们再也不用单独的空间对每个代进行设置了,不用担心每个代内存是否足够。取而代之的是,G1 算法将堆划分为若干个区域(Region),它仍然属于分代收集器



  • 这些区域的一部分包含新生代,新生代的垃圾收集依然采用暂停所有应用线程的方式(STW),将存活对象拷贝到老年代或者 Survivor 空间

  • 老年代也分成很多区域,G1 收集器通过将对象从一个区域复制到另外一个区域,完成了清理工作。

  • 这就意味着,在正常的处理过程中,G1 完成了堆的压缩(至少是部分堆的压缩),这样也就不会有 cms 内存碎片问题的存在了。



  • G1 中,有种特殊的区域,叫 Humongous 区域。 如果一个对象占用的空间超过了分区容量 50%以上,G1 收集器就认为这是一个巨型对象。

  • 这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响

  • 为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放巨型对象


如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC


在 java 8 中,持久代也移动到了普通的堆内存空间中,改为元空间。

对象分配策略

说起大对象的分配,我们不得不谈谈对象的分配策略。它分为 3 个阶段:


  • TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区

  • Eden 区中分配

  • Humongous 区分配




  • 如果对象在一个共享的空间中分配,需要采用一些同步机制来管理这些空间内的空闲空间指针

  • 在 Eden 空间中,每一个线程都有一个固定的分区用于分配对象,即一个 TLAB。分配对象时,线程之间不再需要进行任何的同步。

  • (-XX:+UseTLAB)TLAB 为线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来

  • 对 TLAB 空间中无法分配的对象,JVM 会尝试在 Eden 空间中进行分配。如果 Eden 空间无法容纳该对象,就只能在老年代中进行分配空间

  • G1 提供了两种 GC 模式,Young GC 和 Mixed GC,两种都是 Stop The World(STW)的。


下面我们将分别介绍一下这 2 种模式。

G1 Young GC

Young GC 主要是对 Eden 区进行 GC,它在 Eden 空间耗尽时会被触发


  1. 在这种情况下,Eden 空间的数据移动到 Survivor 空间中,如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到年老代空间

  2. Survivor 区的数据移动到新的 Survivor 区中,也有部分数据晋升到老年代空间中

  3. 最终 Eden 空间的数据为空,GC 停止工作,应用线程继续执行




问题 1:如果仅仅 GC 新生代对象,如何找到所有的根对象呢?老年代的所有对象都是根么?


G1 引进了 RSet 的概念。它的全称是 Remembered Set,作用是跟踪指向某个 heap 区内的对象引用



  • 在 CMS 中,也有 RSet 的概念,在[老年代]中有一块区域用来记录指向[新生代]的引用

  • 这是一种 point-out,在进行 Young GC 时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代

  • 但在 G1 中,并没有使用 point-out,这是由于一个分区太小,分区数量太多,如果是用 point-out 的话,会造成大量的扫描浪费(会存在重复定的扫描指针的数据块),有些根本不需要 GC 的分区引用也扫描了

  • 于是 G1 中使用 point-in 来解决。point-in 的意思是哪些分区引用了当前分区中的对象。


问题 2:根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?


  • 这是不必要的,原因在于每次 GC 时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可


如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这个问题,在 G1 中又引入了另外一个概念,卡表(Card Table)


  • 一个 Card Table 将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡表

  • 卡表通常较小,介于 128 到 512 字节之间


Card Table 通常为字节数组,由 Card 的索引(即数组下标)来标识每个分区的空间地址


默认情况下,每个卡表都未被引用。当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外 RSet 也将这个数组下标记录下来


一般情况下,这个 RSet 其实是一个 Hash Table,Key 是别的 Region 的起始地址,Value 是一个集合,里面的元素是 Card Table 的 Index

Young GC 阶段

  • 阶段 1:根扫描:静态和本地对象被扫描

  • 阶段 2:更新 RS:处理 dirty card 队列更新 RS

  • 阶段 3:处理 RS:检测从年轻代指向年老代的对象

  • 阶段 4:对象拷贝:拷贝存活的对象到 survivor/old 区域

  • 阶段 5:处理引用队列:软引用,弱引用,虚引用处理

G1 Mixed GC

Mixed GC 不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区


GC 步骤分 2 步:


  • 全局并发标记(global concurrent marking)

  • 拷贝存活对象(evacuation)

全局并发标记

进行 Mixed GC 之前,会先进行 global concurrent marking(全局并发标记)


在 G1 GC 中,它主要是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。global concurrent marking 的执行过程分为五个步骤

初始标记(initial mark,STW)

在此阶段,G1 GC 对根进行标记。该阶段与常规的 (STW) 年轻代垃圾回收密切相关

根区域扫描(root region scan)

G1 GC 在初始标记的存活区扫描对老年代的引用(扫描 CardTable 和 RSet),并标记被引用的对象。该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收

并发标记(Concurrent Marking)

G1 GC 在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断。

最终标记(Remark,STW)

该阶段是 STW 回收,帮助完成标记周期。G1 GC 清空 SATB 缓冲区,跟踪未被访问的存活对象,并执行引用处理

清除垃圾(Cleanup,STW)

  • 最后阶段,G1 GC 执行统计和 RSet 净化的 STW 操作。

  • 在统计期间,G1 GC 会识别完全空闲的区域和可供进行混合垃圾回收的区域

  • 清理阶段在将空白区域重置并返回到空闲列表时为部分并发

三色标记算法

提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性


首先,我们将对象分成三种类型的。


  • 黑色:根对象,或者该对象与它的子对象都被扫描

  • 灰色:对象本身被扫描,但还没扫描完该对象中的子对象

  • 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象


当 GC 开始扫描对象时,按照如下图步骤进行对象的扫描:


根对象被置为黑色,子对象被置为灰色。



继续由灰色遍历,将已扫描了子对象的对象置为黑色。



遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。



这看起来很美好,但是如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题


我们看下面一种情况,当垃圾收集器扫描到下面情况时:



这时候应用程序执行了以下操作:


A.c=C
B.c=null
复制代码


这样,对象的状态图变成如下情形:



这时候垃圾收集器再标记扫描的时候就会下图成这样:



**很显然,此时 C 是白色,被认为是垃圾需要清理掉,显然这是不合理的。那么我们如何保证应用程序在运行的时候,GC 标记的对象不丢失呢?**有如下 2 中可行的方式:


  • 在插入的时候记录对象

  • 在删除的时候记录对象


刚好这对应 CMS 和 G1 的 2 种不同实现方式:

CMS 采用的是增量更新(Incremental update)

在 CMS 采用的是增量更新(Incremental update),只要在写屏障(write barrier)里发现要有一个白对象的引用被赋值到一个黑对象的字段里,那就把这个白对象变成灰色的。即插入的时候记录下来

SATB(snapshot-at-the-beginning)的方式

在 G1 中,使用的是 SATB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象,它有 3 个步骤:


  1. 在开始标记的时候生成一个快照图标记存活对象

  2. 在并发标记的时候所有被改变的对象入队(在 write barrier 里把所有旧的引用所指向的对象都变成非白的

  3. 可能存在游离的垃圾,将在下次被收集


G1 到现在可以知道哪些老的分区可回收垃圾最多。 当全局并发标记完成后,在某个时刻,就开始了 Mix GC。这些垃圾回收被称作“混合式”是因为他们不仅仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。


混合式垃圾收集如下图:



混合式 GC 也是采用的复制的清理策略,当 GC 完成后,会重新释放空间。



至此,混合式 GC 告一段落了。下一小节我们讲进入调优实践。

调优实践

MaxGCPauseMillis 调优

前面介绍过使用 GC 的最基本的参数:


-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200


前面 2 个参数都好理解,后面这个 MaxGCPauseMillis 参数该怎么配置呢?这个参数从字面的意思上看,就是允许的 GC 最大的暂停时间。G1 尽量确保每次 GC 暂停的时间都在设置的 MaxGCPauseMillis 范围内


那 G1 是如何做到最大暂停时间的呢?这涉及到另一个概念,CSet(collection set)。它的意思是在一次垃圾收集器中被收集的区域集合。


  • Young GC:选定所有新生代里的 region。通过控制新生代的 region 个数来控制 young GC 的开销。

  • Mixed GC:选定所有新生代里的 region,外加根据 global concurrent marking 统计得出收集收益高的若干老年代 region。在用户指定的开销目标范围内尽可能选择收益高的老年代 region。


问题 3:需要在这个限度范围内设置。但是应该设置的值是多少呢?


  • 我们需要在吞吐量跟 MaxGCPauseMillis 之间做一个平衡。如果 MaxGCPauseMillis 设置的过小,那么 GC 就会频繁,吞吐量就会下降

  • 如果 MaxGCPauseMillis 设置的过大,应用程序暂停时间就会变长。G1 的默认暂停时间是 200 毫秒

其他调优参数

-XX:G1HeapRegionSize=n


设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域


-XX:ParallelGCThreads=n


设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8


如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。


-XX:ConcGCThreads=n


设置并发标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。


-XX:InitiatingHeapOccupancyPercent=45


设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。


避免使用以下参数:


避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。

触发 Full GC

在某些情况下,G1 触发了 Full GC,这时 G1 会退化使用 Serial 收集器来完成垃圾的清理工作,它仅仅使用单线程来完成 GC 工作,GC 暂停时间将达到秒级别的


整个应用处于假死状态,不能处理任何请求,我们的程序当然不希望看到这些。那么发生 Full GC 的情况有哪些呢?

并发模式失败

G1 启动标记周期,但在 Mix GC 之前,老年代就被填满,这时候 G1 会放弃标记周期。这种情形下,需要增加堆大小,或者调整周期(例如增加线程数-XX:ConcGCThreads 等)

晋升失败或者疏散失败

G1 在进行 GC 的时候没有足够的内存供存活对象或晋升对象使用,由此触发了 Full GC。可以在日志中看到(to-space exhausted)或者(to-space overflow)


解决这种问题的方式是:


  • 增加 -XX:G1ReservePercent 选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量

  • 通过减少 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。

  • 也可以通过增加 **-XX:ConcGCThreads **选项的值来增加并行标记线程的数目。

巨型对象分配失败

当巨型对象找不到合适的空间进行分配时,就会启动 Full GC,来释放空间。这种情况下,应该避免分配大量的巨型对象,增加内存或者增大-XX:G1HeapRegionSize,使巨型对象不再是巨型对象

发布于: 21 小时前阅读数: 13
用户头像

我们始于迷惘,终于更高水平的迷惘。 2020.03.25 加入

🏆 【酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客狂人“】 🏅 【Java技术领域,MySQL技术领域,APM全链路追踪技术及微服务、分布式方向的技术体系等】 🤝未来我们希望可以共同进步🤝

评论

发布
暂无评论
🏆「作者推荐」【JVM原理探索】深入理解G1垃圾收集器的原理和运行机制