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 | public static SettlementRecordDaily getQuery(SettlementRecordDaily current, Integer tradeType) { |
这是一个组装数据库查询条件的方法,昨天发版之后,存在 showReference 或者 gatewayPayNo 为空的情况。这样组装出来的查询条件,会从数据库查询出大量的结果。
慢查询
再登录到慢查询平台,这段时间果然发生了慢查,印证了这个结论。
慢查询
修复方案
知道原因之后,修复起来就简单了。只需在代码里加个判断,如果 showReference 或者 gatewayPayNo 为空,就不查询数据库,直接返回空集合即可。
1 | public List<SettlementRecordDaily> queryDailyList(SettlementRecordDaily current, Integer tradeType) { |
小结
这是一个典型的 OOM 场景,由于组装查询条件的代码不够严谨,从而发生拖表的情况。这样查询回来的大对象无法被立即回收,只能放入老年代。当老年代被打满后,就会触发 OOM,导致系统重启。
改进点:
- 发生 OOM 的第一时间,就应该登录慢查询平台,查看是否发生慢查询。
- 业务代码在组装数据库查询条件时,需要要考虑是否存在拖表的情况。
- 如果是通用的查询语句,在不影响业务的情况下,可以考虑增加 limit 限制。