G1 垃圾收集器详解

1,187 阅读15分钟

G1 垃圾收集器原理详解


theme: smartblue

1 G1的内存模型

传统的GC收集器将连续的内存空间划分为新生代、老年代。

而G1虽然还保留了新生代和老年代的逻辑概念,但不再是物理隔离的,各代存储地址是不连续的。

image.png

1.1 Region

G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。 每个分区Region也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。

参数

-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将为2048。

1.2 Card 卡片

在每个分区Region 内部又被分成了若干个大小为512 Byte的卡片(Card),标识堆内存最小可用粒度。 分配的对象会占用物理上连续的若干个卡片。 当查找对分区Region 内对象的引用时便可通过记录卡片来查找该引用对象。 每次对内存的回收,都是对指定分区的卡片进行处理。

1.3 Card Table 卡表

每个Region就有一个卡表来映射Regin中的卡页,整堆有个全局卡片表(Global Card Table) 存储所有Region的卡表情况。 每一个Region的Card,都用一个Byte来记录是否修改过。卡表即这些byte的集合。实际上,如果把RS理解成一个概念模型,那么 Card Table 就可以说是RS的一种实现方式。

image.png

1.4 分区模型

1.4.1 收集集合 CSet

Collect Set(CSet)是指,在Evacuation阶段,由G1垃圾回收器选择的待回收的Region集合。G1垃圾回收器的软实时的特性就是通过CSet的选择来实现的。

CSet:收集集合(CSet) 是一组可被回收的分区的集合。 在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自eden空间、survivor空间、或者老年代。

分代G1模式下选择CSet有两种子模式, 分别对应Young GC和Mixed GC:

Young GC: CSet就是所有年轻代里面的Region; Mixed GC: CSet是所有年轻代里的Region加上在全局并发标记阶段标记出来的收益高的Region;

1.4.2 记忆集RSet

在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。G1收集器中,虚拟机使用Remembered Set来避免全堆扫描(整个老年代加进GC Roots扫描范围)。在回收一个Region的时候不需要执行全堆扫描,只需要检查它的RS就可以找到外部引用,而这些引用就是initial mark的根之一。

在每个分区Region记录了一个RSet,内部类似一个反向指针,记录引用分区Region内对象的卡片Card的索引(记录哪些Region中的对象指向了当前分区中的对象)。每个Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是 xx Region的 xx Card。。

当要回收该分区Region时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活。 事实上,并非所有的引用都需要记录在RSet中,如果一个分区Region确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。 G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet记录。 最后只有老年代的分区Region可能会有RSet记录,这些分区称为拥有RSet分区(an RSet’s owning region)。

image.png

2 G1 回收阶段

image.png

G1的垃圾回收包括了以下几种:

  • Young Collection (YGC,年轻代收集,STW

  • Concurrent Marking Cycle (并发标记)

  • Mixed Collection Cycle (混合收集)

  • Full GC(FGC, STWJDK10以前FGC是串行回收,JDK10+可以是并行回收。

1.1 Young GC

Eden区域满了后触发,并行收集,且完全STW。 YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做YoungGC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发YoungGC。

(1)第一阶段,根扫描:

根是指static变量指向的对象、正在执行的方法调用链上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。

(2)第二阶段,更新RSet:

处理 dirty card 队列中的 card,更新 RSet,此阶段完成后,RSet 可以准确的反映老年代对所在的region 分区中对象的引用

(3)第三阶段:处理RSet:

识别被老年代对象指向的 Eden 中的对象,这些被指向的Eden中的对象被认为是存活的对象

(4)第四阶段:对象拷贝:

将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到年老代空间。

(5)第五阶段:处理引用:

处理软引用、弱引用、虚引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的、没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

1.2 Mixed GC

当老年代占用空间超过整堆比 IHOP 阈值 -XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾回收Mixed GC,Mixed GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

1.2.1 Concurrent Marking Cycle

在进行混合回收前,会先进行 global concurrent marking, 它的第一个阶段初始化标记和YGC一起发生,这个周期的目的就是找到回收价值最大的Region集合(垃圾很多,存活对象很少).在 G1 GC 中,它并不是一次GC过程的必须环节,主要是为 Mixed GC 提供标记服务的.

全局并发标记又分为5步:

初始标记 initial-mark 需要STW

标记了从GC Roots开始直接可达的根对象。初始标记是借用了新生代收集的结果,即新生代垃圾回收后的新生代Survivor分区作为根,所以混合收集一定发生在新生代回收之后,且不需要再进行一次初始标记。在gc日志中表现为GC pause (young)(inital-mark).

并发根区域扫描 Root Region Scanning

G1 GC扫描Survivor区直接可达的老年代区域对象, 并标记被引用的对象。这一过程必须在young GC之前完成(YoungGC时,会动Survivor区,所以这一过程必须在young GC之前完成)

并发标记Concurrent Marking 不需要STW

当YGC执行结束之后,如果发现满足并发标记的条件,并发线程就开始进行并发标记。根据新生代的Survivor分区以及老生代的RSet开始并发标记。时机是在YGC后,只有达到InitiatingHeapOccupancyPercent阈值后,才会触发并发标记。

重新标记Remark 需要STW

用来标记那些在并发标记阶段引用发生变化的对象。G1中采用了比CMS更快的初始快照算法:snapshot一at一the一beginning (SATB).

独占清理cleanup STW 计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。

1.2.2 Mixed Collection Cycle

当G1发起全局并发标记之后,并不会马上开始混合收集,G1会先等待下一次年轻代收集,然后在该 young gc 收集阶段中,确定下次混合收集的CSet.

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收。

混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区 内存分段,Survivor区内存分段。 混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。

由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,XX:G1MixedGCLiveThresholdPercent,默认为85% , Region 中的存活对象低于这个值时オ会回收该 Region。如果存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定 要进行8次。有一个阈值G1HeapWastePercent, 默认值为5%,意思是允许整个堆内存中有5%的空间被浪费,如果可回收百分比低于这个百分比,那么G1就不会触发Mixed GC。因为GC会花费很多的时间但是回收到的内存却很少。

1.3 Full GC

1.3.1 转移失败的担保机制 Full GC

转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程(JDK10支持多线程)的Full GC。

Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。

G1在以下场景中会触发Full GC,同时会在日志中记录to-space exhausted以及Evacuation Failure

  • 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区

  • 从老年代分区转移存活对象时,无法找到可用的空闲分区

  • 分配巨型对象Humongous Object 时在老年代无法找到足够的连续分区

1.3.2解决方案

  • 不要过度加一些jvm参数。比如-Xmn,这个参数会限制G1的参数的自动扩展。删除任何额外的堆大小,例如-Xmn,-XX:NewSize,-XX:MaxNewSize,-XX:SurvivorRatio等。

  • 如果问题仍然存在,则增加JVM堆大小(即-Xmx)。

  • 如果无法增加堆大小,并且注意到marking cycle没有足够早地开始回收老一代,那么请减少-XX:InitiatingHeapOccupancyPercent,默认值是45%。减小该值将提前开始marking cycle 。

  • 如果并发marking cycle准时开始,但需要很长时间才能完成,那么使用属性'-XX:ConcGCThreads'增加并发标记线程数的数量。默认是GC Workers: 1 ,单线程执行。

  • 如果有大量 空间耗尽(to-space exhausted)或空间溢出(to-space overflow)GC事件,则增加-XX:G1ReservePercent。默认值是Java堆的10%。注意:G1 GC将此值限制在50%以内。

3 扩展

3.1 InitiatingHeapOccupancyPercent,

简称IHOP,默认45%。表示在YGC之后,如果使用内存超过总内存的45,那么并发线程就会进行并发标记(简单理解为要开始Mixed GC了)。

InitiatingHeapOccupancyPercent的含义有两种说法

  • 1 整个堆的内存使用超过总容量的45%
  • 2 老年代的内存使用超过总容量的45%

官方文档来看是表示整个堆的内存使用超过总容量的45%。

  • JDK8文档

    -XX:InitiatingHeapOccupancyPercent=percent

    Sets the percentage of the heap occupancy (0 to 100) at which to start a concurrent GC cycle. It is used by garbage collectors that trigger a concurrent GC cycle based on the occupancy of the entire heap, not just one of the generations (for example, the G1 garbage collector).

    By default, the initiating value is set to 45%. A value of 0 implies nonstop GC cycles. The following example shows how to set the initiating heap occupancy to 75%:-XX:InitiatingHeapOccupancyPercent=75

  • JDK11文档

    -XX:InitiatingHeapOccupancyPercent=n

    Sets the Java heap occupancy threshold that triggers a marking cycle. The default occupancy is 45 percent of the entire Java heap. Percentage of the (entire) heap occupancy to start a concurrent GC cycle. It is used by GCs that trigger a concurrent GC cycle based on the occupancy of the entire heap, not just one of the generations (e.g., G1). A value of 0 denotes ‘do constant GC cycles’.

源码验证 参数意义在分代式G1从来没改变过,表示整个G1使用量的百分比。只是使用量的定义改变了: 在JDK8b12之前,包括young gen 和old gen,之后使用量只是 old gen(包括humongous region)的容量

  • JDK8(8u192-b12)
//globals.hpp 

product(uintx, InitiatingHeapOccupancyPercent, 45,                        
          "Percentage of the (entire) heap occupancy to start a "           
          "concurrent GC cycle. It is used by GCs that trigger a "          
          "concurrent GC cycle based on the occupancy of the entire heap, " 
          "not just one of the generations (e.g., G1). A value of 0 "       
          "denotes 'do constant GC cycles'.")

  • JDK11
//gc_globals.hpp 

product(uintx, InitiatingHeapOccupancyPercent, 45,                        
          "The percent occupancy (IHOP) of the current old generation "     
          "capacity above which a concurrent mark cycle will be initiated " 
          "Its value may change over time if adaptive IHOP is enabled, "    
          "otherwise the value remains constant. "                          
          "In the latter case a value of 0 will result as frequent as "     
          "possible concurrent marking cycles. A value of 100 disables "    
          "concurrent marking. "                                            
          "Fragmentation waste in the old generation is not considered "    
          "free space in this calculation. (G1 collector only)")            
          range(0, 100)

结论

  • JDK版本在8b12之前,是整个堆使用量与堆总体容量的比值
  • JDK版本在8b12之后(包括大版本9、10、11…)是老年代大小与堆总体容量的比值

改变之后,G1触发global concurrent marking的条件变得更加关心old gen什么时候会变得无法扩张,而不只是简单的看整堆剩余容量。毕竟global concurrent marking的目的是为了让G1 mixed GC可以找出适合的old gen region来收集,必须在old gen变得无法扩张(也就基本无法收集)之前完成marking。

3.2 SATB(Snapshot At the Begging)

3.2.1 三色标记

要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象:把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:

  • 白色:尚未访问过。
  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色

image.png

当STW时,对象间的引用 是不会发生变化的,可以轻松完成标记。
而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化多标漏标的情况就有可能发生。

image.png

  • 浮动垃圾(多标):将原本应该被清除的对象,误标记为存活对象。后果是垃圾回收不彻底,不过影响不大,可以在下个周期被回收;
  • 对象消失(漏标):将原本应该存活的对象,误标记为需要清理的对象。后果很严重,影响程序运行,是不可容忍的。

3.2.2解决漏标

漏标必须要同时满足以下两个条件:

  • 赋值器插入了一条或者多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

image.png 这两个条件必须全部满足,才会出现对象消失的问题。那么我们只需要对上面条件进行破坏,破坏其中的任意一个,都可以防止对象消失问题的产生。这样就产生了两种解决方案:

  • 增量更新:Incremental Update。
  • 原始快照:Snapshot At The Beginning,SATB。

增量更新 增量更新破坏的是第一个条件,在新增一条引用时,将该记录保存。实际的实现中,通常是将引用相关的节点进行重新标记

原始快照 原始快照破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,并发扫描结束后,在将这些记录重新扫描一次。

读写屏障 无论是增量更新对引用关系记录的插入与原始快照对引用关系的删除记录都是通过写屏障来实现的。 对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新: 当某个对象的的成员变量的引用发生变化时,比如新增引用,我们可以利用写屏障,将a的成员变量引用对象b记录下来。
  • G1:写屏障 + SATB(原始快照): 当对象的成员变量的引用发生变化时,比如引用消失,我们可以利用写屏障,将b原来成员变量的引用对象c记录下来。
  • ZGC:读屏障

3.2.3 为什么G1用SATB?CMS用增量更新?

  • 增量更新:黑色对象新增一条指向白色对象的引用,那么要进行深入扫描白色对象及它的引用对象。

  • 原始快照:灰色对象删除了一条指向白色对象的引用,实际上就产生了浮动垃圾,好处是不需要像 CMS 那样 remark,再走一遍 root trace 这种相当耗时的流程。

SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。