OOM 踩坑日记

Java 的异常分为两个体系,Error 和 Exception。Error 表示严重的错误,程序对此一般无能为力;而 Exception 则是运行时的错误,它可以被捕获并处理。今天遇到的 OOM(OutOfMemoryError) 就是一种 Error,而且是最严重的一种,它会导致 Java 进程关闭。

问题描述

早上来发现服务由于健康检查失败,自动重启了。回想了一下最近有什么变更,大概率与昨天发版的内容有关。

健康检查失败

服务重启

原因定位

登录 grafana 平台,查看服务相关的监控(环境是 JDK 8,垃圾收集器是 ParNew + CMS)。

非堆内存

metaspace 空间

metaspace 空间占用平稳,无异常

堆内存

eden 空间

从 09.16 开始,Eden 空间 gc 频繁

survivor 空间

从 09.20 开始,新生代存活对象激增,导致 Survivor 空间不足

Eden 和两个 Survivor 分区的默认比例是 8:1:1。可以通过 –XX:SurvivorRatio 参数设置。

老年代空间

从 09.22 开始,因为老年代存在 内存分配担保机制,当 Servivor 空间不足时,后续存活对象直接放入老年代,导致老年代空间暴增。

major gc 时间长达 10s

从 09.22 开始,major gc 时间显著增长,老年代的内存一直回收不掉,最终触发 OOM,导致系统重启

于是让运维同事帮忙下载 dump 文件。这里用到了两个 JVM 参数:

  • -XX:+HeapDumpOnOutOfMemoryError
    • 表示当 JVM 发生 OOM 时,自动生成 dump 文件。
  • -XX:HeapDumpPath=${目录}
    • 表示生成 dump 文件的路径,也可以指定文件名称。

原因分析

在等待 dump 文件下载期间,分析了几个可能发生 OOM 的原因:

  • metaspace 空间溢出。
    • 首先排除
    • 因为 metaspace 空间稳定,没有出现暴增的情况。
  • 内存泄漏。
    • 存在一定可能,仔细分析后排除
    • 老年代空间从 09.20 开始增加,仅过了 2 分钟,09.22 就已经将老年代全部占满。如果是内存泄漏,内存占用速度不会这么快。
  • 存在回收不掉的大对象。
    • 极有可能
    • 根据 Eden 空间频繁 gc,Survivor 空间又不足以存放存活对象,以至于短时间内老年代空间就被打满,可以推断,存在回收不掉的大对象。

当前对象集

拿到 dump 文件之后,用 JProfiler 工具进行分析。

JProfiler 当前对象集

SettlementRecordDaily 对象是一个数据库表 entity。现在居然有 335M,非常可疑。

JProfiler 最大对象

前三大对象的内存占比达到 96%,无法回收的大对象就隐藏在其中。从 JDBC42ResultSet 和 DefaultResultHandler 可以推断,大对象与数据库查询结果有关

查看引用堆栈

选中大对象 -> 使用选定对象 -> 传入引用 -> 显示更多 -> 查看完整堆栈信息

使用选定对象

传入引用

Java 堆栈

显示更多

完整堆栈信息

根据堆栈信息,定位到 AdditionalRecordInfoServiceImpl 类的第 732 行。

setAllRemainingAmountRefundFlag 方法

再顺藤摸瓜到 getQuery 这个方法。

1
2
3
4
5
6
7
8
public static SettlementRecordDaily getQuery(SettlementRecordDaily current, Integer tradeType) {
if (Objects.equals(CombineVersionEnum.COMBINE.getVersion(), current.getCombineVersion())) {
return SettlementRecordDaily.builder().showReference(current.getShowReference()).tradeType(tradeType).build();
} else {
return SettlementRecordDaily.builder().gatewayPayNo(current.getGatewayPayNo()).tradeType(tradeType).build();
}
}

这是一个组装数据库查询条件的方法,昨天发版之后,存在 showReference 或者 gatewayPayNo 为空的情况。这样组装出来的查询条件,会从数据库查询出大量的结果

慢查询

再登录到慢查询平台,这段时间果然发生了慢查,印证了这个结论。

慢查询

修复方案

知道原因之后,修复起来就简单了。只需在代码里加个判断,如果 showReference 或者 gatewayPayNo 为空,就不查询数据库,直接返回空集合即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public List<SettlementRecordDaily> queryDailyList(SettlementRecordDaily current, Integer tradeType) {
String queryKey = null;
SettlementRecordDaily query = SettlementRecordDaily.builder()
.channel(current.getChannel())
.tradeType(tradeType)
.build();
if (Objects.equals(CombineVersionEnum.COMBINE.getVersion(), current.getCombineVersion())) {
queryKey = current.getShowReference();
query.setShowReference(queryKey);
} else {
queryKey = current.getGatewayPayNo();
query.setGatewayPayNo(queryKey);
}
//如果 queryKey 值为空,则直接返回空集合,防止查询数据库时发生 OOM。
if (StringUtils.isBlank(queryKey)) {
return Collections.EMPTY_LIST;
}
List<SettlementRecordDaily> dailyList = settlementRecordDailyMapper.selectByEqual(query);
return dailyList;
}

小结

这是一个典型的 OOM 场景,由于组装查询条件的代码不够严谨,从而发生拖表的情况。这样查询回来的大对象无法被立即回收,只能放入老年代。当老年代被打满后,就会触发 OOM,导致系统重启。

改进点:

  • 发生 OOM 的第一时间,就应该登录慢查询平台,查看是否发生慢查询
  • 业务代码在组装数据库查询条件时,需要要考虑是否存在拖表的情况
  • 如果是通用的查询语句,在不影响业务的情况下,可以考虑增加 limit 限制

引用