记一次OOM问题排查以及引出的GC优化

一)介绍

我们有一个服务,主要是给一些商家或者供应商查看售卖的票信息,例如报表下载和一些售票的查询等等。

因为平时用的人比较少,而且我们这边可以配置自动扩容的阈值,所以机器配置是 2C8G,两个机房分别 3 台。

项目很少发版本,最近一次的发版本还是前一个月。

二)问题出现

在某一个下午,突然系统告警,16:52 显示有一个机器 OOM 了,因为我们的运维平台会自动重启实例,所以这个出问题的实例当时的容器快照没有保存下来,不过比较好的是,发生 OOM 时的堆快照被保留下来了。

于是直接登录机器准备下载快照分析,下载比较耗时,在这段时间正好也没事,顺带就看了下容器的整体指标

三)分析

1)QPS 上升导致的?

因为我们是 16:52 出现的告警,因此首先去看的是 16:40 以后的请求 QPS 有不有上升,发现非常平稳,Redis 和 MySQL 也没有出现告警,所以初步怀疑是某一个接口的 bug 导致内存泄漏。

2)容器指标

在排查 QPS 无异常以后,直接查看容器的 JVM 情况,发现非常不乐观,在将时间向前选择到 16:30 以后,发现各个指标均出现异常。

image-20221105192453087

可以看到在 16:30 到 16:35 之间,突然出现了 4 次 FullGC,同时 JVM 的老年代在 16:30 以后直接突然上升。

在 JVM 触发 FullGC 以后,老年代也会被回收,但是看老年代的内存,不减反而一直在增加,那就说明一个问题,有大量的对象直接晋升到了老年代。

排查到这里以后,基本确定是代码问题,首先因为大量对象晋升,触发 FullGC,引起 CPU 使用率上升,被宿主机限流。

3)error 日志

从监控看发生时间应该是 16:30 附近,所以将日志查询时间提前到 16:30,查看到如下异常:

image-20221105141553115

image-20221105141722433

问题基本确定在某一个报表导出接口上,顺着日志逆推接口,很快就定位到响应的接口了。

4)堆快照

image-20221105142410306

此时堆的快照文件已经下载下来了,用 mat 查看以后,按照堆内存使用情况排序,可以看到大量的 com.mysql.cj包的对象,初步猜测是数据库查询量太多。

至此,问题大致应该已经定位到了,应该是某一个接口在查询 MySQL 的大量数据,然后将其生成 Excel 供用户下载。

四)修复

通过之前的报错日志,反推接口伪代码如下:

1
2
3
4
5
6
7
8
public void export(Args args) {
List<XX> list1 = getfromDB(args);
List<XX2> list2 = list1.parallelStream()
.filter(对某些条件进行过滤)
.map(转化为另一个对象)
.collector(Collectors.toList);
Excell.write(list2);
}

本着优先解决线上问题的要求,第一时间先加限制,如下:

  1. 经过我们的对 DB 数据量的评估,最多只能查询一个月比较合理,因此第一步限制了接口的查询范围。

  2. 对机器进行扩容,由原来的 8G 直接扩容至 16G。

五)复盘

1)业务上

1.1)接口的限制

对于需要从 DB 通过日期查询的数据,必须加限制,同时限制做成可以动态配置的,最好是全局限制,防止业务人员变动导致后续忘记在某一个接口添加限制了。

如果有额外的需求,那么就通过数据异构的方式生成专门用于报表的表,或者直接将此类需求转给 BI。

2)技术上

2.1)对 Stream 进行优化

在 Java 中,由于 Stream 具有不可变性,因此 Stream 的每次操作都是会生成一个新的 Stream,同时原来的 Stream 不会被释放。

但是对于我们的业务来说,list1 中的每一个元素,在转变为 list2 中的一个元素以后,就不会再被使用了,因此可以直接设置为 null,提前 GC。

测试如下:

首先设置 JVM 的参数为:-Xmx120M

image-20221105160822279

这段代码首先从 getFromDB方法里面获取一个 List,大小为 100M,而堆的最大内存是 120M,在将 list1 转化为 list2 的过程中,可以看到 list1 中的每一个元素在使用后就可以被 GC 回收。但是使用 stream 无法提前释放从而触发 OOM。

于是进行如下优化:

image-20221105160918763

可以看到 OOM 已经不会出现了。

这是因为在 GC 回收的时候,list1 是一个可达对象,所以 list1 中的元素是无法被回收的,那么经典八股文来袭,那些对象是可以作为可达对象?

  1. 被 system class loader 所加载的类
  2. 方法区中静态变量或者引用
  3. 活跃的线程
  4. 方法中的本地变量或者参数
  5. JNI handles:全局和本地的
  6. 对象被加 synchronized 锁
  7. 被 JVM 所持有的对象:基本数据类型等等

所以 list1 是一个活跃线程中的虚拟机栈中的一个本地变量,因此可以作为 GC Root,那么 list1 中的所有元素就无法被释放。

2.2)关于help gc

这个注释其实在 JDK 中也有出现,通过全局搜索 help gc 可以看到很多核心类库都有提前设置 null 的操作。

image-20221105194458401

以 LinkedList 为例,在 unlinkFirstunlinkLast 的方法内部,均有 help GC 的操作,可以看看 LinkedList 的做法。

LinedList

image-20221106011207894

那么假设有一个 List 如下,初始的时候有三个 Node,然后经过两次调用以后,first 指针指向了 node3,如果每一次的调用都没有设置 next 为 null,那么就有可能会导致内存泄漏。

image-20221106135609119

假设现在 node2 已经使用完了,但是由于 node1 的 next 还是持有它的强引用,如果说外部还持有 node1 的强引用,那么 node2 也算一个可达对象,因此无法被释放。

既然 JDK 中这么写,那么肯定有他的原因的,那什么地方持有 f 的引用呢?答案是 listIterator方法。代码如下:

image-20221106143608919

可以看到,如果 index 给的是0,那么 ListItr 的 next 指针还是会持有 node1 的引用,因此 node1 的 next 如果不设置 null,那么就会导致一个引用链出现。

所以这里的 f.next = null 还是有点用的。计算有引用,那也就是 node1,不会导致很大的问题。

好了,继续说回到优化上。

2.2)替换 poi 为其他优秀的excel导出工具

这部分目前还在调研测试中

2.3)配置 DB 慢查询告警

这种大量数据查询,而且又是时间范围的,一般都比较慢,配置慢查询告警可以在提前发现一些问题。

六)其他

在看容器的情况的时候,出现了很多 close wait 的连接。

image-20221105140357193

看时间也和 FGC 触发的时间吻合,但是具体是什么原因暂时还未知,因为很多现场都已经没了,等待后续复现抓包分析。

猜测是某种情况下,对方主动关闭了 socket 连接,但是这台机器上由于某些原因没有回复 FIN。

作者

Somersames

发布于

2022-11-05

更新于

2022-11-06

许可协议

评论