基础知识

初衷

在G1提出之前,经典的垃圾收集器主要有三种类型:串行收集器、并行收集器和并发标记清除收集器,这三种收集器分别可以是满足Java应用三种不同的需求:内存占用及并发开销最小化、应用吞吐量最大化和应用GC暂停时间最小化,但是,上述三种垃圾收集器都有几个共同的问题:(1)所有针对老年代的操作必须扫描整个老年代空间;(2)年轻地和老年代是独立的连续的内存块,必须先决定年轻代和老年代在虚拟地址空间的位置。

设计目标

G1是一种服务端应用使用的垃圾收集器,目标是用在多核、大内存的机器上,它在大多数情况下可以实现指定的GC暂停时间,同时还能保持较高的吞吐量。

使用场景

G1适用于以下几种应用:

  • 可以像CMS收集器一样,允许垃圾收集线程和应用线程并行执行,即需要额外的CPU资源;
  • 压缩空闲空间不会延长GC的暂停时间;
  • 需要更易预测的GC暂停时间;
  • 不需要实现很高的吞吐量

G1(Garbage-First)垃圾回收器是在Java7 update4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。

G1的重要概念

分区(Region)

G1采取了不同的策略来解决并行、串行和CMS收集器的碎片、暂停时间不可控制等问题——G1将整个堆分成相同大小的分区(Region),如下图所示。

image.png

每个分区都可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。

年轻代、幸存区、老年代这些概念还存在,成为逻辑上的概念,这样方便复用之前分代框架的逻辑。在物理上不需要连续,则带来了额外的好处——有的分区内垃圾对象特别多,有的分区内垃圾对象很少,G1会优先回收垃圾对象特别多的分区,这样可以花费较少的时间来回收这些分区的垃圾,这也就是G1名字的由来,即首先收集垃圾最多的分区。

新生代其实并不是适用于这种算法的,依然是在新生代满了的时候,对整个新生代进行回收—— 整个新生代中的对象,要么被回收、要么晋升,至于新生代也采取分区机制的原因,则是因为这样跟老年代的策略统一,方便调整代的大小。

G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。每个分区的大小从1M到32M不等,但是都是2的冥次方

G1 GC常用参数设置

  • -XX:+UseG1GC :启用G1 GC,JDK7和JDK8要求必须显示申请启动G1 GC;在JDK9中成为了默认的垃圾收集器。
  • -XX:MaxGCPauseMills :预期G1每次执行GC操作的暂停时间,单位是毫秒,默认值是200毫秒, G1会尽量保证控制在这个范围内。
  • -XX:+InitiatingHeapOccupancyPercent (简称IHOP):G1内部并行回收循环启动的阈值, 默认为Java Heap的45%。这个可以理解为老年代使用大于等于45%的时候,JVM会启动垃圾回收。这个值非常重要,它决定了在什么时间启动老年代的并行回收。

收集集合(CSet)

一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。

已记忆集合(RSet)

RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。

如下图所示,Region1和Region3中的对象都引用了Region2中的对象,因此在Region2的RSet中记录了这两个引用。

image.png

摘一段R大的解释:G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。 这个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-into的关系;而card table仍然记录了points-out的关系。

Snapshot-At-The-Beginning(SATB)

SATB是维持并发GC的正确性的一个手段,G1GC的并发理论基础就是SATB,SATB是由Taiichi Yuasa为增量式标记清除垃圾收集器设计的一个标记算法。Yuasa的SATAB的标记优化主要针对标记-清除垃圾收集器的并发标记阶段。按照R大的说法:CMS的incremental update设计使得它在remark阶段必须重新扫描所有线程栈和整个young gen作为root;G1的SATB设计在remark阶段则只需要扫描剩下的satbmarkqueue。

SATB算法创建了一个对象图,它是堆的一个逻辑“快照”。标记数据结构包括了两个位图:previous位图和next位图。previous位图保存了最近一次完成的标记信息,并发标记周期会创建并更新next位图,随着时间的推移,previous位图会越来越过时,最终在并发标记周期结束的时候,next位图会将previous位图覆盖掉。

下面我们以几个图例来描述SATB算法的过程:

  1. 在并发周期开始之前,NTAMS字段被设置到每个分区当前的顶部,并发周期启动后分配的对象会被放在TAMS之前(图里下边的部分),同时被明确定义为隐式存活对象,而TAMS之后(图里上边的部分)的对象则需要被明确地标记。

image.png

  1. 并发标记过程中的堆分区

image.png

  1. 位于堆分区的Bottom和PTAMS之间的对象都会被标记并记录在previous位图中;

image.png

  1. 位于堆分区的Top和PATMS之间的对象均为隐式存活对象,同时也记录在previous位图中;

image.png

  1. 在重新标记阶段的最后,所有NTAMS之前的对象都会被标记

image.png

  1. 在并发标记阶段分配的对象会被分配到NTAMS之后的空间,它们会作为隐式存活对象被记录在next位图中。一次并发标记周期完成后,这个next位图会覆盖previous位图,然后将next位图清空。

SATB是一个快照标记算法,在并发标记进行的过程中,垃圾收集器(Collecotr)和应用程序(Mutator)都在活动,如果一个对象还没被mark到,这时候Mutator就修改了它的引用,那么这时候拿到的快照就是不完整的了,如何解决这个问题呢?G1 GC使用了SATB write barrier来解决这个问题——在并发标记过程中,将该对象的旧的引用记录在一个SATB日志对列或缓冲区中。

G1的过程

四个操作

G1收集器的收集活动主要有四种操作:

  • 新生代垃圾收集
  • 后台收集、并发周期
  • 混合式垃圾收集
  • 必要时候的Full GC

第一、新生代垃圾收集的图例如下:

image.png

  • Eden区耗尽的时候就会触发新生代收集,新生代垃圾收集会对整个新生代进行回收
  • 新生代垃圾收集期间,整个应用STW
  • 新生代垃圾收集是由多线程并发执行的
  • 新生代收集结束后依然存活的对象,会被拷贝到一个新的Survivor分区,或者是老年代。

G1设计了一个标记阈值,它描述的是总体Java堆大小的百分比,默认值是45,这个值可以通过命令 -XX:InitiatingHeapOccupancyPercent(IHOP)来调整,一旦达到这个阈值就会触发一次并发收集周期。注意:这里的百分比是针对整个堆大小的百分比,而CMS中的 CMSInitiatingOccupancyFraction命令是针对老年代的百分比。


第二、G1的并发标记周期包括多个阶段:

并发标记周期采用的算法是我们前文提到的SATB标记算法,产出是找出一些垃圾对象最多的老年代分区。

  • 初始标记(initial-mark),在这个阶段,应用会经历STW,通常初始标记阶段会跟一次新生代收集一起进行,换句话说——既然这两个阶段都需要暂停应用,G1 GC就重用了新生代收集来完成初始标记的工作。在新生代垃圾收集中进行初始标记的工作,会让停顿时间稍微长一点,并且会增加CPU的开销。初始标记做的工作是设置两个TAMS变量(NTAMS和PTAMS)的值,所有在TAMS之上的对象在这个并发周期内会被识别为隐式存活对象;
  • 根分区扫描(root-region-scan),这个过程不需要暂停应用,在初始标记或新生代收集中被拷贝到survivor分区的对象,都需要被看做是根,这个阶段G1开始扫描survivor分区,所有被survivor分区所引用的对象都会被扫描到并将被标记。survivor分区就是根分区,正因为这个,该阶段不能发生新生代收集,如果扫描根分区时,新生代的空间恰好用尽,新生代垃圾收集必须等待根分区扫描结束才能完成。如果在日志中发现根分区扫描和新生代收集的日志交替出现,就说明当前应用需要调优。
  • 并发标记阶段(concurrent-mark),并发标记阶段是多线程的,我们可以通过 -XX:ConcGCThreads来设置并发线程数,默认情况下,G1垃圾收集器会将这个线程总数设置为并行垃圾线程数( -XX:ParallelGCThreads)的四分之一;并发标记会利用trace算法找到所有活着的对象,并记录在一个bitmap中,因为在TAMS之上的对象都被视为隐式存活,因此我们只需要遍历那些在TAMS之下的;记录在标记的时候发生的引用改变,SATB的思路是在开始的时候设置一个快照,然后假定这个快照不改变,根据这个快照去进行trace,这时候如果某个对象的引用发生变化,就需要通过pre-write barrier logs将该对象的旧的值记录在一个SATB缓冲区中,如果这个缓冲区满了,就把它加到一个全局的列表中——G1会有并发标记的线程定期去处理这个全局列表。
  • 重新标记阶段(remarking),重新标记阶段是最后一个标记阶段,需要暂停整个应用,G1垃圾收集器会处理掉剩下的SATB日志缓冲区和所有更新的引用,同时G1垃圾收集器还会找出所有未被标记的存活对象。这个阶段还会负责引用处理等工作。
  • 清理阶段(cleanup),清理阶段真正回收的内存很小,截止到这个阶段,G1垃圾收集器主要是标记处哪些老年代分区可以回收,将老年代按照它们的存活度(liveness)从小到大排列。这个过程还会做几个事情:识别出所有空闲的分区、RSet梳理、将不用的类从metaspace中卸载、回收巨型对象等等。识别出每个分区里存活的对象有个好处是在遇到一个完全空闲的分区时,它的RSet可以立即被清理,同时这个分区可以立刻被回收并释放到空闲队列中,而不需要再放入CSet等待混合收集阶段回收;梳理RSet有助于发现无用的引用。

第三、混合收集只会回收一部分老年代分区。

混合收集会执行多次,一直运行到(几乎)所有标记点老年代分区都被回收,在这之后就会恢复到常规的新生代垃圾收集周期。当整个堆的使用率超过指定的百分比时,G1 GC会启动新一轮的并发标记周期。在混合收集周期中,对于要回收的分区,会将该分区中存活的数据拷贝到另一个分区,这也是为什么G1收集器最终出现碎片化的频率比CMS收集器小得多的原因——以这种方式回收对象,实际上伴随着针对当前分区的压缩。

两个模式

G1收集器的模式主要有两种:

  • Young GC(新生代垃圾收集)
  • Mixed GC(混合垃圾收集)

巨型对象的管理

巨型对象在G1中,如果一个对象的大小超过分区大小的一半,该对象就被定义为巨型对象(Humongous Object)。巨型对象分配时直接分配到老年代分区,如果一个对象的大小超过一个分区的大小,那么会直接在老年代分配两个连续的分区来存放该巨型对象。巨型分区一定是连续的,分配之后也不会被移动——没啥益处。

由于巨型对象的存在,G1的堆中的分区就分成了三种类型:新生代分区、老年代分区和巨型分区。

如果一个巨型对象跨越两个分区,开始的那个分区被称为“开始巨型”,后面的分区被称为“连续巨型”,这样最后一个分区的一部分空间是被浪费掉的,如果有很多巨型对象都刚好比分区大小多一点,就会造成很多空间的浪费,从而导致堆的碎片化。如果你发现有很多由于巨型对象分配引起的连续的并发周期,并且堆已经碎片化(明明空间够,但是触发了FULL GC),可以考虑调整 -XX:G1HeapRegionSize参数,减少或消除巨型对象的分配。

关于巨型对象的回收:在JDK8u40之前,巨型对象的回收只能在并发收集周期的清除阶段或FULL GC过程中被回收,在JDK8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在年轻代收集中被回收。

巨型对象分配失败

如果在GC日志中看到莫名其妙的FULL GC日志,又对应不到上述讲过的几种情况,那么就可以怀疑是巨型对象分配导致的,这里我们可以考虑使用 jmap命令进行堆dump,然后通过MAT对堆转储文件进行分析。


jacheut
4 声望1 粉丝

引用和评论

0 条评论