🏆「作者推荐」【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
:**设计堆内存的最大内存为 32GXX: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 空间耗尽时会被触发。
在这种情况下,Eden 空间的数据移动到 Survivor 空间中,如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到年老代空间。
Survivor 区的数据移动到新的 Survivor 区中,也有部分数据晋升到老年代空间中。
最终 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 开始扫描对象时,按照如下图步骤进行对象的扫描:
根对象被置为黑色,子对象被置为灰色。
继续由灰色遍历,将已扫描了子对象的对象置为黑色。
遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。
这看起来很美好,但是如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题
我们看下面一种情况,当垃圾收集器扫描到下面情况时:
这时候应用程序执行了以下操作:
这样,对象的状态图变成如下情形:
这时候垃圾收集器再标记扫描的时候就会下图成这样:
**很显然,此时 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 个步骤:
在开始标记的时候生成一个快照图标记存活对象
在并发标记的时候所有被改变的对象入队(在 write barrier 里把所有旧的引用所指向的对象都变成非白的)
可能存在游离的垃圾,将在下次被收集
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
,使巨型对象不再是巨型对象。
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/1a8b9b129a603e216a5a876d7】。文章转载请联系作者。
评论