G1 (Garbage First)垃圾收集器

因为上一篇文章JVM垃圾收集器总结的篇幅太长,同时G1也非常的重要篇幅也不会短,所以这里另开一篇文章总结一下G1垃圾收集器;

前面的文章我们介绍了很多垃圾收集器,例如PS+PO,CMS等,就还有G1没有讲到,那接下来我们先认识一下G1,

我学习这部分的内容时觉得前面讲解的PS+PO,CMS等这些垃圾收集器的时候觉得没有难,但是到G1,java的堆内存布局就有点”妖艳贱货”了! 然后就有点越来越看不懂了;看不懂就多看几遍,多查阅一些资料!

简单的介绍G1

G1把堆分成了大小相等的独立区域(Region),新生代和老年代不在物理上隔离,只在逻辑上有定义;每一个Region都可以根据需要,扮演新生代的Eden空间,Survivor空间,或者老年代空间,除此之外它还有一类特殊区域叫做Humongous,专门用来存储大对象,当新建对象大小超过Region大小一半时,直接在新的一个或多个连续的Region中分配,并标记为H,空间分布如下图:

image.png

上面提到G1的堆内存被划分为多个大小相等的Region,但是划分多少个?我找了一些资料:

The goal is to have around **2048** regions for the total heap.

对于一个Region来说,是逻辑连续的一段空间,其大小的取值范围是1MB到32MB之间;那这些数据是哪里来的呢?我说32MB你们就信了吗?就像2048一样一些证据呀!

#ifndef SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP
#define SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP

#include "memory/allocation.hpp"

class HeapRegionBounds : public AllStatic {
private:
// Minimum region size; we won't go lower than that.
// We might want to decrease this in the future, to deal with small
// heaps a bit more efficiently.

//最小的Region大小为1M
static const size_t MIN_REGION_SIZE = 1024 * 1024;

// Maximum region size; we don't go higher than that. There's a good
// reason for having an upper bound. We don't want regions to get too
// large, otherwise cleanup's effectiveness would decrease as there
// will be fewer opportunities to find totally empty regions after
// marking.

//最大的Region大小为32M
static const size_t MAX_REGION_SIZE = 32 * 1024 * 1024;

// The automatic region size calculation will try to have around this
// many regions in the heap (based on the min heap size).

//默认的Region个数为2048
static const size_t TARGET_REGION_NUMBER = 2048;

public:
static inline size_t min_size();
static inline size_t max_size();
static inline size_t target_number();
};

来源于:http://hg.openjdk.java.net/jdk/jdk/file/fa2f93f99dbc/src/hotspot/share/gc/g1/heapRegionBounds.hpp

这个更权威吧,但是知道这些数据重要吗?我觉得不重要!但是知道了更牛逼呀!

言归正传,G1重新定义了堆空间,打破原有的分代模型,将堆划分为一个个区域,这么做的目的就是为了在进行收集时不必再全堆范围进行,这是它最显著的特定;不在全堆内进行收集带来的好处就是:停顿时间可以预测;用户可以指定收集操作在多长时间内完成;如果用户指定了收集时间为0.05s;那G1就会努力向这个时间上靠,G1收集垃圾时怎么能保证接近这个时间呢?G1要想达到这个目标就会最少的收集Region;

把上面的话重新组织一下就是说:G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个java堆中进行全区域的收集,G1会通过一个合理的计算模型,计算出每个Region的收集成本并量化,这样一来,收集器在给定了停顿时间的限制下,总是能选择一组恰当的Region作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的;

上面我们在宏观上对G1进行了大致了解,接下来我们对它的执行流程步骤了解一下:

G1收集器的运作过程

初始标记(Initial Marking)

仅仅只是标记了一下GCRoots能直接关联的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region上创建对象,这个阶段需要停顿线程,但耗时很短!

并发标记

从GCRoots开始对堆进行可达性分析,找到存活的对象,这个阶段耗时较长,可以与用户程序并发执行;

最终标记

这个阶段是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面;最终标记阶段需要把Remembered Set Logs 的数据合并到Remembered Set中;这个阶段需要停顿线程,但是可并行执行;(这里对Remembered Set有眼熟,后面会讲到)

筛选回收

负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划;

可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region中存活的对象进行复制到空的Region中,再清理掉整个旧的Region的全部空间;这里的操作涉及到存活对象的移动复制,时必须要暂停用户线程,由多个收集器线程并行完成的;

G1收集器的大致介绍到这里,上面的提到了Remembered Set,下面我们介绍一下Remembered Set为何物?

G1涉及到的技术点

Rset (Remembered Set)

每个区域(Region)都有一个Rset,用于记录进入该区域块的对象引用,比如区块A中的对象引用的区块B,区块B的Rest需要记录这个信息,它用于实现收集过程的并行化以及使得区块能独立收集,当然Rset会占用Region的空间,但是总体上占用的内存小于5%;

卡表(Card Table)

在上一篇文章JVM垃圾收集器总结中我们也对Card Table 做了介绍!我们一起可以再回顾一下:

在上一篇我们讲述CMS的时候有这样一个场景:CMS是老年代的垃圾收集器,但是在并发标记阶段它也会扫描新生代,因为在并发过程中无法确保不可达对象会再次变成可达对象(之前和GCRoots跟断了但是并发阶段又重新连接上);这个时候如果全量的去扫描新生代和老年代就是特别的慢!这个时候就会用到卡表(Card Table)这个东西其实就是个数组,数组中每个位置存的是一个byte;CMS将老年代的空间分成大小为512bytes的块,card table中的每个元素对应着一个块。

并发标记时,如果某个对象的引用发生了变化,就标记该对象所在的块为 dirty card(脏卡)

如果你没有看过我的上一篇文章也没有关系,我这里贴一下:

下面我们用图来解释一下:

并发标记时对象的状态:

但随后current obj的引用发生了变化:

current obj所在的块被标记为了dirty card。

随后到了pre-cleaning(预清理阶段)阶段,该阶段的任务之一就是标记这些在并发标记阶段被修改了的对象,之后那些通过current obj变得可达的对象也被标记了,变成下面这样:

同时dirty card标志也被清除。老年代的机制就是这样。

这是卡表(Card Table)的其中一个功能,卡表(Card Table)还有另外一个功能:

我们再想象一个场景,老年代的对象可能引用新生代的对象,那标记存活对象的时候,需要扫描老年代中的所有对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为GC Roots。那不是得又做全堆扫描?成本太高了吧。在进行Minor GC的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。

卡表能用于减少老年代的全堆空间扫描,这能很大的提升GC效率

三色标记

在遍历对象的过程中,把访问的对象按照是否访问过这个条件标记成以下三种颜色:

  • 白色表示对象尚未被垃圾回收器访问过。显然,在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾回收器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其它的对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾回收器访问过,但这个对象至少存在一个引用还没有被扫描过
image.png

可以看到,灰色对象是黑色对象与白色对象之间的中间态,当标记过程结束后,只会有黑色和白色的对象,而白色的对象就是需要被回收的对象。

在并发标记阶段,垃圾收集器和用户线程同时工作,这个时候就会产生问题;垃圾收集器在对象图上面标记颜色,而同时用户线程在修改引用关系,引用关系变了,那么对象图就变化了,这样就会出现2中情况:

一种是把原本消亡的对象错误的标记为存活,这种错误还是可以容忍的,只不过产生了一点逃过本次回收的浮动垃圾而已,下次清理即可!

另一种是把原本存活的对象错误的标记为消亡,这就是非常严重的后果了,一个程序还需要使用的对象被回收了,那程序肯定会因此发生错误!这种情况绝对是不能容忍的!

所以接下来我们着重分析对象消失的问题;在看对象消失的情况之前我们先看一下正常的情况是什么样子的,这样我们更容易理解对象消失的情况;

下面的内容引用了:why技术的一篇博客

首先是初始状态,很简单,只有GC Roots是黑色的。同时需要注意下面的图片的箭头方向,代表的是有向的,比如其中的一条引用链是: 根节点->5->6->7->8->11->10

image.png

在扫描的过程中,变化是这样的:

你看上面的动图,灰色对象始终是介于黑色和白色之间的。当扫描顺利完成后,对象图就变成了这个样子:

image.png

此时,黑色对象是存活的对象,白色对象是消亡了,可以回收的对象。

记住,上面演示的是一切都是那么美好的正常情况

对象消失的情况一

如果用户线程在标记的时候,修改了引用关系,就会出现下面的情况:

当扫描完成后,对象图就变成了这个样子:

image.png

这时我们和之前分析的正常扫描结束的对象图对比,就能清楚的看到,扫描完成后,原本还在被对象5引用的对象9由于是白色对象,所以根据三色标记原则,对象9会被当成垃圾回收;

对象消失情况二

下面看一下另外一种对象消失的情况

上面演示的是用户线程切断引用后重新被黑色对象引用的对象就是原来引用链的一部分。

对象7和对象10本来就是原引用链(根节点->5->6->7->8->11->10)的一部分。修改后的引用链变成了(根节点->5->6->7->10)。

当扫描完成后,对象图就变成了这个样子:

image.png

由于黑色对象不会重新扫描,这将导致扫描结束后对象10和对象11都会回收了。他们都是被修改之前的原理的引用链的一部分;

这就是并发标记带来的问题,不光有浮动垃圾的产生,同时也产生了对象消失的问题;

解决对象消失的问题

上面我们演示的对象消失的两个场景都要满足两个条件:

  • 条件一: 赋值器插入了一条或者多条从黑色对象到白色对象的新引用

  • 条件二:赋值器删除了全部从灰色对象到该白色对象的直接或者间接的引用关系

当这两个条件同时满足的时候,才会出现对象消失的情况;我们捋一捋上面的这两个条件:

黑色对象5到白色对象9之间的引用是新建的,对应条件一。

黑色对象6到白色对象9之间的引用被删除了,对应条件二。

image.png

由于这两个条件同时满足才会出现对象消失的情况,所以我们只要破坏其中一个条件就可以解决这个问题;

于是就产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

在HotSpot虚拟机中,CMS是基于增量更新来做并发标记的,G1则采用的是原始快照的方式。

增量更新

增量更新破坏的是第一个条件,当黑色对象插如新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中黑色对象为根,重新扫描一次。

可以简单理解为:黑色对象一旦插入了指向白色对象的引用之后它就变成了灰色对象。

下面的图就是一次并发扫描结束之后,记录了黑色对象5新指向了白色对象9:

这样对象9又被扫描成为了黑色。也就不会被回收,所以不会出现对象消失的情况。

原始快照

原始快照要破坏的是第二个条件,照要破坏的是第二个条件(赋值器删除了全部从灰色对象到该白色对象的直接或间接引用),当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。

这个可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照开进行搜索。

需要注意的是,上面的介绍中无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。写屏障也是一个重要的知识点,但是不是本文重点,就不进行详细介绍了。

只是补充两点:

1.这里的写屏障和我们常说的为了解决并发乱序执行问题的”内存屏障”不是一码事,需要区分开来。

2.写屏障可以看作虚拟机层面对”引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫做写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。

所以,经过简单的推导我们可以知道:

增量更新用的是写后屏障(Post-Write Barrier),记录了所有新增的引用关系。

原始快照用的是写前屏障(Pre-Write Barrier),将所有即将被删除的引用关系的旧引用记录下来。

Full GC

这是G1(大多数收集器也同样)的一个兜底的阶段,原因主要是老年代的内存占用太多,比如程序中使用了某一个模板模板解析引擎,但是没有提取变量,导致每一次执行都是编译一个新的class,这种情况就很容易导致Full GC,或者有很多humongous object的存在,如果程序中发生了Full GC 除了GC相关的的调优,也需要多花时间去优化业务代码。

优化Full GC

  • 增加堆大小,或调整老年代和年轻代的比例;对应的效果G1可以有更多的时间去完成Concurrent Marking

  • 如果可能是大对象太多造成的,可以通过gc+heap=info查看humongous region个数。可以增加通过 -XX:G1HeapRegionSize增加Region Size,避免老年代中的大对象占用过多的内存空间。

  • 增加Concurrent Marking的线程,通过-XX:ConcGCThreads设置

  • 更早的进行并发周期,默认是整个堆内存的45%被占用就开始进行并发周期

参数介绍

  • -XX:+UseG1GC

    使用 G1 收集器

  • -XX:MaxGCPauseMillis=200

    指定目标停顿时间,默认值 200 毫秒。

    在设置 -XX:MaxGCPauseMillis 值的时候,不要指定为平均时间,而应该指定为满足 90% 的停顿在这个时间之内。记住,停顿时间目标是我们的目标,不是每次都一定能满足的。

  • -XX:InitiatingHeapOccupancyPercent=45

    整堆使用达到这个比例后,触发并发 GC 周期,默认 45%。

  • -XX:NewRatio=n

    老年代/年轻代,默认值 2,即 1/3 的年轻代,2/3 的老年代

    不要设置年轻代为固定大小,否则:

    • G1 不再需要满足我们的停顿时间目标
    • 不能再按需扩容或缩容年轻代大小
  • -XX:SurvivorRatio=n

    Eden/Survivor,默认值 8,这个和其他分代收集器是一样的

  • -XX:MaxTenuringThreshold =n

    从年轻代晋升到老年代的年龄阈值,也是和其他分代收集器一样的

  • -XX:ParallelGCThreads=n

    并行收集时候的垃圾收集线程数

  • -XX:ConcGCThreads=n

    并发标记阶段的垃圾收集线程数

    增加这个值可以让并发标记更快完成,如果没有指定这个值,JVM 会通过以下公式计算得到:

    ConcGCThreads=(ParallelGCThreads + 2) / 4^3

  • -XX:G1ReservePercent=n

    堆内存的预留空间百分比,默认 10,用于降低晋升失败的风险,即默认地会将 10% 的堆内存预留下来。

  • -XX:G1HeapRegionSize=n

    每一个 region 的大小,默认值为根据堆大小计算出来,取值 1MB~32MB,这个我们通常指定整堆大小就好了。

关于JVM的文章目前就先告一段落,后面学到新的东西再补充!只要努力就能解决的事情,我觉得就是最简单的事情!