真·JVM参数调优实战——缩短FullGC频率为原来的1%

633 阅读21分钟

FullGC系列过往文章:

FullGC系列(一):手把手排查线上FullGC问题

FullGC系列(二):内存泄露是否真实存在

FullGC系列(三):JVM参数调优实战

〇、先看结果:

image-20241010181222423

未优化的节点FullGC频率为:

7天内一共发生了25次FullGC,平均每6小时发生一次,。

优化后的节点FullGC频率为:

因优化后到文章截止时仍没有发生过FullGC,固按照现有增长速率计算,平均26天会发生一次,也就是平均624小时发生一次。

现有频率为原有频率的:

6h/624h≈0.96%<1%

一、问题背景

1.1 发现问题

发现问题仍然是从某次告警开始的,同事发现了告警邮件,某个中间件链接超时,排查后发现是机器此时在发生FullGC,导致stw(stop the world)

image-20241008141104870

image-20241008140912321

同时也发现该应用,存在经常发生FullGC的现象。

1.2 定位问题

1.2.1 查看内存使用情况

首先我们查看近一周的内存使用情况:

image-20241008142044050

发现近一周的内存使用情况确实比较频繁,多的时候一天可能会有发生4-5次FullGC,进而影响届时正在进行的业务。

1.2.2 内存分析

一般经常或频繁的FullGC有两种情况:

  • 一种是内存泄露,即每次FullGC完都会有一些内存不能被回收掉,比较明显的现象是每次FullGC完之后的低点会越来越高

    img

  • 另一种是非内存泄露,每次FullGC都可以回收掉绝大多数的对象,不存在一直回收不掉的对象,现象是FullGC完之后的内存低点不会越来越高

    image-20241008142044050

    结论:

    观察我们的内存图,发现我们这个应用每次gc完之后的内存低点没有变高,因此是符合第二种现象。所以此时我们应该可以下定论,这个应用的经常性FullGC是属于“正常GC行为”,也就是说老年代的对象是可以被正常回收的。

    因此我们需要找到是哪些大对象长期占用老年代的空间。

1.2.3 dump内存快照

选择dump内存时,尽量选择内存快满的时候dump,因为此时dump文件内信息较多, 容易看出来是什么对象占用内存较大,如果在FullGC完立马就dump,这个时候可能是没有任何有效数据的。

image-20241008144802056

1.2.4 分析堆内存

使用JProfiler工具打开此快照文件,需要注意的时,需要使用自定义打开选项,在选项中取消分析时执行FullGC操作(无论是不是用JProfiler,其他内存分析工具有都有类似的选项)。

这是因为我们在1.2.2中已经分析了我们是属于第二种现象,即大对象在FullGC后是可以被正常回收掉的,因此我们需要关注这些大对象时不能使用FullGC的选项,否则就看不到这些对象了。但是当属于第一种现象时,即大对象在FullGC后不能被正常回收掉的,这个时候我们需要执行FullGC操作,因为我们关注的对象是FullGC后回收不掉的对象。

所以在分析时是否执行FullGC操作时,是要看关注的是FullGC前的对象还是FullGC后的对象,根据不同的场景使用不同的策略,不然就有可能出现,如果直接打开并执行了FullGC,我们这个场景是看不到任何真正有用的大对象的(因为都已经被FullGC掉了)。

详见:FullGC系列(二):内存泄露是否真实存在,章节:2.3

步骤如下:

首先勾选自定义分析选项:

image-20241008152525175

然后在分析时取消勾选FullGC选项

image-20241008152347030

这样就能看到JVM堆内存中原封不动的对象信息:

image-20241008152836488

结论:

可以看到 char[] byte[] int[]占了大约两个G,因此频繁产生大对象就应该在这些对象之间。

如果我们使用了标准的内存分析(即会自动FullGC),可以看到如下的内存情况,是什么有效的数据都没有的:

image-20241008153835631

1.2.5 定位代码

选择占比最大的char[]数组,双击找到对应的引用

image-20241010112928863

可以看到确实有很多字符串类型的大对象(String的底层仍然是char[]) ,最大的一个对象有28335kb=28.3mb, 其余的大对象也不在少数。因此,我们已经找到了罪魁祸首。

下一步就是根据这些字符串信息找到对应的代码位置(以下的流程不具有通用性,需要具体情况具体分析了):

image-20241010140220665

对应的文本如下

[""bizContent":""applicationNo":"2121665698781691904","files":["content":"/9j/4UHxRXhpZgAATU0AKgAAAAgABg...

我们观察到有两个字段: applicationNofiles 并且files是一个list类型。

我们在对应的应用中全局搜索files字段,并勾选words(当成完整的次搜索,而不会搜索到其他的例如FileService字段)。这里不使用 applicationNo搜索是因为这个字段很多地方都会用到,所以搜索难度大一些。

搜索结果如下:

image-20241010140751815

可以看到,这个实体类的结构和我们看到的对象结构一模一样,因此我们可以确定大对象为这个类:

public class ChannelFileUpload {
​
    private String applicationNo;
​
    private List<ChannelBinaryFile> files;
  
    ...
}

然后根据这个类的引用找到对应的代码位置:

private ChannelFileUpload financePacketBuilder(FileDeliveryRecord record) throws Exception {
        Map<String, byte[]> contents = new HashMap<>(forwardingOssFiles.size());
        for (ForwardingOssFile forwardingOssFile : forwardingOssFiles) {
            contents.put(
                    forwardingOssFile.getFileName(),
                    //下载oss文件
                    jingdongStorageManager.download(forwardingOssFile.getOssBucket(), forwardingOssFile.getOssPath()));
        }
        return ChannelFileUpload.createChannelFileUpload(record.getLoanApplyNo(), contents);
      
  
public static ChannelBinaryFile createChannelBinaryFile(String name, byte[] content) {
        String type = StringUtils.substringAfterLast(name, ".");
        String contentString = null;
        try {
            //将下载到的文件byte[]用base64编码下,然后作为对象中的一个属性
            contentString = Base64.encodeBase64String(content);
        } catch (Exception ignored) {
​
        }
        return new ChannelBinaryFile(type, name, contentString);
    }

总结一下,这个流程会先下载oss中的文件,然后再将文件内容的char[]用base64编码后作为对象中的一个属性

因此,当一个图片很大时,再用base64编码后会更大,就会出现大对象的问题。

至此,我们已经找到了出现问题的地方,但是还需要结合监控图验证一下。我们找到相同的时间内这个接口的TPS与老年代内存上涨的情况:

image-20241010141841675

并且我们也看到了一个很有意思的现象:

每当这个接口的TPS升高时,对应内存上涨的斜率也会升,图中四处TPS升高带来了四处内存上涨斜率的增高(并且过程中内存使用率过高还带了两次FullGC)。

换句话说,每当这个接口被调用时,对应的内存上涨的就越快,因此我们就可以确定,至少是这个接口调用引起的内存上涨。

之所以说至少是这个接口,因为我们发现了内存中还有其他格式的大对象,排查后发现也都是类似的问题,会将各种文件下载到内存中,然后用base64编码,造成大对象的存在,此处不再赘述。

二、解决问题

问题已经找到了,就要解决这个问题。

2.1 思路一:使用完成之后主动删除

我们在使用完成后如果将对象设置为null会不会有效果:

//创建对象
ChannelFileUpload channelFileUpload = financePacketBuilder(record);
//使用对象
BaseResponse<BaseBizContentResponse> response = request.sendToFinance(channelFileUpload);
//将该对象设置为null
channelFileUpload = null;

其实想一下这样是不行的,因为我们在new对象的时候会在堆内存中创建一块区域存储具体的数据,然后将要创建对象的引用指向该区域,虽然我们将对象的设置为null,但是也只是将这个对象的引用设置为null,在堆内存中存储的数据不会被回收掉。

因此这样设置意义不大。

或者我们主动调用GC呢

//创建对象
ChannelFileUpload channelFileUpload = financePacketBuilder(record);
//使用对象
BaseResponse<BaseBizContentResponse> response = request.sendToFinance(channelFileUpload);
//主动GC
System.gc();

主动调用GC首先不一定会触发FullGC,因为这里只是建议系统要GC,但是JVM还是会根据自己的策略选择在什么时候去执行GC操作,其次如果该接口TPS较高的时候,每次调用都执行GC也会有频繁GC的风险。因此这样改造风险大于收益,也暂不考虑。

2.2 思路二:修改接口逻辑

从根本上能解决问题的方式就是不创建大对象,我们在接口中不下载oss文件,而把oss链接给到下游系统,让下游系统自己下载文件。这样就能从根本上解决问题。

但是因为系统会涉及到上下游应用及外部系统,因此改造起来困难较大、排期较长,基本上也不可能。

因此,如果必须在系统内创建大对象的话,只能优化JVM参数这一条路。

2.3 思路三:优化JVM参数

要优化JVM参数,我们首先看一下这个应用对应的JVM参数:

-server -Xms4096m -Xmx4096m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError

这些参数表明了JVM使用的堆内存大小为4G,并且没有指定使用哪个垃圾收集器。

2.3.1 调整堆内存大小

查看我们容器的内存大小,发现是4C8G,也就是说JVM只用了一半的内存。

image-20241010155539341

一般的业务应用推荐JVM使用宿主机的内存占比为50%-75%,也就是二分之一到四分之三,我们只使用了一半,因此可以调整堆内存大小为6G。

-server -Xms6144m -Xmx6144m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError

2.3.2 设置进入老年代阈值

最简单的调大堆内存我们已经设置过了,下面来看看调整其他的JVM参数。

根据我们前文的情况,我们重新梳理一下思路:

首先创建对象时,因为对象过大会导致直接进入老年代,因此会老年代会逐步变大,进而导致一天内可能会出现多次FullGC。

其次,这些对象也只是会在一次接口调用中短时存在,接口调用完成后数据就没用了,因此这些对象其实更应该待在年轻代,被频率较高的YGC回收掉。

因此我们的解决方案是:

让大对象待在年轻代,让年轻带的Ygc把这些无用的对象给回收掉。此外,需要注意的是这个操作会带来年轻代的增长速率变快,因此我们也需要对应的调整年轻代的大小。

我们知道JVM中可以通过这个参数调整进入老年代的阈值:

-XX:PretenureSizeThreshold

同时因为没有指定使用那个垃圾收集器,JDK8默认使用Parallel垃圾收集器,因此该收集器指定年轻代大小的参数为:

-Xmn

所以调整完之后的JVM参数如下:

-server -Xms6144m -Xmx6144m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError -XX:PretenureSizeThreshold=30m -Xmn2457m 

将进入老年代的阈值设置为30m,同时将年轻代内存设置为2.4G。

我们将此配置重新部署部分节点至生产环境,其余节点配置照旧(为了对比新老配置是否有效),然后过两天重新观察内存使用率情况:

image-20241010162835270

可以看到本次的改动是有效的,未修改JVM配置的节点一天发生了5次FullGC,而修改了配置之后的节点,一天内还没有发生过一次FullGC。

那么问题到这里就解决了吗?其实并没有,我们发现虽然现在还没有发生GC,但是当前的内存使用率已经快到阈值了,最多再过几个小时还是会发生GC。因此,我们上一步只是将发生GC的频率降低了,并没有从根本上解决问题。

此外,dump下内存快照后发现,堆内存中还是有很多我们之前发现的大对象也印证了以上的猜测,问题还没有解决,大对象还是会待在老年代。

2.3.3 无效的参数-XX:PretenureSizeThreshold

从上文来看,我们设置的参数 -XX:PretenureSizeThreshold好像并没有生效,查阅资料后发现,我们使用的Parallel垃圾收集器中确实是不支持这个参数。

此外,我们也发现,即使我们换个垃圾回收器支持这个参数,设置完成后也是无效的。

这是因为,我们当初设置这个参数时,不希望对象能直接分配到老年代,而应该一直待在年轻代。此参数的作用是,当创建对象的内存大小大于设置的阈值时(30m),会直接进老年代,而不是说小于这个阈值就会一直待在年轻代。

那么,为什么这些大对象会在短时间内就进入到老年代呢?

我们重新回忆一下对象什么时候会进入老年代:

1. 年龄达到阈值

当对象在年轻代中的Survivor区经过多次垃圾收集(Minor GC)后,其年龄(即对象存活的次数)达到一定的阈值时(默认阈值为15,可以通过 -XX:MaxTenuringThreshold 参数进行调整),该对象会被提升(Promote)到老年代。这是最常见的方式之一。

2. 年轻代空间不足

当年轻代的Eden区和Survivor区无法容纳新创建的对象或存活的对象时,会触发一次Minor GC。如果在GC之后,年轻代仍然不足以容纳这些对象,那么一些对象将会被直接提升到老年代。这种情况通常发生在大量对象短时间内被创建的情况下。

3. 大对象直接分配到老年代

大对象(例如大型数组或字符串)如果超过了特定的大小阈值(可以通过 -XX:PretenureSizeThreshold 参数设置),会直接分配到老年代,而不会经过年轻代。这种方式主要用于避免在年轻代中频繁复制大对象,降低垃圾收集的开销。

4. 动态年龄判定

在某些垃圾收集器(如G1 GC)中,JVM会动态地调整对象提升到老年代的年龄阈值。JVM会根据对象在Survivor区的分布情况来决定哪些对象应该提升到老年代,而不是严格按照固定的年龄阈值。

我们使用排除法依次来找到问题的原因:

  1. 年龄达到阈值

我们查看了这个请求的耗时,基本上都在2-3S左右。然后查看YGC的频率,平均28S发生一次,这些大对象的请求不可能在年轻代待到15次YGC。因此这种情况是不可能的。

image-20241010172526185

  1. 大对象直接分配老年代

因为我们此收集器不支持这个这个 PretenureSizeThreshold参数配置,因此在默认为0的情况下,任何对象都会现在年轻代分配内存。这种直接分配到老年代的情况也不存在。

  1. 动态年龄判断

同1,对象存在时间太短,不会撑过一轮YGC,因此也暂不考虑

那么有可能会让对象进入老年代的只剩下第2种情况了。

  1. 年轻代空间不足

2.3.4 年轻代空间不足时进入老年代的过程

我们知道年轻代分为Eden和survivor区,其默认比例是8:1:1。新生代中有两个Survivor区,分别称为Survivor区0(S0)和Survivor区1(S1),它们的作用是保存从Eden区中存活下来的对象。

那么对象的生命周期为:

  1. 对象创建:大多数新对象在Eden区中创建。此时S0与S1都是空的。
  2. 第一次GC:当Eden区满时,触发Minor GC,存活的对象被复制到Survivor区0(假设S0)。
  3. 第二次及后续GC:在下一次Minor GC中,Eden区和Survivor区0(S0)中的存活对象被复制到Survivor区1(S1)。如果对象在Survivor区中经历了足够多次的GC(达到一定年龄阈值),它们会被提升到老年代。或者当S1中存放不下Eden+S0的对象时,也会放入老年代。

我们从监控图查看Survivor的空间大小:

image-20241010174203513

可以看到Survivor的空间是会变化的,最大有77.5MB,而最小只有2MB! 不应该一直是固定的8:1:1的比例吗?

查询相关资料后发现,这是因为Parallel垃圾收集器默认开启了这个策略:

-XX:+UseAdaptiveSizePolicy

这个策略的意义是:

-XX:+UseAdaptiveSizePolicy 参数开启时,JVM 会启用自适应内存管理策略(Adaptive Size Policy),这意味着 JVM 会根据应用程序的运行情况动态调整堆内存的分配,包括新生代(Young Generation)和老年代(Old Generation)的大小。这个策略主要应用于年轻代和老年代的内存分配,以及垃圾收集的频率和停顿时间。

这也就解释了为什么Survivor的空间是动态变化的,以及为什么这些大对象会在短时间进入老年代。

当Survivor区被设置为2mb,发生YGC后,Eden区+S0区存活的对象内存空间大小大概率是大于S1区的,因此这些对象会直接进入到老年代,因此几乎每次YGC都会把剩余对象放入老年代,这次是为什么老年代的内存增长频率会特别高。

2.3.5 设置固定Eden和Survivor区固定比例

根据查阅资料里面的解决方案,显式的设置Eden和Survivor区固定比例为8:1:1。

-XX:SurvivorRatio=8

然后设置并部署后发现这个参数只会在JVM初始化时比例为8:1:1,后面随着YGC的开始,Survivor区的大小还是会变化。想了一下应该还是-XX:+UseAdaptiveSizePolicy这个策略的问题。

临门一脚,我们决定关闭这个策略。

咨询GPT后发现关闭这个策略并不会有什么问题,只会导致每个区域都是固定的内存大小,不会动态的变更,而这正是我们所需要的。

因此,设置这个参数为关闭,最终的JVM配置为:

-server -Xms6144m -Xmx6144m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError -XX:PretenureSizeThreshold=30m -Xmn2457m -XX:SurvivorRatio=8 -XX:-UseAdaptiveSizePolicy

2.4 结果与结论

国庆回来之后,过了7天我们再观察下对应的监控图,发现问题已经解决了:

image-20241010181222423

筛选同样的时间段,发现未修改配置的节点,7天内一共发生了25次FullGC,平均每6小时发生一次,平均每次669毫秒:

image-20241010181437741

也就是说一天内会有4次长达700毫秒的时间系统是不可用的。

而优化过配置的节点,7天的时间内存增长了:

1.46GB-0.76GB=700MB

700MB/7天=100MB/天

平均每天老年代增长100MB,2.6G的老年代内存可以支撑大约26天会发生一次FullGC

26天*24小时=624小时

也就是说现在平均600小时才会发生一次FullGC,而原来平均每6小时就会发生一次FullGC,将发生FullGC的频率缩短为原来的1%.

6h/624h≈0.96%<1%

至此,26天才会发生一次的FullGC,已经可以被正常的业务应用所接受了。

三、总结

再回顾一下整体的流程。

我们观察监控图发现频繁发生GC的原因是正常的大对象存在,而非内存溢出的情况,因此选择dump下内存快照后使用自定义分析工具不自动GC。找到大对象后,根据字符串的特征找到对应的代码,而后根据这个接口的调用情况与内存上涨的速率,确定了是这个接口导致的内存上涨。

找到问题后,发现前两种思路都不能解决此问题,最后选择优化JVM参数配置的方式。先选择最简单的调整JVM堆内存大小,调整完成之后设置-XX:PretenureSizeThreshold参数,发现会有用但是仍然会存在FullGC的情况,因此重新梳理可能会导致对象短时间内进入老年代的情况,根据排除法确定了最终的问题是年轻代内存不足。

而最终的问题就是应用没有指定垃圾回收器,JDK8使用了默认的Parallel垃圾回收器,而该垃圾回收器会默认开启这个策略-XX:+UseAdaptiveSizePolicy,该策略会导致年轻代的Survivor区最小可能缩短至2mb(S0跟S1都是2mb),而在这种情况下,YGC后发现不能将Eden+S0存放至S1,就会将对象存放至老年代,而导致老年代的增长速率会特别快,进而导致频繁的FullGC。

四、拓展

4.1 堆内存大小

众所周知,JVM的堆内存不宜设置的太大或太小,本案例中内存设置的偏小。

但是我们现在有的应用8G的宿主机内存JVM只用了2个G,也有的应用4G的内存JVM用了4个G。

这两种都是错误的示范,JVM内存使用率过小会导致更可能的发生FullGC。而使用率过大甚至超过了宿主机内存,轻则导致容器内存使用率一直告警,重则会导致内存使用率100%而导致所有的业务都无法响应。

4.2 具体情况具体分析

2.3.4中我们提到了,我们上文根据字符串找到了对应的请求接口,并且确定了一次接口请求时长在3秒左右,而平均30秒才会发生一次GC,因此不可能存在一个对象被YGC了15次都没有回收掉的的情况(接口请求完成后,该对象无引用,一次YGC就会被回收掉),所以排除了大对象时通过年龄过大进入的老年代。

但是如果现在是这样的一种场景:有一个定时任务 ,会固定创建一个大对象,并且每次处理时间会长达半个小时,YGC同样也是30秒一次,这个时候出现频繁的FullGC,此时我们就要考虑是否是YGC次数过多导致的进入老年代。

因此,具体情况应该具体分析。像上述的假设条件,就可以考虑调整进入老年代年龄的阈值。

-XX:MaxTenuringThreshold

4.3 留存信息

像排查生产问题一样,我们在排查生产问题时,如果已经通过其他方式可以解决此问题,但是并没有找到具体的原因时,一般会留1到2台机器作为案发现场,以方便后续排查具体的问题。

同样的在进行JVM参数优化时,可以只设置部分节点,另一部分节点作为对比,同时保留每次操作前后的截图和数据,方便后续复盘和总结使用。

4.4 有选择的使用Parallel收集器

Parallel收集器因为默认开启了自适应分配内存策略,所以优点是会动态调整JVM各个区域和各种参数的配置,以减少GC所产生的等待事时间。但是如我们本文所分析的一样,他有时候可能会过分的为了减少GC时间而导致了某些区域内存分配明显不合理,而带来的增加内存GC频率的问题。

因此如果我们启动脚本里没有指明使用那个垃圾回收器,需要关注一下JVM的内存使用情况。