JVM-G1垃圾收集器

简介

概念

    全称Garbage First,垃圾优先收集器,是jdk1.9的默认垃圾收集器。G1的设计初衷是为用户提供大内存低GC停顿时间的应用解决方案。G1会跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的时间经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

    G1的设计目标:

  • 像CMS那样做到并发GC,提高GC并行和并发表现
  • 没有内存碎片问题,内存整理过程不需要延长GC时间,不需要STW
  • 可预测的GC停顿时间
  • 更高的吞吐量
  • 在不增加堆内存大小下更好地利用堆内存
  • 尽量缩短处理超大堆(6-8GB)时产生的停顿

    与CMS区别,G1优点包括:

  • G1是内存整理虚拟机,G1通过对堆内存划分区域(region)分区管理避免使用细粒度空闲列表来实现高效内存整理
  • G1提供比CMS更加可预测的GC停顿时间,并允许用户设定停顿时间目标

    但是,G1为了垃圾收集产生的内存占用以及程序运行时的额外执行负载都比CMS要高。
    在HotSpot垃圾收集器里,除G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作;而G1 GC可以采用用户线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用用户线程帮忙加速垃圾回收过程。

    推荐使用场景:

  • FullGC发生频繁或总时间过长
  • 对象分配率或对象升级至老年代的比例波动较大
  • 较长的垃圾收集或内存整理停顿(大于0.5至1秒)

    G1在回收的时候将对象从一个小堆区复制到另一个小堆区,这意味着G1在回收垃圾的时候同时完成了堆的部分内存压缩,相对于CMS的优势而言就是内存碎片的产生率大大降低。heap被划分为一系列大小相等的“小堆区”,也称为region。每个小堆区region的大小为1-32MB,数值必须是2的幂,所以Region的大小只能是1M、2M、4M、8M、16M或32M。整个堆默认划分为2048个小堆区,比如堆内存为16g,G1就会采用16G / 2048 = 8M 的Region。

    G1在逻辑上同样会将堆划分出Eden、Survivor和Old,只是Eden、Survivor和Old空间对应的小堆区的个数不是固定的,各代小堆区存储地址是不连续的。此外heap堆还存在着一些未被使用的空间,这些空间同样也会被进行划分。Eden、Survivor和Old空间大小不再是固定的,每个代分区的数量是可以动态调整的。
堆分布图
图中的H代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj)。

大对象分配

    如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。为此,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

  • 对象的大小<0.5个RegionSize直接存在新生代Eden Region区
  • 对象的大小>=0.5个RegionSize且对象的大小<1个RegionSize,存到大对象区Humongous Region
  • 对象的大小>=1个RegionSize存到连续的大对象区Humongous Region

数据结构

    G1垃圾收集器占用内存会比CMS大,增大的部分主要与accouting数据结构有关,如Remembered Set(RSet)Card TableCollection Set(CSet)

RSet

RSet

    Remembered Set: 每个Region都包含一个RSet,RSet记录的是其他Region中的对象引用本Region对象的关系,用来记录不同代之间的引用关系,主要是老年代到新生代之间的引用的一个集合,至于新生代之间的引用记录会在每次GC时被扫描,所以不用记录新生代到新生代之间的引用。每个Region对应一个RSet,RSet对整体内存占用的影响少于5%。RSet是一个空间换时间的数据结构,有了RSet可以避免对整个堆进行扫描

    在CMS中,也有RSet的概念,在老年代中有一块区域用来记录指向新生代的引用,即我引用了谁的对象。这是一种point-out,在进行Young GC时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
    但在G1中,并没有使用point-out,这是由于一个Region太小,Region数量太多,如果是用point-out的话,会造成大量的扫描浪费,有些根本不需要GC的Region引用也扫描了。于是G1中使用point-in来解决。point-in的意思是哪些Region引用了当前Region中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描

    比如a=b(a引用b),若采用point out结构,则在a的RSet中记录b的地址;若采用point in结构,则在b的RSet中记录a的地址。G1的RSet采用的是point in结构,即谁引用了我;Card Table采用的是point out结构。

    RSet其实是一个 hash table,key是别的Region的起始地址,value是一个集合,里面的元素是 card table的 Index。举例来说,如果region A的Rset里有一项的key是 region B,value里有 index为1234的card,它的意思就是 region B的一个card里有引用指向region A。所以对 region A来说该RSet记录的是 points-in的关系,而 card table仍然记录了 points-out的关系。

    由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次GC时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。

GC为什么需要记录跨代的引用

    Young GC只会回收年轻代,Old GC只会回收老年代,无论是哪一种GC都会面临跨代引用的情况,比如老年代对象引用新生代或者新生代对象引用老年代。
    Young GC在回收年轻代时,需要判断年轻代的对象是否存活,而年轻代的部分对象可能被老年代的对象引用,因此必须扫描老年代才不会发生误判年轻代的对象为垃圾;同理,在回收老年代时,也需要扫描年轻代。
    那么无论是只回收新生代还是老年代,都需要扫描其他代的对象,相当于进行全堆扫描,效率很低。那么将代际之间的引用关系记录在一个单独的地方,只需要扫描这个地方即可,避免全堆扫描。

RSet带来的问题
  • RSet需要额外的内存空间来存储这些引用关系,一般是JVM最大的额外开销的1%-20%之间;
  • RSet中的对象可能已经死亡,那么这个时候引用的对象会被认为活跃对象,实际上它是浮动垃圾;
  • RSet是通过写屏障来完成的,即在内存分配的地方,插入一段代码来执行RSet的更新,如果对象的创建/修改/回收比较频繁,那么写RSet的性能开销还是比较大的。因此一般不会记录年轻代到老年代的引用。
G1-RSet记录

主要分析哪些引用的关系需要记录在RSet中;

  • 分区内部的引用

无论是新生代还是老年代的分区内部的引用,都不需要记录引用关系。因为是针对一个分区进行的垃圾回收,要么这个分区被回收,要么不被回收。

  • 新生代引用新生代

G1的三种回收算法(YGC/MIXED GC/FULL GC)都会全量处理新生代分区,所以新生代都会被遍历到。因此无需记录这种引用关系。

  • 新生代引用老年代

无需记录。G1的YGC回收新生代,无需这个引用关系。
混合GC时,G1会采用新生代分区作为根,那么在遍历新生代分区时就能找到老年代分区了,无需这个引用关系。
FGC时,所有分区都会被处理,也无需这个引用关系。

  • 老年代引用新生代

需要记录。YGC在回收新生代时,如果新生代的对象被老年代引用,那么需要标记为存活对象。即此时的根对象有两种,一个是栈空间/全局变量的引用,一个是老年代到新生代的引用。

  • 老年代引用老年代

需要记录。混合GC时,只会回收部分老年代,被回收的老年代需要正确的标记哪些对象存活。

RSet的更新

G1中采用post-write barrier和concurrent refinement threads实现了RSet的更新。

G1的RSet的更新是通过写屏障完成的,在写变更时,通过插入一条额外的代码把引用关系放入到DCQ(dirty card queue)队列中,随后refinement线程取出DCQ队列的引用关系,更新RSet。比如,每一次将一个老年代对象的引用修改为指向新生代对象时,都会被写屏障捕获,并且记录下来。

对于一个写屏障来时,过滤掉不必要的写操作是十分必要的,G1进行以下过滤:

  • 不记录新生代到新生代的引用 或者 新生代到老年代的引用
  • 过滤一个分区内部的引用
  • 过滤空引用
RSet的问题

我们得知应用线程只负责把更新字段所在的Card插入到dirty card queue中,然后由后台线程refinement threads负责RSet的更新操作,如果应用线程插入速度过快,refinement threads来不及处理,那么应用线程将接管RSet更新的任务,这是必须要避免的。
refinement threads线程数量可以通过-XX:G1ConcRefinementThreads-XX:ParallelGCThreads参数设置

Card Table

Card Table:Card Table(全局卡表)是一个位图,全局只有一个,每个Region又被分成了512个Card,这些Card都会记录在全局卡表中。
Card中的每个元素对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为卡页。一个卡页的内存中通常不止一个对象,只要卡页中有一个以上对象的字段存在着跨Region引用(老年代之间跨Region、老年代到年轻代之间跨Region),这个对应的元素的值就标识为1。

比如G1默认的Region有2048个,默认每个Region为2M,那每个Region对应的Card的每个元素对应的卡页的大小为2M / 512=4K,即这4K内存中只要有一个或一个以上的对象存在着跨Region对年轻代的引用,这个卡页对应的Card的元素值为1。

card

在Young GC时,只需要将脏的Region中的那个卡页加入GC Roots一并扫描即可。
在做YGC的时候,只需要选定young generation region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个old generation,大大减少了扫描的数据量,提升了效率。 而mixed gc的时候,old generation中记录了old->old的RSet,young->old的引用由扫描全部young generation region得到,这样也不用扫描全部old generation region。所以RSet的引入大大减少了GC的工作量。

Collection Set:CSet记录在一次GC中将被回收的区域集合。所有CSet区域中的存活对象都会被移动到新的区域中,这些区域可以是Eden、Survivor和Old代的,并且可以同时包含这几代分区的内容。CSet对JVM内存占用影响少于1%。

停顿预测模型

    G1不是实时垃圾收集器,它会尽量让停顿时间低于用户设置的停顿时间目标但不能保证一定如此。G1根据历史垃圾收集监测数据来预测每个区域的回收时间,然后根据用户设定的目标停顿时间决定每次GC时可以回收哪些区域。G1通过这种方式建立比较精确的区域回收时间预测模型。

G1的两种GC模式

    G1提供了两种GC模式:Young GCMixed GC,两种都是完全Stop The World的。无论是Young GC还是Mixed GC都是并发拷贝的。

  • Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
  • Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。
    Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满,无法继续进行Mixed GC,就会使用full GC来收集整个GC heap。

    G1的整体运行过程:会在Young GC和Mixed GC之间不断的切换运行,同时定期的做全局并发标记,在实在赶不上回收速度的情况下使用Full GC(Serial GC)。初始标记是搭在YoungGC上执行的,在进行全局并发标记的时候不会做Mix GC,在做Mixed GC的时候也不会启动初始标记阶段。当MixGC赶不上对象产生的速度的时候就退化成Full GC。

Young GC 模式

Young GC也可以被称为Minor GC。Young GC并不代表年轻代内存不足,它事实上只表示在Eden区上的GC,

特性:

  • 会发生Stop The World事件
  • GC线程可以并发执行

过程:

young gc

    Eden空间存活的对象会被复制移动到另外一个或是多个Survivor小堆区,如果Survivor空间不够,Eden空间的部分对象会直接晋升到老年代空间。Survivor区的对象移动到新的Survivor区中,也有部分对象(达到年龄=15)晋升到老年代空间中。上述的整个过程会发生STW事件(在STW过程会同时计算出Eden的大小和Survivor的大小,为下一次Young GC做准备。Accounting信息会被保存用于计算大小),最终Eden空间的数据会被清空,GC停止工作,应用线程继续执行。

总结:

  • 堆内存是一个单独的内存区域,被分为多个大小相同的区域
  • 新生代内存由一系列不连续的区域组成,按需调整内存大小很简单
  • 新生代垃圾回收(Young GC)需要STW,所有应用线程需要停顿
  • Young GC多线程并行执行
  • 存活对象复制到新的survivor区或老年代区

Mixed GC 模式

初始标记阶段(Initial Marking Phase)

Initial Marking

    新生代垃圾收集捎带着一次存活对象的初始标记。在GC日志中打印为GCpause(young)(inital-mark)
这个过程会发生STW事件。

并发标记阶段(Concurrent Marking Phase)

Concurrent Marking

    本阶段会与应用程序并发地查找存活的对象,如果找到了空的小堆区(图中标记为红叉的),他们会在重新标记阶段被马上清除。同时,计算各区域活跃度(回收优先级)的所需的信息在这个阶段统计。在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题。

重新标记阶段(Remark Phase)

Remark

    这一阶段空区域被回收,计算出所有区域活跃度。它会短暂地停止应用线程(STW),停止并发更新日志的写入,处理其中的少量信息,并标记所有在并发标记开始时未被标记的存活对象。这一阶段也执行某些额外的清理,如引用处理或者类卸载。

复制/清除阶段(Copying/Cleanup Phase)

CopyingCleanup

    清除阶段

  • 执行存活对象的accounting和完全释放空的小堆区(STW
  • 擦除RSets(STW
  • 重置空的小堆区并将他们归还给free list,也就是空闲表(Concurrent)

    复制阶段

  • 本阶段在复制移动存活对象到新的未被使用的区域时,会有STW停顿。停顿时间的控制,是通过选择CSet的数量来达到控制时间长短的目标。在新生代小堆区完成时会被记录为 [GC pause (young)],如果在新生代和老年代的小堆区一起执行时会被记录为[GC Pause (mixed)]

CopyingCleanup

    G1会优先选择活跃度最低的小堆区,因为这些区域会被最快地的回收。还有新生代和老年代都会在本阶段被回收。

复制/清除阶段后期(After Copying/Cleanup Phase)

After CopyingCleanup

    被选中区域的存活对象移动到新的区域中,原区域被回收加入空白列表。

After CopyingCleanup

总结

    并发标记阶段

  • 在应用程序运行时并发地计算活跃度信息
  • 活跃度信息甄别出哪个小堆区是在撤离暂停时最适合回收的

    重新标记阶段

  • 使用Snapshot-at-the-Beginning (SATB) 算法,这个算法比CMS所使用的要快得多
  • 回收空的小堆区

    复制/清除阶段

  • 新生代和老年代同时被回收
  • 老年代的小堆区会根据活跃度而进行部分的选定,确定回收优先级

使用方式

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

  • -XX:G1HeapRegionSize=n参数可以设置Region的大小

  • -XX:MaxGCPauseMillis=n参数设置用户期待的应用程序暂停时间。暂停时间设置的太短,就会导致出现G1跟不上垃圾产生的速度,最终导致Full GC。一般情况下这个值设置到100-200,单位是ms。

  • -XX:InitiatingHeapOccupancyPercent=45 设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%,达到这个阈值就会进行mixed gc

  • -XX:ParallelGCThreads=n设置 STW 工作线程数的值。将n的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。如果逻辑处理器不止8个,则将 n 的值设置为逻辑处理器数的 5/8 左右

  • -XX:ConcGCThreads=n设置并行标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads) 的 1/4 左右。适当提高线程数,可尽可能避免转移失败。

  • -XX:G1ReservePercent=n为堆内存设置虚拟使用上限,预留一部分空间防止to-space情况出现,以降低失败的可能性,默认值是 10。

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

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

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

参考资料