G1垃圾收集器(上)
在上一篇文章中,已经简单的介绍了G1垃圾收集器,回顾一下:
G1垃圾收集器,在JDK7开始支持,在JDK8.40后提补齐并发的类卸载功能,趋于成熟。在JDK9中,已经作为默认的垃圾收集器了。它可以看做是CMS的继任者,在JDK9中,CMS已经被标记为废弃了。 作为CMS的继任者,G1也是一款侧重低停顿的垃圾收集器,更确切的说,是建立了一套“停顿时间模型”(Pause Prediction Model)的收集器,尽量在指定的(-XX:MaxGCPauseMillis=N,单位ms)时间内,完成垃圾收集。当然,也不能太离谱,通常是几百ms。
接下来,我们就详细介绍一下G1垃圾收集器。
一、概述
1.1 堆内存布局
G1垃圾收集器,与之前的各种垃圾收集器,最大的不同是,它并不是把堆简单的分为年轻代和老年代,而是切分成一块块固定大小的区域(Region),每个区域大小为1MB~32MB,且应为2的N次幂。每块区域可以根据需求扮演年轻代或者老年代。大小超过区域一半的对象,即被判定为大对象,存放大对象的区域被称之为Humongous区域,逻辑上属于老年代的一种,G1会对此进行特殊的优化。
上图为官网中的内存分布示意图,红色为年轻代,标记S的为年轻代的survivor区域。蓝色为老年代,H为Humongous区域。
🤔这样的内存划分有什么好处呢? 虽然还有年轻代、老年代的概念,但他们可以不是固定大小的一块连续的内存,而是一系列动态的、可以不连续的区域(region)的集合。G1之所以可以建立可预测的停顿时间模型,一个主要的原因,是它把区域(region)作为最小的垃圾回收单元。
- 年轻代回收,可以通过控制堆大小,也就是年轻代区域(region)数量,来控制停顿时间;
- 老年代回收,很难在可控的时间,回收完所有垃圾。那么,可以分多次,每次选择“回收价值”更高的一些区域回收。这也是G1(Garbage First)命名的由来。
- 对于超过“分区”一半的大对象,直接占用一个或者多个连续的分区,这样会造成内存浪费(有一部分用不到),但是可以对大对象,进行提前回收。毕竟这种分区只有一个对象,是否存活,相对好分析得多。
1.2 回收流程
在宏观上上,G1收集器在两个阶段之间交替。
- 年轻代回收(young-only),仅回收年轻代垃圾;
- 混合回收(mixed),回收年轻代 + 部分老年代区域;
以上为官网的示意图,上半部分的Young-only即为年轻代回收,下面的Space Reclamation就是混合回收。其中每个蓝色的圈,是年轻代回收,紫色的圈是混合回收,黄色的,是需要暂停用户线程的标记流程。以下简单描述两个阶段的循环流程。
- 年轻代回收时,超过了一定年龄的垃圾,会进入老年代,随着年轻代的多次回收,老年代内存占用逐渐增加,超过一定阈值后,会启动并发标记流程(Concurrent marking cycle),也就是图中的Old gen occupancy exceeds threshold, Concurrent Start。
- 并发回收,又分为两个大的阶段,标记阶段(标识哪些是垃圾)和回收节点(也就是混合回收)。
- 标记阶段,部分流程与用户线程一同进行,部分需要暂停用户线程(也就是黄色圆圈部分)。
- 回收阶段,多次进行,每次在给定的时间内,选择部分回收价值高的区域,与年轻代,一起垃圾回收(紫色圆圈部分)。
- 经过多次混合回收,老年代垃圾占比低于一定比例后,回归到年轻代回收(young-only)流程。
1.2.1 年轻代回收
G1的年轻代垃圾回收,与其他垃圾回收器类似,也是标记复制算法,也就是把存活的对象,复制到Survivor区域,年龄超过阈值,进入老年代。主要区别是,年轻代大小会根据历史回收的停顿时间和预期的停顿时间(-XX:MaxGCPauseMillis=xxx,默认200ms),动态变化,以满足停顿时间的需求。
年轻代相关参数:
- -XX:G1NewSizePercent=5,年轻代占用堆的最小百分比;
- -XX:G1MaxNewSizePercent=60,年轻代占用堆的最百分比;
注意,在jdk8中,这两个是实验性质的参数,使用前,需要打开实验开关(-XX:+UnlockExperimentalVMOptions)
java -XX:+UnlockExperimentalVMOptions -XX:G1NewSizePercent=10 -XX:G1MaxNewSizePercent=75 G1test.jar
1.2.2 标记流程
前文已经讨论过,便随年轻代的回收,老年代内存占用的增加,超过一定阈值后,会触发标记流程(Concurrent marking cycle)。
🤔阈值是什么呢? 由两个因素影响,满足任意一个条件,就会触发标记流程。
- 老年代对象占用堆超过一定比例启动标记流程,这个阈值称之为IHOP(Initiating Heap Occupancy Percent)。在默认情况下,G1根据历史标记时长&老年代占用的内存等因素,来确定最佳阈值。
- 在虚拟机刚开始运行时,由于没有样本,该阈值的初始值由-XX:InitiatingHeapOccupancyPercent来确定。
- 可以使用xx:-G1UseAdaptiveIHOP关闭自适应预测,那么这个比例始终由-XX:InitiatingHeapOccupancyPercent来指定。
- 堆的剩余空间小于一定比例,默认为10%,可以通过-XX:G1HeapReservePercent来指定阈值。
🤔什么时候触发以上比例的计算呢 当然是老年代内存使用发生变化的时候:
- 年轻代垃圾回收后;
- 分配了大对象,直接进入老年代,也就是分配了H区。
完整的标记流程,包含多个阶段,部分是停止用户线程的(STW stop the world ),部分可以与用户线程并发。
初始标记(Initial Mark)
停止用户线程(STW),扫描所GCRoots能直接关联到的对象,后续会以这些对象为根,继续扫描。同时对堆打一个快照,称之为SATB(Snapshot-At-The-Beginning ),SATB外的其他对象(也就是后续并发过程中,新进入老年代的对象),都认为是存活对象。这些SATB外的对象,当然也可能是垃圾,但只能等到下次才能进行回收了。
实际上,当达到IHOP阈值时,并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记。也就是上面流程图中,那个大的蓝色圆圈。
🤔为什么呢
与下一阶段的根分区扫描(Root Region Scanning)有关系,根分区扫描需要记录所有年轻代指向老年代的引用,那么就一定需要对整个年轻代进行扫描,那么索性就借助一次年轻代的回收,进行初始标记。
根分区扫描(Root Region Scanning)
与用户线程并发执行,标记所有“根分区”能直接关联到的对象。上一步已经收集了所有的GCRoots能直接关联的对象,那么除了GCRoots外,还需要记录所有年轻代指向老年代的引用。初始标记,会在一次年轻代回收之后执行,因此本次扫描,只需要扫描年轻代垃圾回收后的那个到Survivor分区即可。而这个Survivor分区,就是这里所谓的“根分区”。根分区扫描必须在下一次年轻代垃圾收集启动前完成(新的年轻代回收,回移动“根分区”对象)。因此如果发生了young gc,需要等young gc结束后重新执行。
并发标记(Concurrent Marking)
与用户线程并发执行,从上述两个阶段找到的直接关联到老年代的引用,继续扫描。所有SATB外的都被认为是存活对象,因此只需要扫描SATB内的即可。然而,在该过程中,用户线程会修改堆中的引用,造成标记结果不准确,这时需要记录对引用的修改。G1使用前置写屏障(与JAVA内存模型中的写屏障不同,类似一种AOP,可以在引用修改时插入一些操作)记录到SATB BUFFER中。BUFFER满了之后,会被加入全局链表中,标记线程会周期处理这些被修改的引用。
重新标记(Remark)
停止用户线程(STW)
- 处理上一阶段的SATB BUFFER以及全局链表中的引用修改。
- 处理引用(弱引用、软引用、虚引用、最终引用)
- 类卸载
清除(Cleanup)
停止用户线程(STW),为mixed gc做准备工作
- 计算老年代各个Region的回收价值和成本进行排序。
- 对辅助垃圾回收的一些数据结构进行状态维护。
- 回收完全没有存活对象的老年代或者大对象。
并发清除(Concurrent-cleanup)
与用户线程并发执行,完成上一步剩余的清理工作;清理上一步回收的分区相关的记忆集等数据结构,归还到内存空闲链表。
1.2.3 混合回收(mixed gc)
混合回收过程中,G1会把老年代的回收,拆分成多次,与年轻代一起回收,这也是“混合”的由来。
G1会试图在一次不大于用户期望的暂停时间内,回收尽可能多的老年代,因此在混合回收阶段,年轻代的大小被设置为允许的最小值,由-XX:G1NewSizePercent确定,默认为5%。也正是因为需要先调整年轻代大小,在标记流程结束后,G1需要先进行一次年轻代的垃圾回收,回收后改变年轻代大小,才能开启混合回收。
年轻代大小调整之后的每次年轻代垃圾回收,都会触发混合回收,直到剩余垃圾小于一定比例,由-XX:G1HeapWastePercent控制,默认为5%。
🤔每次混合回收,选择多少老年代,选择哪些老年代
- 回收数量,最少为候选老年代 / 预期混合回收次数(-XX:G1MixedGCCountTarget指定,默认为8);
- 候选老年代的标准为:存活对象的数量,小于-XX:G1MixedGCLiveThresholdPercen,默认为85,即存活对象数量大于这个阈值,回收意义很小。
- 满足以上条件,优先回收代价小,收益高的区域(可以简单的理解为垃圾更多,当然实际情况更复杂)。
1.2.4 Full GC
G1本身不提供Full GC的能力,只有G1无法处理的场景,才会退回到Full GC:
- 垃圾回收的速度没有生产的速度快:G1垃圾收集器有很多流程,是与用户线程并发的,也就是说,在垃圾收集过程中,了垃圾也在不断生产着。当垃圾收集的速度没有生产的速度快,就会回退到FULL GC。可以尝试以下手段优化
- 提前触发并发标记,减少-XX:InitiatingHeapOccupancyPercent或者增加-XX:G1HeapReservePercent。
- 增加并发线程数,通过-XX:ConcGCThreads。默认为另一个参数-XX:ParallelGCThreads的1/4。
- G1的混合回收,也采用标记复制算法,需要预留一定空间,才能把存活的对象复制过去,如果没有足够空间用于复制(垃圾回收日志会出现to-space exhausted),也会导致Full GC。本质上原因与上述类似,优化思路也类似。
1.2.5 巨型对象回收
大小超过区域一半的对象,即被判定为大对象,存放大对象的区域被称之为Humongous区域。它有以下特点:
- 一个巨型对象,占用一个或多个连续的完整区域,剩余空间无法利用。
- 巨型对象不会被移动,因为复制一个大对象,很耗时。
- 在分配巨型对象之前会先检查是否超过InitiatingHeapOccupancyPercent和The Marking Threshold,超过则启动全局并发标记,以提早回收,防止Evacuation Failures(转移失败)和Full GC。
- 按照上面的垃圾回收流程来说,一个巨型对象,只能在标记结束的回收阶段或者Full GC时,才能回收。不过,对于基本类型数组(例如布尔数组、各种整数数组和浮点数数组)的大型对象有一个特殊的规定,在任何类型的垃圾收集暂停时,G1都会尝试回收它们。该策略默认开启,可以通过选项-XX:G1EagerReclaimHumongousObjects来禁用。
附录
参考: