写点什么

G1 原理—如何优化 G1 中的 FGC

作者:EquatorCoco
  • 2025-01-18
    福建
  • 本文字数:7330 字

    阅读完需:约 24 分钟

1.G1 的 FGC 可以优化的点


(1)FGC 的基本原理


一.FGC 的并行处理


G1 有两个得天独厚的优势:


优势一.Region 是一个相对独立的内存区域

优势二.每个 Region 都有一个 RSet

 

通过 GC Roots + RSet,就能完整对某 Region 进行所有存活对象的标记。

 

二.FGC 的并行处理流程


并行 FGC 开始前的前置工作:对象头、锁信息等信息的保存处理。在保存完一些对象头相关的信息之后,就要开始 FGC 了。

 

具体步骤和串行化的 FGC 是类似的。

步骤一:标记所有存活对象

步骤二:计算对象的新地址

步骤三:更新对象间的引用地址

步骤四:移动对象完成压缩

步骤五:对象移动后的后续处理

 

三.并行标记过程 STW


FGC 并行化后,其并行标记过程和串行化过程时差别不是很大,都是要标记出来所有的存活的对象。

 

需要注意的是:因为是并行化处理,所以多个线程在进行并行标记时,每个线程都会比起串行化处理时,多一个标记栈(任务栈)。也就是把起始对象 GC Roots 分成多份,每一个 GC 线程持有一部分。

 

FGC 会对所有堆分区里的对象都进行标记,而且系统程序会 STW。

 

四.FGC 标记过程中的任务窃取


完成任务栈的对象标记的线程会从未完成的线程那里窃取一些任务。

 

(2)遇到 FGC 应该怎么处理


一.尽可能避免


FGC 是需要极力避免的,JVM 的优化手段多数都是尽可能减少 FGC 的出现。比如调整分代比例、Region 大小、停顿时间、老年代预留空间比例等。所以,对于 FGC 的处理,最重要的手段就是避免它。

 

二.尝试优化 FGC 的速率


如果是正常的 FGC,优化速率的方法就是,减少 FGC 需要处理的量。也就是 FGC 时,避免堆中存在大量需要复杂处理的对象。

 

(3)应该如何操作来规避 FGC


基本的思路还是要避免达到产生 FGC 的条件。

 

一.产生 FGC 的条件主要是以下两种


条件一:MGC 不及时,导致垃圾对象存活过多,造成空间不够

这其实也是并发标记的启动时机存在问题。如果并发标记启动的频率,远远落后于垃圾产生的速率。那么就会出现大量空间被垃圾对象占用,导致不必要的 FGC。另外就是回收速度太低,导致停顿时间内回收垃圾太少,造成空间不够。


条件二:存活对象太多,各种 GC 都尝试过,无法腾出足够空间给新对象

执行了 YGC + MGC 后出现晋升失败,不得不进行 FGC。

 

二.产生 FGC 的场景主要是以下两种


场景一:G1 使用的算法,是标记复制算法(YGC 和 MGC) + 标记整理算法(FGC)。在进行垃圾回收时,新创建的对象及存活的对象,没有足够空间可使用。复制操作无法实现,因为每次 GC 的存活对象要复制到空闲 Region 中。


场景二:多次 GC 后仍然无法给新对象腾出足够的空间,导致 FGC。此时所能做的就是尽可能合理优化参数,保证不触发这些场景。

 

(4)应该如何操作来加快 FGC 的速度


FGC 的速度,在 JVM 层面其实已经做了很多优化,包括并行优化等。那么在此基础之上,我们还能通过什么策略来提升 FGC 的速度?比如要减少 FGC 处理的总量,靠减少堆内存来减少 FGC 要处理的总量吗?针对 FGC 的速度要做的优化,不能简单的从堆内存空间大小来考虑。

 

G1 提供了一种思路,叫"弥留空间"。当 G1 发现经历多次 GC 后,就会允许一定比例的空间,作为把垃圾对象当成存活对象处理的空间。这些垃圾对象所在的一定范围的区域,可以成为弥留空间。虽然这个弥留空间是垃圾对象,但在 GC 处理时,是当作存活对象来处理。

 

FGC 在处理这块弥留空间里的对象时,会把它们直接当作存活对象来处理,不需要做各种复杂的标记、判定引用、指针替换等各种操作,直接去复制。明知是死亡对象,但此时先不做全部空间的标记压缩整理,而只做部分的。所以弥留空间就能快速被跳过,减少处理空间,一定程度提高 FGC 的速率。弥留空间,就是为了提升 FGC 的效率而设计的。

 

2.一个 bug 导致的 FGC(Kafka 发送重试 + subList 导致 List 越来越大)

 

(1)运营场景业务分析


一般在电商公司里会有一个运营平台,有些公司叫营销平台,这个平台的作用主要就是拉新、增涨营收。就是通过运营平台发起活动、或者投放广告,来实现用户增长。对于平台的存量用户,也会有优惠活动、节日福利活动等来留住用户。

 

各种各演的运营活动,都会通过运营平台去产生。然后专门有一个抽离的运营消息推送服务来推送运营活动消息给用户。由于这些消息的数量非常庞大,对于一家几十万上百万的用户,一天假如有 3 次运营活动,就要至少好几百万甚至上千万的的消息要推送。这么大量的消息,不会直接全量推送,而是生成推送消息,发送到 MQ,然后通过一个消费者去消费这些消息,慢慢把千万级别的消息推送给用户。

 

(2)业务场景背景介绍


在这个场景中,一个很重要的要点就是:运营消息推送平台会生成消息推送到消息中间件 Kafka 中。这个消息生成、推送至 MQ 的速率,很大程度影响到整个推送流程的速度。

 

因此,对这个消息生成和推送的过程,就需做优化,当时优化的思路是:

一.分布式生成消息,即多台机器,把用户群体分成多个部分来生成消息

二.batch 推送消息,减少与消息中间件的网络通信

三.运营消息推送平台,使用多线程并发推送 batch 消息

四.把一些大批量的查询借助一些其他的数据搜索引擎或缓存来提升效率

 

比如,借助 JVM 本地缓存,每台机器保存一些用户账号相关信息。比如,使用 ES 来存储用户信息,提升多条件下的搜索查询效率。比如,使用 Redis Cluster 缓存,避免使用数据库导致效率低。

 

这些基本的思路其实还是比较简单的,细节上的优化就是:batch 的大小如何确定、多线程推送时线程池的线程数量要怎么设置等。

 

(3)问题现场及问题排查


一.引发 OOM 的原因是频繁 FGC


然后在一次优化测试上线后,运行一段时间,没有什么大问题。在某天 Kafka 服务发生了一次抖动,持续时间大概 5-6s 的时间,然后发现这个运营平台直接崩溃了。

 

在优化前,这个运营平台的推送效率有待提升,但是系统还是很稳定的。出现这个问题后紧急查看了报错日志,发现是堆内存 OOM 导致进程崩溃。

 

于是下载 GC 日志,下载内存快照文件,查看 dump 快照文件,最后发现是 batch 推送时线程池中的线程持有了大量的大 List 对象。

 

本来是打算找 OOM 的原因,解决 OOM 问题的。但发现在 OOM 前出现了大量的 FGC,而大量的 FGC 才最终导致系统崩溃。

 

那么结合 dump 快照里大量的 List 对象,很容易想到是大量的 List 导致频繁 FGC。而大量的 List 都还存活,所以最终导致 OOM。

 

二.频繁 FGC 的原因是 List 造成的对象过大


那么为什么会有这么多次 FGC,为什么有这么多的 List 没被回收?通过代码排查,发现了一个很严重的代码 bug,这个 bug 其实很好规避。

 

原来在代码中采取的策略是:每次会查询一组用户数据,这组用户数据会有 2000 个用户。然后将这组数据封装成消息体是按照一个 List 放 200 个用户去封装,也就是将 200 个用户封装成一个 List 消息体之后,才会发送到 Kafka 中。

 

注意:Kafka 本身也有一个缓冲机制来实现 batch 发送。我们的代码将消息一条一条发送时,Kafka 会在本地客户端暂存。存到一定大小时,才会按照一个批次推送到 Kafka 服务端。

 

这种策略正常来说是没问题的,因为 200 个用户封装后的大小也不大。但是写代码时,因为运营的一个需求,把其中一段代码修改了一下。运营要求在某个促销活动下,消息必须推送到用户侧,不能出现漏发错发多发。

 

原有的代码是:把拿到的 2000 一组的用户数据,拆分成 10 个 batch,然后推送至 Kafka。假如推送失败就不管了,因为漏发某个用户的消息也问题不大。

 

修改后的代码是:针对这种特殊活动,添加了一些逻辑,即等待 Kafka 返回推送结果。如果拿到推送结果则表明推送成功,则执行完毕。如果拿不到推送结果则表明推送失败,需要把失败的用户集,重新加入到一个集合中去重试推送。具体的伪代码如下:


//获取一组用户信息ArrayList list = getUserList();List subList;List failList = new ArrayList();int index = 0;//拆分用户信息while(true) {    if (index >= list.size()-1) {        break;    }    subList = list.subList(index, index + 200);    kafkaPushResult = sendToKafka();    //等待发送结果    if (kafkaPushResult == false) {        //发送失败,是第一次,就把发送失败的subList赋值给failList        if (index == 0) {            failList = subList;        }        //不是第一次,就把发送失败的全部加入到failList中,等待后续继续发送        failList.addAll(subList);    }}
复制代码


上述代码看起来是不是没什么问题?事实上,当这个 kafkaPushResult 失败没有触发时,确实不会出什么问题。但是一旦触发了失败,就会出现问题了。

 

原因在于对于 List 来说,subList 不是新建了一个对象,而是把大的 list 的其中一段使用了两个指针去指向它,所以本质上 subList 还是大的 list 的一部分。

 

那么如果频繁触发发送失败,failList 这么写就相当于是大的 list 的一部分。那么执行 failList.addAll(subList)代码时,相当于是在把这部分失败的元素,加入到大的 list 里。



所以,list 是会越来越大。如果一次推送几十万用户的消息,一个线程池里面设置 30-60 个线程。那么在 Kafka 抖动的几秒的这段时间,这些 list 会极速扩张好几千倍不止。也就是说,假设这 5s 内,一共能拿到 10w 的用户数据。那么在 5s 内就会膨胀到几千万甚至上亿的数据,紧接着,重试操作会针对这些数据重试,而这个大的 list 又暂时不释放。如果 Kafka 抖动时间短,经历几次 FGC 就完成重新推送,没什么问题了。如果 Kafka 经常抖动或抖动时间长,就会造成频繁的 FGC,甚至 OOM。

 

这个场景,由于 FGC 产生的原因比较直接。所以分析时很容易通过 GC 日志 + dump 快照文件迅速定位了问题。但很多 FGC 场景,产生的原因各不相同,分析过程还是会非常复杂的。不过基本思路都是:系统日志 -> 监控数据 -> GC 日志 -> dump 文件 -> 代码反查 -> bug 复现 -> 解决问题。

 

(4)问题解决


知道了问题原因,其实就很好解决了。根本原因还是因为在写代码时对 api 源码的实现不熟悉,同时也没有深入检查代码的习惯,导致在做一些业务处理时出现了意想不到的 bug。

 

对于这个代码,其实只需要做一个改动就 OK 了。在外层初始化 failList,并且在需要把 subList 数据作为数据源的操作时,使用 addAll 操作,把它加入到新的 list 中再进行操作即可。


failList = new ArrayList();if (index == 0) {    failList = subList;}

->failList.addAll(subList)
复制代码


在使用 subList 拆分 list 时,一定要熟悉一下这个 subList 的源码实现方式。往往 JDK 的一些优化手段,会给我们程序造成一些不必要的问题。

 

此外,如果出现了频繁 FGC,很有可能是对象产生的速度和垃圾回收的速度匹配不上,回收的量不够就会有可能 OOM 了。

 

3.为什么 G1 的 FGC 比 ParNew + CMS 要更严重


(1)ParNew + CMS 的 FGC 触发


ParNew + CMS 触发的 FGC 的规则其实还是比较简单的,总结来说,就是老年代不够用了。

 

当然老年代不够用的过程可能比较多:新生代晋升、大对象占用等。但使用 ParNew + CMS 时,新生代和老年代的比例往往都是比较均衡,有些系统新生代甚至远大于老年代。

 

那么在这种场景下,FGC 相对来说回收的空间就不算太恐怖,毕竟回收的空间只有堆内存的 1/2 左右。比较耗时的地方就是:全量标记、对象复制、压缩整理的过程。因此 FGC 即使偶尔发生一次,比如一天一次或几小时一次,也能接受。

 

并且老年代对象的晋升,是有一系列的担保机制的,比如:老年代剩余内存大于新生代存活对象、老年代剩余内存大小大于历次新生代晋升到老年代的对象的平均大小等。

 

因此综合分析下来,ParNew + CMS 触发 FGC 时,从空间上和处理算法(标记整理)上来说,偶尔一次还是能够接受的。

 

(2)G1 的 FGC 触发


G1 的 FGC 触发场景,基本上有两种:

 

一.新生代晋升失败导致的 FGC


有可能是因为晋升预留空间不够导致的,比如预留的晋升空间比例参数 G1ReservePercent 调整为了 5,而新生代区本次晋升对象比较多,此时就会发生晋升失败导致 FGC。

 

这种情况触发的 FGC 稍微好一点,因为老年代的实际使用量是:老年代大小 * (1 - --XX:G1ReservePercent%)。并且因为是晋升导致的 FGC,此时新生代是刚执行完新生代回收的。所以基本上这种情况下导致的 FGC,只需回收相对比较小的区域即可。

 

即便如此,这种情况下的 FGC 效率依然很低,因为涉及到大量的标记、压缩、整理、引用处理等各种操作。

 

二.对象分配失败导致的 FGC


对象分配失败导致的 FGC,就比较恐怖了。因为在对象分配失败时,会经历如下整个过程:TLAB -> TLAB 扩展 -> 堆内存分配 -> Region 扩展 -> YGC -> MGC -> 堆扩展 -> 分配失败。

 

如果是这个过程造成分配失败,则意味着整个堆内存的使用率非常高。即使经过了 YGC + MGC + 扩展操作,还是无法成功分配。

 

这时进入的 FGC 就可以理解为:几乎大部分的内存都被占用了,连一个对象都无法成功分配。此时的 FGC 需要处理的 Region,几乎是整个堆内存里的所有 Region。

 

并且结合 FGC 的整个处理过程:标记->对象头处理->对象移动->压缩->清理->Rset 处理等一系列操作,此时的 FGC 就会非常耗时。

 

所以这个情况下触发的 FGC 会非常恐怖,当然如果没有乱调参数,正常情况也不会发生这么恐怖的 FGC。

 

三.总结


如果 YGC 执行后晋升失败导致 FGC,那么就是 reserve 空间不够导致的。也就是分配对象失败,执行 YGC 后,必然会发生晋升,此时有可能就会在晋升失败前触发 FGC 来清理了。所以基本上不会出现达到多次 YGC + MGC 之后,还无法分配成功的情况。

 

但要注意有可能会出现:QPS 暴增,对象产生速度比较快,然后回收速度比较慢,虽然经历了多次 YGC,但垃圾对象依然很多的情况。

 

(3)G1 的 FGC 更加恐怖的原因总结


一.G1 通常来说要管理更大的堆内存空间,因此需要处理更多的对象

二.G1 触发 FGC 的条件比较苛刻,分配失败的 FGC 需处理整个堆内存

三.G1 在执行 FGC 过程中,需要针对复杂的 Rset 引用关系做更多处理

 

4.FGC 的一些参数及优化思路(都围绕回收速度跟不上垃圾产生速度展开)


(1)-XX:G1HeapRegionSize


这个参数用于控制 Region 大小,调整这个参数可以避免老年代中的大对象占用过多的内存。因为老年代大对象占用过多的内存,就会提高老年代的使用率。老年代的使用率高了,就必然会增加晋升失败的概率。所以增大 Region 大小,可以避免不算太大的对象进入老年代,从而降低晋升失败的概率。

 

调大 RegionSize 的另外一个优点是:在发生对象复制、晋升时,PLAB 也会相对大一些,从而在复制、晋升时,速率也会提升。

 

(2)-XX:G1ReservePercent 默认是 10


这个参数代表老年代预留给新生代对象晋升的空间占用堆内存的比例,如果经常因为晋升失败导致 FGC,说明这个值太小。此时可适当调高这个值,降低 FGC 频率。

 

(3)-XX:MaxGCPauseMillis 停顿时间


这个参数设置是否合理,关系到了垃圾回收的效率。假如设置了 20ms 的停顿时间,则很有可能导致每次 GC 回收的垃圾非常少。假如系统并发非常高,产生垃圾的速度非常快,就有可能会不断进行 YGC。但是每次 YGC 都只能回收掉很少一部分垃圾,最终造成 FGC。

 

所以,一个合理的停顿时间设置,是非常有必要的。一般情况下,系统的停顿时间可以设置 100-200ms 之间,具体情况需要根据系统运行情况及 JVM 监控情况来调整。

 

(4)-XX:InitiatingHeapOccupancyPercent


意思是当堆内存的占用比例达 45%时,就会触发并发标记(可能开启 MGC)。假如因为垃圾回收的速率跟不上系统产生垃圾的速度而造成频繁的 FGC,那么就可以适当调低这个参数,尽快开启 MGC,通过提升 MGC 的频率来避免 JVM 内堆积过多的垃圾对象。

 

注意:这个参数调小造成的多次 GC 和停顿时间造成的多次 GC 不是一个概念。

 

停顿时间造成的 GC:是因为每次停顿时间不够,只能回收很少的垃圾,导致垃圾堆积最终 FGC。

 

调小 InitiatingHeapOccupancyPercent 这个参数:此时停顿时间固定,但是回收的频率提升上来了。这样在同样的程序运行时间里,能够回收更多的垃圾,可以避免 FGC。

 

(5)-XX:ConcGCThreads


这个参数是指在标记过程中的并行标记(STW)线程数量。如果因为并发标记不够及时,并发标记时间过长,导致垃圾回收的速率跟不上,造成 FGC。

 

略微提升这个值,可能能够提升标记速度,以达到避免 FGC 的效果。当然线程数量多少,要和服务器的配置相匹配,不能盲目调大这个值。

 

(6)-XX:G1ConcMarkStepDurationMillis


这个参数指的是并发标记(不会 STW)执行的时间,默认是 10ms。调小该参数可以提升并发标记的次数,让并发标记触发的更加频繁一点,从而让重新标记更短一点,以此来提升垃圾回收的速率,避免垃圾回收速度跟不上垃圾产生速度,最终造成 FGC。

 

正常来说这个参数也是不需要调整的,只有发现系统经常因为垃圾处理不及时而频繁的触发 FGC,才有可能需要调小这个参数。

 

(7)-XX:MarkSweepAlwaysCompactCount


这个参数表示,经过多次 GC 后:允许 JVM 内存中有一定比例的空间用来将垃圾对象当作存活对象来处理,可以称这块儿空间为弥留空间。

 

弥留空间主要的目的就是可以把垃圾对象当作存活对象来处理,相当于给了一块比较好处理的空间,能够减少 FGC 时的处理压力。可以说是间接"减少 FGC 需要处理的空间",这个空间不宜太大。如果太大就会造成一部分空间被占用,一般来说保持默认即可。

 

(8)总结


上面介绍的这些参数,多数都是以"避免"FGC 的思路来展开优化。只有第七个参数是通过为 FGC"减负"的思路来展开优化,而且避免 FGC 通常都是从提升垃圾回收的速率这个角度出发:让垃圾回收速率赶上垃圾对象产生的速率。

 

对于 FGC 来说,"减负"这个思路,在多数情况下是无法采取的。因为 FGC 的特性就决定了不太可能通过其本身的参数调整来提速,更多的优化思路和优化手段还是要从"避免"角度出发,避免大多数 FGC 才是优化的重点。

 

回收速度跟不上垃圾产生速度总结:


一.调大 RegionSize,让 PLAB 增大,从而加快复制、晋升的速度

二.调大老年代预留给新生代晋升的占比,降低晋升失败的频率

三.调小触发 MGC 的老年代占比,加快 MGC

四.增加并发标记的线程,加快 GC

五.降低每次并发标记的执行时间,降低重新标记时间,加快 GC

六.调大弥留空间占比,降低 FGC 的处理压力,提升 FGC 的处理速度

 

为什么 G1 的 FGC 比 ParNew + CMS 要更严重:


一.ParNew + CMS 的 FGC 触发

就是老年代不够用了。


二.G1 的 FGC 触发条件

条件一:新生代晋升失败导致的 FGC。

条件二:对象分配失败导致的 FGC。


三.G1 的 FGC 更加恐怖的原因总结

原因一:G1 通常来说要管理更大的堆内存空间,因此需要处理更多的对象。

原因二:G1 触发 FGC 的条件比较苛刻,分配失败的 FGC 需处理整个堆内存。

原因三:G1 在执行 FGC 过程中,需要针对复杂的 Rset 引用关系做更多处理。


文章转载自:东阳马生架构

原文链接:https://www.cnblogs.com/mjunz/p/18677839

体验地址:http://www.jnpfsoft.com/?from=001YH

用户头像

EquatorCoco

关注

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
G1原理—如何优化G1中的FGC_Java_EquatorCoco_InfoQ写作社区