细说jvm(七)、垃圾回收器G1

1,544 阅读17分钟

之前的文章

1、细说jvm(一)、jvm运行时的数据区域

2、细说jvm(二)、java对象创建过程

3、细说jvm(三)、对象创建的内存分配

4、细说jvm(四)、垃圾回收算法

5、细说jvm(五)、垃圾回收器入门

6、细说jvm(六)、垃圾回收器CMS详解

上篇讲了CMS的工作原理,这篇我们开始说说G1,文章依然会有一定的难度,不建议跳过基础篇来看这里

一、分区&&分代

1、分代的目的以及缺点

我们在之前的第四篇中说过,分代回收的目的是避免一次性扫描整个堆取而代之的是一次去扫描某个代,这样可以减少垃圾回收所花的时间。但是当jvm堆内存非常大的时候,比如说64G,这时候不管是老年代还是年轻代都会有几十G的空间,这时候即使是分代,每次扫描的空间也会非常大,从而造成较长时间的停顿(记得第五篇最后说过的不可能三角吗),在这些年,随着应用内存越来越大,这个问题也越来越严重。

2、分区的进步之处

因为单纯的分代并不能解决在内存大的时候每次扫描的空间过大,所以从G1开始,不再采用传统的物理空间分代的方式,而是采用了分区的方式。形象一点说就是把堆内存分成了若干个很小的区域,每次垃圾回收的时候只需要扫描其中的一些区即可,这样就能避免在整体堆内存很大的时候需要扫描过大的内存空间进而引起的停顿时间过长的现象。从分区设计开始出现之后,jvm的垃圾回收往前进了一大步,这时候,设计者们意识到,并不需要每次都完整的扫描某个代的内存空间,而只要垃圾回收的速度能跟得上内存分配的速度即可,也是从这时候开始,垃圾回收器也变得更智能,更好用。

更智能、更好用并不代表内部不复杂,我们觉得好用的原因是设计者背后做出了更多的努力以及更加巧妙的设计。
3、分代和分区理解的误区

在之前面试的时候我问过一些候选者,分区就会不分代吗?这其实是个非常简单的问题,但是发现很多候选者都没有搞清楚,有好几个居然都说G1分区是没分代的。这里需要说一下的是,分区是和分代毫不冲突,它们确实是有相同的目的,都是为了去避免一次性扫描过大的内存空间(每次扫描太大的内存空间容易造成长时间的停顿),但是所使用的手段是完全不一样的,分代是根据对象的年龄大小将内存划为年轻代和老年代的方式,避免对那些可能不需要扫描的区域去进行频繁的扫描回收来减少的,这一点体现在老年代gc和年轻代gc的频率是完全不一样体现出来的(年轻代会经常gc,但是老年代相对稳定);分区是通过控制每次扫描的空间大小来减少每次扫描空间的大小。所以说,两者完全是可以结合在一起的。

二、G1的Region以及RSet

1、Region

G1将堆内存空间分成等分的多个Region,物理上不一定连续,逻辑上构成连续的堆地址空间。同时每个Region被标记成E、S、O、H,分别表示Eden、Survivor、Old、Humongous。其中E、S属于年轻代,O与H属于老年代。G1分布如下图: 当分配的对象大小大于等于Region的一半的时候就会被放到H区,这是为了防止GC时候大对象的拷贝。当找不到一块足够大的连续区域存放大对象的时候,就会触发一次full gc。

2、RSet和Card

假如在young gc的时候,young区有的对象还引用着老年代的对象,这时候为了避免扫描所有老年代,G1的每个region维护了一个Remember Set用来记录外部Old区指向本Region的所有引用,简称RSet,这样在gc的时候去扫描RSet中的对象即可。

注意,RSet维护的是外部Old到本Region的引用,为什么是外部Old,而没包含young呢?这是因为young区变
化本来就很大,根本没必要去记录,如果有引用,直接去扫描整个那边的young区的region就行。

Card指的是每个Region会被分成若干个Card,每个Card的大小是512K。为什么说这个Card呢?这是因为每个RSet记录的其实是Card,比如说Region1中有Region2中某对象的引用,这个引用是在Region1的Card1上面,则这里Region2的RSet记录的就是Region1的Card1的地址。Region和RSet和Card的关系可以用下面的图来表示:

三、工作过程

1、几个工作模式

由于G1是能够兼顾整个堆内存的回收器,因此在工作过程中,会使用不同的模式来来针对不同的区域被对象填充满的问题。

young gc

这种是最容易理解的一种模式,就是当E区域满了的时候就会发生的gc,这时候会清空E区域。回收前和回收后见如下两个图:

我们可以看出来,回收之后还有S区,只是E区都被清空了。

G1的young gc其实也有类似于其他回收器的几个过程,比如root scanning,update RS,scan RS,object copy这几个过程,这几个过程在gc日志上面能够很好的体现出来。其中object copy一般耗时是最长,young gc中最耗时的一般都是object copy这个阶段,停顿时间主要是在这里。

mixed gc

这个模式的中文名是叫做混合回收,之所以这么叫是因为这个模式下会同时回收所有的young区和部分old区。为什么是会选部分old区而不是全部呢?这是因为G1有个很强大的点,它建立了一个可预测的停顿模型,我们可以使用-XX:MaxGCPauseMillis来控制我们期望停顿的时间,G1会尽可能的满足我们要的这个时间,进而选出相应的region来进行回收,每次gc完后会去统计对应区垃圾的数量,在后边的mixed gc中会优先回收之前认为垃圾多的区域,来达到一个最大的回收收益,这也是Garbage first名称的由来。mixed gc的触发条件也可以由我们使用XX:InitiatingHeapOccupancyPercent这个参数来控制,它的值默认是45,也就是当老年代占用达到百分之四十五的时候就会触发mixed gc。

mixed gc的过程分为以下几个关键步骤:

1、全局并发标记(global concurrent marking)

全局并发标记又可以进一步细分成下面几个步骤:

  • 初始标记(initial mark,STW)。它标记了从GC Root开始直接可达的对象。初始标记阶段借用young GC的暂停,因而没有额外的、单独的暂停阶段。
  • 并发标记(Concurrent Marking)。这个阶段从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息。
  • 最终标记(Remark,STW)。标记那些在并发标记阶段发生变化的对象,将被回收。
  • 清除垃圾(Cleanup,部分STW)。这个阶段如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。 清除空Region。
是不是看起来和CMS很相似,但还是需要区分的,其中细看的话,初识标记这块差别是比较大的,这里的初识标记和young gc是共用暂停时间的。然后就是清
除这块,CMS是并发的,但是G1是STW的,因为这时候要进行object copy,如果和用户线程并发的话,这个工作的准确性就会变得难以保证。
2、拷贝存活对象(Evacuation)

Evacuation阶段是全暂停的。它负责把一部分region里的活对象拷贝到空region里去(并行拷贝),然后回收原本的region的空间。Evacuation阶段可以自由选择任意多个region来独立收集构成收集集合(collection set,简称CSet),CSet集合中Region的选定依赖于上文中提到的停顿预测模型,该阶段并不evacuate所有有活对象的region,只选择收益高的少量region来evacuate,这种暂停的开销就可以在一定范围内可控(指的是我们给的MaxGCPauseMillis参数中的值)。

full gc

当回收的速度赶不上分配的速度的时候,就会触发full gc。值得一提的是,其实G1并没有full gc的模式,当发生full gc的时候,是用的serial来进行全堆的回收的,这时候会引发长时间的暂停。根据作者自己的经验来看,当我们使用G1的时候,如果配置合理并且代码是没有问题的,那么full gc其实会非常少,G1没那么容易full gc,它不像CMS有这样那样的缺陷导致它会发生full gc。

2、标记过程中出现的问题及解决方式

我们之前在介绍算法的那篇中说过,在并发标记的过程中会发生引用被改变的这种情况,那时候我们说了读屏障和写屏障这两个解决方式。G1这里使用的是SATB(Snapshot At The Beginning)来解决,这个玩意比较复杂,简单的说,就是在gc的过程中,如果对象的引用发生了变化,就将这个对象的引用放到一个队列中,然后重新标记的时候去以这个队列中的引用为出发点去标记扫描,通过这种方式,保证了在并发标记中的正确性,而这个队列就叫做satb_mark_queue。这里引用以下R大的话:

其实只需要用pre-write barrier把每次引用关系变化时旧的引用值记下来就好了。这样,
等concurrent marker到达某个对象时,这个对象的所有引用类型字段的变化全都有记录在案,就不会漏掉任何
在snapshot里活的对象。当然,很可能有对象在snapshot中是活的,但随着并发GC的进行它可能本来已经死了,
但SATB还是会让它活过这次GC。CMS的incremental update设计使得它在remark阶段必须重新扫描所有线程栈
和整个young gen作为root;G1的SATB设计在remark阶段则只需要扫描剩下的satb_mark_queue ,解决了
CMS垃圾收集器重新标记阶段长时间STW的潜在风险。

但是如果在并发标记中出现了新对象怎么办?这里还使用了叫做TAMS(Top at Mark Start)的指针,G1给每个Region都设置了个这玩意,这玩意就是Region的一部分空间,用来存储并发过程中新对象的分配的,G1回收器默认是认为这些对象都是存活对象,不会将这些对象纳入回收范围。

3、可预测的停顿模型的建立

我们前面说过,可以通过-XX:MaxGCPauseMillis来控制我们期望的停顿的时间,但是G1是怎么满足我们的期望呢?G1这里是通过衰减平均值来做到的,G1在垃圾回收的过程中,会去记录每个Region在这次垃圾回收中的各项指标,比如脏卡的数量、回收耗时、存活对象数量等,并去计算这些指标上的一些统计学数据,进而得出回收哪些区域的收益最大,下次回收的时候就在这些里面去选出收益最大的若干区域进行回收(注意,这些region都是老年代的region)。

其实这里还有个知识点“衰减标准偏差”,这玩意是个统计学的知识,为了降低文章的难度,我不在这里提这个点,有兴趣的读者可以自行去了解一下这个。

4、常见误区

  • 停顿时间和吞吐量之间的权衡:千万不要很天真的以为-XX:MaxGCPauseMillis设置的越小就是越好的,因为如果停顿的时间过短,就会造成每次清理的空间过小,进而导致回收器必须进行多次回收,从而导致吞吐量的降低,另外如果这个时间过低,会造成垃圾的堆积,最终导致full gc,从而产生更长的停顿时间。
这个参数就像我们在使用CMS时候的这个-XX:+UseCMSComPackAtFullCollection参数一样,并不是越小越
好,或者是越大越好,而是需要根据实际压测的情况来进行取值,一般来说,就是几百毫秒的范围。
  • 和CMS的比较:在深入理解java虚拟机这本书上面有着这样的原话(我看的是第三版),目前在小内存应用上CMS的表现仍大概率好于G1,而在大内存应用上面G1能发挥其优势,而这个优劣势的堆容量平衡点是6-8GB之间。我们项目组所使用的jdk版本是1.8.0_20,我们uat环境的jvm内存我仅仅给了3G,但是经过我的测试,这时候G1无论是从停顿时间还是gc发生的次数来看,都比CMS优秀太多,所以这里推荐大家在1.8之后,还是尽可能的去选择G1来作为生产环境的回收器。

三、常见参数

除过上面的参数之外,G1还有如下比较重要的参数:

  • -XX:G1HeapRegionSize=n 这个是设置每个region的大小,这个取值范围是1-32MB,且必须是2的指数,如果不设定,G1将会根据堆的大小来确定。
  • -XX:G1NewSizePercent 新生代大小的最小值,这里最小取5。
  • -XX:G1MaxNewSizePercent 新生代大小的最大值,这里最大可以取60。
很多人会比较懵逼上面两个参数是怎么回事。因为G1的年轻代和老年代的比例并非一成不变的,这其实是个变化的值,这点尤其需要注意。
  • -XX:ParallelGCThreads 这个是STW的时候,在回收的线程的数量,这里建议最好是给成和CPU数量相等的值,不要超过这个值。
  • -XX:ConcGCThreads=n 这个是并发标记阶段,并行执行的线程数,这里建议使用默认值。
  • -XX:InitiatingHeapOccupancyPercent 这个是触发标记的java堆内存占比(这里的堆内存占比是指非年轻代,具体指的是o+h区),这里的默认值是45,这个值比较保守,一般建议这个值可以适当的给大一些。
  • -XX:G1ReservePercent 这个是保留的堆的大小,意思是在回收时侯预留出来一些堆空间给新的对象分配用,默认是10。

四、日志分析

GC日志确实是个非常烦人的东西,每个GC回收器的日志都不一样,虽然作者自己看日志看的比较多,但是其实也记不住,很多时候也需要翻翻自己的笔记,这里
建议大家其实也没必要专门去记这种东西,自己记录笔记或者找个靠谱的博客收藏下来以后对照着看即可。

这里拿了我们线上的日志中的一部分来做分析,解释见我的汉语注释

// 这里可以看出来本次暂停的原因是young gc,暂停的总时间是0.0824483秒
2021-01-26T09:56:58.661+0800: 61596.688: [GC pause (G1 Evacuation Pause) (young), 0.0824483 secs]
// 可以看到gc线程是4个,因为我设置了-XX:ParallelGCThreads=4
   [Parallel Time: 28.4 ms, GC Workers: 4]
      [GC Worker Start (ms): Min: 61596688.3, Avg: 61596688.3, Max: 61596688.4, Diff: 0.1]
      // 扫描Roots花费的时间,sum为花费总时间,单位毫秒
      [Ext Root Scanning (ms): Min: 1.5, Avg: 2.1, Max: 4.0, Diff: 2.5, Sum: 8.5]
      // 每个线程花费在更新RS上的时间
      [Update RS (ms): Min: 0.4, Avg: 2.4, Max: 3.2, Diff: 2.8, Sum: 9.5]
         [Processed Buffers: Min: 5, Avg: 19.8, Max: 46, Diff: 41, Sum: 79]
      // 这里是扫描collection set中的RS
      [Scan RS (ms): Min: 2.3, Avg: 2.6, Max: 2.8, Diff: 0.5, Sum: 10.4]
      // 扫描code中的root,这里code中的root指的是被JIT编译后的代码引用的对象,引用关系保存在RS中
      [Code Root Scanning (ms): Min: 0.1, Avg: 1.0, Max: 1.9, Diff: 1.8, Sum: 3.9]
      // 对象拷贝时间
      [Object Copy (ms): Min: 19.2, Avg: 20.1, Max: 21.0, Diff: 1.7, Sum: 80.5]
      [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      // 做的其他工作时间(没列出来,我也不知道干嘛了,不清楚为啥有这个)
      [GC Worker Other (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.4]
      // 每个线程花费的时间和
      [GC Worker Total (ms): Min: 28.2, Avg: 28.3, Max: 28.3, Diff: 0.1, Sum: 113.1]
      // 线程工作结束时间
      [GC Worker End (ms): Min: 61596716.6, Avg: 61596716.6, Max: 61596716.7, Diff: 0.1]
      // 下面是一些做其他事情的时间,不是很重要
   [Code Root Fixup: 3.6 ms]
   [Code Root Migration: 28.4 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.7 ms]
   [Other: 21.3 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 16.9 ms]
      [Ref Enq: 0.1 ms]
      [Redirty Cards: 0.2 ms]
      [Free CSet: 3.3 ms]
   [Eden: 3282.0M(3282.0M)->0.0B(3278.0M) Survivors: 46.0M->50.0M Heap: 3450.4M(6656.0M)->172.4M(6656.0M)]
   // 和之前一样user是所有线程使用的时间,real是暂停时间,sys是等待系统调度时间
 [Times: user=0.17 sys=0.00, real=0.08 secs]

从上面的日志我们也可以看出来,G1 young gc有以下几个重要步骤 1、根节点枚举,2、更新RS,3、扫描CS,4、拷贝对象。

五、full gc的原因

这里参考了一些其他文章中的写的很不错的内容,我们来看看G1中的full gc都有哪些原因

1、放弃mixed gc中的标记

这时候是因为启动了mixed gc,我们知道,标记过程是和用户线程并发进行的,这时候就会发生这种情况。这时候的措施是增大堆内存,如果不能增加堆内存的话,则需要减少-XX:InitiatingHeapOccupancyPercent的值,并且增加-XX:ConcGCThreads=n的值

2、晋升失败

我们注意看1和2的地方,这个在我参考那篇文章中作者解释的有一些问题。这里的正确的原因是,mixed gc的标记阶段已经结束,这时候开启了回收工作,但是因为to space放不下,需要将对象晋升到老年代,但是老年代空间不足,导致晋升失败,因此这里触发了这次full gc。

这时候最好的方式是减少-XX:InitiatingHeapOccupancyPercent这个值的大小,或者增大-XX:G1ReservePercent 这个参数也有不错的效果

3、大对象分配失败

大对象在G1中是个很难受的问题,为什么这么说呢?因为G1默认认为超过一半Region的对象便是大对象,会为它分出一个region来存它,这就意味着内存的碎片化,因为此时这个region可能会有一半的空间被浪费掉。碎片化会导致空间利用效率地下,此外,在1.8早期的版本中,只有full gc中才会回收这些大对象。

我们应该适当的增加region大小,以减少大对象的数量,或者如果使用的是jdk11以上的版本,可以尝试使用ZGC。

参考

G1中的算法实现部分极其的复杂,你别看我写的云淡风轻的,其实很多地方我也是看了很多遍才理解,为了帮大家更好的学习,作者列出了自己学习时候的参考的文章及书籍

1、《深入理解java虚拟机》第三版,周志明

这本书就不解释了,jvm入门必看

2、Java Hotspot G1 GC的一些关键技术

是美团技术团队的文章,值得一看

3、[Java 垃圾回收算法之G1[精品长文]] (juejin.cn/post/684490…) 同样写的很不错的一篇文章,介绍的很全面

4、hllvm-group.iteye.com/group/topic…

这里有R大的一些对G1中的复杂概念的解释

5、[Tips for Tuning the Garbage First Garbage Collector] (www.infoq.com/articles/tu…) 这是一个老外的文章,介绍了G1里面的一些很关键的东西,作者英语一般,但是为了学习也硬着头皮看完了