剖析G1 垃圾回收器

简单回顾

        在Java当中,程序员在编写代码的时候只需要创建对象,从来不需要考虑将对象进行释放,这是因为Java中对象的垃圾回收全部由JVM替你完成了(所有的岁月静好都不过是有人替你负重前行)。

        而JVM的垃圾回收由垃圾回收器来负责,在JDK的不断更新迭代过程中,JVM的垃圾回收器也经历了Serial 垃圾收集器、Serial Old 垃圾收集器、ParNew 垃圾收集器、Parallel Old垃圾收集器、CMS垃圾收集器,以及目前最流行的G1垃圾回收器(也就是本文重点要讲的)。

G1的诞生与发展

        G1垃圾回收器最早在2004年发表论文提出(Garbage-First Garbage collection,doi:10.1145/1029873.1029879),在JDK6 中首次被应用,在JDK7中被正式支持,而到了2019年发布的JDK9中,G1垃圾收集器已经作为了官方默认的垃圾收集器,取代了之前的CMS。

 

G1有什么厉害的地方

        先介绍两个词:硬实时性和软实时性。

        硬实时性(hard real-time):每次处理的时间都不能超过最后期限,比如医疗机器人控制系统、航空管制系统。

        软实时性(soft real-time):稍微超出几次最后期限也没有什么问题的系统,例如网络银行系统。

        G1最大的特点就是非常重视高吞吐量与软实时性的最佳平衡,它让用户来设定期望最大暂停时间(Stop the word),也就是在垃圾回收时停止所有用户线程的时间,G1垃圾收集器可以预测下次 GC 会导致应用程序暂停多长时间。然后根据预测出的结果,G1会通过延迟执行GC、拆分 GC 目标对象等手段来尽量满足用户设置的期望最大暂停时间,默认的暂停目标是 200ms。你想让GC时暂停多久,它就能尽量的满足你。

G1垃圾收集器的应用场景最好需要包含以下特性(满足这些特性的话,则可能更适合G1出马,否则可能其他GC更合适):

  • 堆内存大小超过10G,且存活对象占用比例超过50%

  • 对象分配和晋升速率可能随时间有显著变化

  • 堆中存在大量碎片

  • 预测的最大停顿时间不超过几百毫秒

G1有什么特别牛逼的地方呢?

        首先,以往的 GC 都是尽可能缩短最大暂停时间,缩短最大暂停时间很容易导致吞吐量下降。当然我们肯定希望是暂停时间越短约好呀,但是暂停时间过短很可能会导致频繁发生GC,从而把CPU全部都打满了,也就是吞吐量降低。所以并不是暂停时间越短机器性能越好。另外以往的 GC 无法预测暂停时间,GC 时可能会使应用程序长时间暂停的风险。G1的目的就是高效地实现软实时性,能够让用户设置期望暂停时间。在确保吞吐量比以往的 GC 更好的前提下,实现了软实时性。最大程度利用服务器上多处理器的优势,而且在处理巨大的堆时,也不会降低 GC 的性能。

G1垃圾收集模型

堆内存划分

        G1垃圾收集器采取了和之前所有的垃圾收集器完全不一样的思路,可以说是一个开拓者。别人都在研究如何让马车能够把车拉的更快(改良马车结构,马车上多绑几匹马),G1相当于发明出来了蒸汽机来替代马车。

 

        G1垃圾收集器开创了面向局部收集的设计思路和基于Region的内存布局形式。G1不再坚持固定大小以及固定数量的分代区域划分,而将内存结构划分成如图所示大小相等的块状区域,称为region,region是内存分配和内存回收的最小单位,每个Region都可以成为 Eden空间、Survivor空间、老年代空间。region的大小可以由用户进行调整,但是内部会将用户设置的值向上调整为 2 的指数幂来作为区域的大小。

        图中绿色区域指代的是新生代中的伊甸园区(Eden),蓝色区域指代的是老年代(old generation),标注为S的橙色区域的为Suvivor Region,标注为H的蓝色区域为Humoungous Region(巨型对象,也属于老年代),灰色的区域为空闲区。我们从图中对象占用的内存都不是连续的,并且任何一个区域都没有特定的年代划分,可以将它分配成新生代也可以分配为老年代。由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。这是G1和其他收集器在堆内存结构上的最大差别,也是为什么G1更适合处理堆中存在大量碎片场景的原因。

 

卡表与卡页

        JVM将堆内存划分为了2次幂大小的卡页(Card Page),使用卡表来记录整个内存的状态。默认情况下,卡页的大小为512B。卡表(Card Table)是由1B组成的数组,卡表里的元素称为卡片(Card),每个卡片对应堆内存中的一个卡页,卡表中的卡片与卡页的映射关系:卡片数组位置为堆内存地址除卡页大小(向下取整)。在堆大小是 1GB 时,卡表大小为 2MB。

        堆中对象所对应的卡片在卡表的索引值 = (对象的地址 - 堆的头部地址) / 512

        因为卡片的大小是 1B,所有可以表示其对应卡页所处的很多状态,在后面只涉及到其中的两种:净卡片和脏卡片。

记忆集(RSet)

        记忆集英文全称为RemerberSet,简称RSet,是一种抽象概念。其核心思想是通过卡表,记录对象在不同代际间的引用关系,加速垃圾回收的速度。

        JVM会通过可达性分析算法标记存活对象来帮助进行垃圾回收,不过在分代GC中,新生代和老年代处于不同的回收阶段,如果仅仅只需要回收新生代,却标记了老年代的对象,那么这无疑是不必要的。但是如果只回收新生带的时候,新生代的对象有被老年代引用的情况,也就是出现跨区引用时,只扫描新生代显然是不行的。所以JVM设计了RSet这样的玩意来避免这种现象,将跨带引用的信息都记录到对应的记忆集中,使其即便不扫描全部对象,也可以查到待回收对象所在分区被其它分区引用的情况。

        每个Region中都会开辟一小块的区域作为记忆集的存储位置,记忆集的结构为一个Hash表,Key为引用本分区的其它分区的地址,Value是卡片(Card)在卡表的索引值,即分区中的哪些卡页引用了本分区。

例如在下图中,区域 B 中的对象 b 引用了区域 A 中的对象 a。因为对象 b 不是区域 A 中的对象,所以必须记录这个引用关系。在记忆集合 A 中,以区域 B 的地址为键记录了卡片的索引 2048(对象 b 对应的卡片索引),此时对象 b 对对象 a 的引用被准确记录了下来。

 

那么是否是所有的引用关系都需要记录到记忆集中呢?答案不是的,分为以下几种情况来进行讨论。

无需记录引用关系:

  • Region内部间的引用:无需记录引用关系,Region是内存分配和回收的最小单位,要么都回收,要么都不回收,所以不需要进行记录。

  • 新生代分区到新生代分区的引用:无需记录引用关系,无论哪种类型的回收,新生代都会全量回收。

  • 新生代分区到老年代分区的引用:无需记录引用关系,对于YoungGC来说,针对的新生代,则无需关心;对于混合GC来说,会使用新生代分区作为根,那么遍历所有新生代分区自然能找到老年代;对于FullGC来说,所有分区都会被清理,无需关心引用关系。

需要记录引用关系:

  • 老年代分区到新生代分区的引用:需要记录引用关系,新生代回收时,有两种根,一种是栈空间/全局变量的引用,另一种便是老年代到新生代的引用

  • 老年代分区到老年代分区的引用:需要记录引用关系,混合回收时可能只回收部分Region,需要记录引用关系,快速找到活跃对象

写屏障

记忆集的主要作用是记录跨Region的引用关系,为保证其正确性,那么当引用关系变化时,我们需要及时更新记忆集,而记忆集写屏障则负责对记忆集进行更新。

每个应用程序线程都持有一个转移专用记忆集合日志的缓冲区,其中存放的是卡片索引的数组。当对象的域被修改时,写屏障就会感知,并会将对象 所对应的卡片索引添加到转移专用记忆集合日志中。

主要步骤:

  1. 从转移专用记忆集合日志的集合中取出转移专用记忆集合日志,从头开始扫描

  2. 将卡片变为净卡片

  3. 检查卡片所对应存储空间内的所有对象的域

  4. 向域中地址所指向的区域的记忆集合中添加卡片

G1 垃圾回收过程

        G1的GC主要分为Young GC和Mixed GC两种模式,有些特殊场景可能会发生Full GC。不过如果按照垃圾回收的阶段来划分的话,G1的垃圾回收过程只包含两个阶段,Young-Only和Space Relcaimation阶段。

        在Young Only阶段,G1只会回收新生代内存,即新生代回收,在Space Reclamation阶段,G1除了会全量回收新生代内存,还会回收老年代区域,即混合回收。Full GC是一种特殊的兜底回收逻辑,此处不考虑进来。所以G1的垃圾回收其实不是我们所想的Young GC和Mixed GC穿插进行,而是Young GC 持续一段时间,Mixed GC 再持续一段时间。

        图中的圆圈表示G1回收过程中的暂停:蓝色圆圈表示Young-only GC导致的暂停,红色圆圈表示Mixed GC导致的暂停,黄色圆圈表示有并发标记导致的暂停。

全局并发标记

并发标记的时机是在Young GC后时,只有达到InitiatingHeapOccupancyPercent阈值后,才会触发并发标记。InitiatingHeapOccupancyPercent默认值是45。

整个过程分为四个步骤:

初始标记:标记处所有跟GC Roots直接关联的对象,这一阶段STW,并且耗时很短,主要是修改TAMS指针的值,让下一阶段分配对象能够使用Region内存。

并发标记:从GC Roots对堆中的对象进行可达性分析, 找出存活的对象,并发标记阶段产生的新的引用会被SATB的写屏障记录下来,并且还会定期更新和处理SATB局部缓存表的信息和记录,如果发现某一个Region中所有都是垃圾,那么就直接进行回收。

重新标记:标记在并发期间因为程序运作而改变的引用对象。

清除:进行价值衡量,回收最优价值的Region区。

转移存活对象

在并发标记完成后,G1能筛选出所有候选的回收集,并根据用户定义的期望最大停顿时间,筛选本次转移真正的回收集(CSet),标记回收集中的存活对象,将这些对象转移,完成垃圾回收。

参数介绍

参数

说明

-XX:+UseG1GC

使用 G1 收集器

-XX:G1HeapRegionSize=n

设置 G1区域Region的大小。范围从1 MB 到32MB之间,目标是根据最小的 Java 堆大小划分出大约2048个区域。

-XX:MaxGCPauseMillis=200

设置最长暂停时间目标值,默认是200毫秒

-XX:G1NewSizePercent=5

设置年轻代最小值占总堆的百分比,默认值是5%

-XX:G1MaxNewSizePercent=60

设置年轻代最大值占总堆的百分比,默认值是java堆的60%

-XX:ParallelGCThreads=n

设置STW并行工作的GC线程数,一般推荐设置该值为逻辑处理器的数量,最大是8;如果逻辑处理器大于8,则取逻辑处理器数量的5/8;这适用于大多数情况,除非是较大的SPARC系统,其中的n值可以是逻辑处理器的5/16

-XX:ConcGCThreads=n

并发标记阶段,并发执行的线程数,一般n值为并行垃圾回收线程数(ParallelGCThreads)的1/4左右

-XX:InitiatingHeapOccupancyPercent=45

设置触发全局并发标记周期的Java堆内存占用率阈值,默认占用率阈值是整个Java堆的45%

-XX:G1MixedGCLiveThresholdPercent=85

老年代Region中存活对象的占比,只有当占比小于此参数的Old Region,才会被选入CSet。这个值越大,说明允许回收的Region中的存活对象越多,可回收的空间就越少,gc效果就越不明显

-XX:G1HeapWastePercent=5

设置G1中愿意浪费的堆的百分比,如果可回收region的占比小于该值,G1不会启动Mixed GC,默认值10%,主要用来控制Mixed GC的触发时机。在global concurrent marking结束之后,我们可以知道老年代regions中有多少空间要被回收,在每次YGC之后和再次Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会触发Mixed GC。

-XX:G1MixedGCCountTarget=8

一次全局并发标记后,最多执行Mixed GC的次数,次数越多,单次回收的老年代的Region个数就越少,暂停也就越短

-XX:G1OldCSetRegionThresholdPercent=10

一次Mixed GC过程中老年代Region内存最多能被选入CSet中的占比

-XX:G1ReservePercent=10

设置作为空闲空间的预留内存百分比,用来降低目标空间溢出的风险,默认是10%,一般增加或减少百分比时,需要确保也对java堆调整相同的量。

实例参考

下面是一台部署线上服务的4核10G的服务器的GC情况,其使用的垃圾回收器为G1:

 如图可得该实例在最近两小时共产生16次GC,平均每次间隔七八分钟。

 

        每次GC耗时没有超过50ms,因此每分钟的吞吐量也超过了99%,符合MaxGCPauseMillis=200这个配置,所有的停顿时间都没有超出目标值。

         堆内存大约在到达4G的时候开始被回收,符合InitiatingHeapOccupancyPercent=40这个配置,在堆内存达到10*40%=4G的时候开始触发标记周期,然后进行内存回收工作.

猜你喜欢

转载自blog.csdn.net/zzu_seu/article/details/129348848