JVM垃圾回收新贵——G1收集器探秘

1个月前发布 gsjqwyl
17 0 0

介绍

G1垃圾收集器在JDK7时开始进行研发,到JDK8基本实现了全部功能,并且成功取代Parallel Scavenge成为服务端模式下的默认垃圾收集器。在JDK9之后,G1成为默认的垃圾收集器,取代了CMS收集器。

G1和CMS一样,也是采用三色标记分段式的回收算法,不过它是通过写屏障结合STAB快照来实现的,后续会详细讲解。

G1收集器的显著特征

  • G1最为突出的特点是引入了分区的思路,在一定程度上淡化了分代的概念。
  • 并行与并发:G1能够充分利用CPU的多核优势,在多核环境下发挥硬件性能,利用多个CPU(或CPU核心)来缩短Stop-The-World的停顿时间。其他一些收集器原本需要让Java线程停止执行的GC操作,G1收集器能够通过并发的方式让Java程序继续运行。
  • 空间整合:与CMS采用的“标记 – 清除”算法不同,G1从整体来看是基于“标记 – 整理”算法实现的收集器,不会产生空间碎片;从局部来看则是基于“标记 – 复制”算法实现的。
  • 可预测的停顿:G1垃圾回收器设定了用户可以控制的停顿时间目标,开发者能够通过设置参数来指定允许的最大垃圾回收停顿时间。G1会依据这个目标动态调整回收策略,尽可能减少长时间的垃圾回收停顿。

如何实现可预测性?

G1会根据历史数据来预测本次回收所需的堆分区数量,也就是确定要回收哪些内存空间。最基础的方法是利用算术平均值建立线性关系来进行预测。例如:过去10次一共收集了10GB的内存,花费了1秒。那么在200毫秒的时间内,最多可以收集2GB的内存空间。而G1的预测逻辑是基于衰减平均值和衰减标准差来确定的。

CMS与G1的差异

  • 在CMS中,堆被划分为PermGen、YoungGen、OldGen,其中YoungGen又划分出两个survivor区域。而在G1中,堆被平均划分为若干个区域(region),虽然每个区域在逻辑上仍保留新老代的概念,但收集器是以整个区域为单位进行收集的。
  • G1在回收内存之后,会立即同时进行合并空闲内存的工作;而CMS默认是在STW(stop the world)的时候进行合并。
  • G1可用于Young GC,而CMS只能用于Old区的回收

分区Region

G1同时对新生代和老年代进行回收,分别被称作G1的Young GC模式和Mixed GC模式。这一特性源于G1独特的内存布局,内存分配不再严格遵循新生代、老年代的划分方式,而是以Region为单位,G1会跟踪各个Region的情况并且维护一个关于Region的优先级列表,在合适的时机选择合适的Region进行回收。这种基于Region的内存划分为实现解决停顿时间和高吞吐的目标提供了基础。接下来我们将详细讲解G1的详细垃圾回收过程以及其中值得关注的设计。

G1运用了分区(Region)的思路,将整个堆空间分割成若干个大小相等的内存区域,每次分配对象空间会逐段地使用内存。所以,在堆的使用方面,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续就行;每个分区也不会固定地为某个代服务,可以根据需求在年轻代和老年代之间转换。启动时可以通过参数-XX:G1HeapRegionSize=n来指定分区大小(1MB~32MB,且必须是2的幂),默认情况下会将整堆划分为2048个分区。在分代垃圾回收算法的思想下,region在逻辑上被划分为Eden、Survivor和老年代。每个分区都有可能是eden区,Survivor区也有可能是old区,但在某一时刻只能是一种分区。各种角色的region个数都不固定,这表明每个代的内存也是不固定的。这些region在逻辑上是连续的,并非物理上连续,这与之前young/old区物理连续的情况有很大不同。

G1对内存的使用以分区(Region)为单位

  • 堆内存会被切分成很多个固定大小的区域(Region),每个区域是连续范围的虚拟内存。
  • 堆内存中一个区域(Region)的大小,可以通过-XX:G1HeapRegionSize参数进行指定,大小区间最小为1M、最大为32M,并且是2的幂次方。
  • 默认是将堆内存按照2048份进行均分。

  • 每个Region被标记为E、S、O和H,这些区域在逻辑上被映射为Eden、Survivor和老年代。存活的对象会从一个区域转移(即复制或移动)到另一个区域。区域被设计用于并行收集垃圾,可能会暂停所有应用线程 。如上图所示,区域可以被分配到Eden、survivor和老年代。
  • 巨型区域(Humongous Region):如果一个对象占用的空间超过了分区容量的50%以上,G1收集器就认为这是一个巨型对象。如果对一个短期存在的大对象使用复制算法进行回收,复制成本会非常高,而直接将其放进old区则会导致原本应该短期存在的对象占用老年代的内存,更不利于回收性能。为了解决这个问题,G1划分出一个Humongous区,专门用于存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

内部数据结构

卡表Card Table

Card Table是Region的内部结构划分。每个region内部被划分为若干个内存块,这些内存块被称为card。这些card的集合被称作card table。

例如下面的例子,region1中的内存区域被划分为9块card,9块card的集合就是卡表card table。

card表能够记录每一块card内存区域是否是dirty状态。当发生YGC时,要知道哪些是存活对象以及其他代区域是否有引用这部分对象,于是将region划分成很多card区域,每个区域大小不超过512b,当该card区域里的对象存在引用关系时,会将当前card置为“dirty”,并且使用卡表(CardTable)来记录每一块card是否dirty,在进行GC时,不需要遍历所有的空间,只需要遍历卡表中为”dirty”的部分。

Rset记忆集合

除了卡表,每个region中都包含Remember Set,简称为RSet。RSet实际上是一个hash表,其中key为引用本region的其他region的起始地址,value为本region中被key对应的region引用的card索引位置。

这里必须讲解一下RSet存在的原因,RSet是为了解决“跨代引用”的问题。设想一下,一个新生代对象被老年代对象引用,那么为了通过引用链找到这个新生代对象,从GC Roots出发遍历对象时必须经过老年代对象。实际上以这种方式遍历时,会把所有对象都遍历一遍。但我们其实只想回收新生代的对象,却把所有对象都遍历了一遍,这显然效率很低。

在YoungGC时,当RSet存在时,会顺着引用链进行查找。如果引用链上出现了老年代对象,那么直接放弃查找这条引用链。当整个GC Root Tracing执行完毕后,就知道了除被跨代引用外还存活的新生代对象。紧接着再遍历新生代Region的RSet,如果RSet里存在key为老年代的Region,就将key对应的value代表的card的对象标记为存活,这样就标记到了被跨代引用的新生代对象。它能够使得垃圾收集器不需要扫描整个堆去找到谁引用了当前分区对象,是G1高效回收的关键所在。

当然这么做会存在一个问题,如果部分老年代对象是应该被回收的对象,但还是跨代引用了新生代,会导致原本应该被回收的新生代对象躲过本轮新生代回收。这部分对象就只能等到后续的老年代的垃圾回收mixed GC来回收掉。这也是为什么G1的回收精度比较低的原因之一。

以这幅图为例,region1和region2都引用了region3中的对象,那么region3的RSet中有两个key,分别是region1的起始地址和region2的起始地址。在扫描region3的RSet时,发现key为0x6a的region是一个old区region。如果这时第3、5card对应的对象没有被标记为可达,那么这里就会根据RSet再次进行标记。同样的,key为0x9b对应的region是一个young区域的region,那么0、2号card的对象则不会被标记。

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

Per Region Table (PRT)

RSet在内部使用Per Region Table (PRT)来记录分区的引用情况。由于RSet的记录会占用分区的空间,如果一个分区非常“受欢迎”,那么RSet占用的空间会增加,从而降低分区的可用空间。G1应对这个问题采用了改变RSet密度的方式,在PRT中将会以三种模式记录引用:

  • 稀少:直接记录引用对象的卡片索引
  • 细粒度:记录引用对象的分区索引
  • 粗粒度:只记录引用情况,每个分区对应一个比特位

由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。

RSet和卡表的区别是什么?

卡表记录的是堆内存中card有没有变成”dirty”,但是它本身不知道dirty里面哪些是引用了的对象,它是一个大维度的记录,RSet是记录自身Region中对象引用了其它Region中的那些对象,详细记录对方引用对象信息,G1使用了两者的结合,实现了增量式的垃圾回收,并优化跨区引用的最终处理。详情可以继续看后文

堆Heap

G1同样可以通过- Xms/-Xmx来指定堆空间大小。当发生年轻代收集或混合收集时,会通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;目标参数- XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。

CSet

Collection SET用于记录可被回收分区的集合组,G1使用不同算法,动态地计算出哪些分区是需要被回收的,将其放到CSet中,在CSet当中存活的数据都会在GC过程中拷贝到另一个可用分区,CSet可以是所有类型分区,它需要额外占用内存,堆空间的1%。

CSet收集示意图

收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

候选老年代分区的CSet准入条件,可以通过活跃度阈值- XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比- XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。

由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。

年轻代收集集合 CSet of Young Collection

应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。

同时,年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。年轻代收集首先先将晋升对象尺寸总和、对象年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量- XX:TargetSurvivorRatio(默认50%)、最大任期阈值- XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。

混合收集集合 CSet of Mixed Collection

年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值- XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。

为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数- XX:G1MixedGCCountTarget(默认8)、堆废物百分比- XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。

Young GC流程

在了解了region的内部结构之后,我们再来看一下G1的young gc的具体流程。

  1. stop the world,整个young gc的流程都是在stw里进行的,这也是为什么young gc能回收全部eden区域的原因。控制young gc开销的办法只有减少young region的个数,也就是减少年轻代内存的大小,还有就是并发,多个线程同时进行gc,尽量减少stw时间。
  2. 扫描GCRoots,这里扫描的GC Roots是一般意义上的GC Roots,是扫描直接指向young代的对象,如果GC Root是直接指向老年代对象的,则会直接停止在这一步,也就是不往下扫描了。被老年代对象指向的young代对象会在接下来利用Rset中key指向老年代的卡表识别出来,这样就避免了对老年代整个大的heap扫描,提高了效率。这也是Rset能避免对老年代整体扫描的原因。
  3. 排空dirty card quene,更新Rset。Rset中记录了哪些对象被老年代跨带引用,也就是当新生代对象被老年代对象引用时,应该更新这个记录到RSet中。但更新RSet记录的时机不是伴随着引用更改马上发生的。每当老年代引用新生代对象时,这个引用记录对应的card地址其实会被放入Dirty Card Queue(线程私有的,当线程私有的dirty card queue满了之后会被转移到全局的dirty card queue,这个全局是唯一的),原因是如果每次更新引用时直接更新Rset会导致多线程竞争,因为赋值操作很频繁,影响性能。所以更新Rset交由Refinement线程来进行。全局DirtyCardQueue的容量变化分为4个阶段

    • 白色:无事发生
    • 绿色:Refinement线程被激活,-XX:G1ConcRefinementGreenZone=N指定的线程个数。从(全局和线程私有)队列中拿出dirty card。并更新到对应的Rset中。
    • 黄色:产生dirty card的速度过快,激活全部的Refinement线程,通过参数-XX:G1ConcRefinementYellowZone=N 指定
    • 红色:产生dirty
© 版权声明

相关文章

没有相关内容!

暂无评论

none
暂无评论...