mybatis 、ThreadPoolExecutor 导致的 oom 源码角度分析以及解决方案

前言

正所谓前人,栽树后人乘凉。随着项目业务逻辑越来越复杂,屎山代码越积越高,部分接口的响应速度开始变的有点慢,于是乎想着用 CompletableFuture 去将代码去优化一下。但是优化着优化着就感觉有点不对劲了,电脑温度蹭蹭的往上涨,一看内存都要爆了。oom一下子就出现了。

Mybatis 源码系列文章地址

点击查看 Mybatis 源码文章

CompletableFuture 常用方法简单介绍不做文本重点

创建一个异步任务并执行返回值为1

CompletableFuture<Integer> task1 = CompletableFuture.supplyAsync(() -> {return 1;});

阻塞主线程,等待 task1、task2 都执行完毕

 CompletableFuture.allOf(task1,task2).join();

获取 task1 任务的返回值,task1 未执行完成前主线程将一直阻塞

task1.get()

用 50 mb 内存查出 50 w数据方案介绍

可能很多人第一想到的是使用 limit 关键字分多批次去查询数据,最后将数据汇总。但是这里处理不好的话还是会存在问题的,首先你要考虑游标的问题,如果表结构 id 是自增的还好办,如果是非自增的有够你头疼的,而且你是将多批次查询得到的数据全部汇总到一个容器还是多个容器?不管是一个容器还是多个容器,最终是不是都是 50 w的数据打到了服务器内存上,避免不了 oom。业务代码中大致写了这么一条sql,后续业务逻辑需要根据数据做统计,导致一次性把50w的数据从数据库查出来了,一下子就把内存打满了,直接 oom。

  1. 方法一:想着用多线程去拆分,每次取的数据量小一点然后最后结果用CompletableFuture合并来着,但是写着写着进行测试的时候,每次取数据量这个值设置多少是个问题。
  2. 方法二:直接写复杂sql脚本逻辑封装在sql里面减少数据量的返回。(但是sql执行的慢呀,整体接口查询还是慢)
  3. 方法三:通过设置 FetchSize 参数流逝查询数据,这样内存将平稳,不会一下子就给他打满(优选
select * from user

使用 mybatis 大数据量查询为什么会导致 oom?

由于之前看过 mybatis 的源码,知道他其实就是对 jdbc 的封装而已,我这里就不卖关子的直接定位到问题代码。由于 mybatis 在解析结果集的时候,会将解析好的数据全部都放到 resultContext 容器中。最终调用 Mybatia 默认的 DefaultResultSetHandler 将数据存起来,一块返回给用户。注意由于是一次性全部解析好且全部数据一次性反给用户,这才造成了 OOM。为了避免 OOM 的产生,我们手动实现 Mybatis 为我们提供的扩展接口 ResultHandler 就行,每查出一部分数据,就 GC 一下,这样内存不就趋于平稳了。

  private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler<?> resultHandler, RowBounds rowBounds, ResultMapping parentMapping)
    throws SQLException {
    DefaultResultContext<Object> resultContext = new DefaultResultContext<>();
    ResultSet resultSet = rsw.getResultSet();
    skipRows(resultSet, rowBounds);
    //while循环直至解析完从数据库中返回的所有数据
    while (shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() && resultSet.next()) {
      ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(resultSet, resultMap, null);
      //根据 resultSet 获取每一行数据
      Object rowValue = getRowValue(rsw, discriminatedResultMap, null);
      //将解析好的每一条数据逐个存储在一个容器(resultContext)中,最终返回给我们
      storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet);
    }
  }

在这里插入图片描述
以上源码可以简化成以下这个代码,50w条数据不断的被解析放到 list 中,list 占用内存一直在蹭蹭的往上涨。当大到服务器最大内存时 oom 可不就出现了吗。这也是为什么大家一开始学 java 的时候,少使用 select * 的原因,查出一些无用字段,这些字段都是要消耗内存的,少给服务器增加压力了,而且 sql 执行效率也会变的慢。

import java.util.ArrayList;
import java.util.List;

public class OOMExample {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            byte[] bytes = new byte[1024 * 1024]; // 每次分配1MB的内存
            list.add(bytes);
        }
    }
}

知道了为什么使用 mybatis 查询大量数据会导致 oom 的原因后,那有没有什么办法可以做到用很小的内存就查出所有的数据吗?答案肯定是有的,不是有一个叫做 GC 的东西吗。垃圾回收呀。
每查定量的数据后,回收内存不就好了。

FetchSize 参数原理说明

我用的是 mysql 这里先简单介绍一下 FetchSize 参数的作用,当我们为 Statement 设置了 FetchSize 值为 10 后,假设下面这条 SQL 可以查出来 1000 条数据,那么 ResultSet 将需要进行 100 次网络传输从 MYSQL 中去取数据,每次取 10 条存储至 ResultSet 。当我们不设置 FetchSize 时,默认就是一次网络传输返回所有的数据。

  • 前者花费的时间 = 10 次网络传输 + MYSQL 执行语句的时间。
  • 后者花费的时间 = 1 次网络传输 + MYSQL 执行语句的时间。

但是后者一次性存储 1000 条数据,由于服务器内存过小可能会造成 OOM。这个 OOM 是 JDBC获取大量数据一次性存储至 ResultSet 产生的 OOM。Mybatis 也考虑到了这种情况就是可以让开发者自定义此次查询的 FetchSize 的值,以及提供了 ResultHandler 扩展接口,让开发者可以自定义处理结果集。接下来用代码给读者举例!

  Class.forName(driver);
  conn = DriverManager.getConnection(url, username, password);
  stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
  stmt.setFetchSize(Integer.MIN_VALUE);
  String sql = "select * from service_outsourcing_implement";
  stmt.setFetchSize(10);
  rs = stmt.executeQuery(sql);
  int count = 0;
  while (rs.next()) {
    count++;
  }
  System.out.println(count);

用 50 mb 内存查出 50 w数据方案实现

首先我们自定义一个实现 ResultHandler 接口用于接收 Mybatis 解析好结果集的每一条数据。

public class CusResultInfoHandler implements ResultHandler<ServiceOutsourcingImplement> {
  //存储每批数据的临时容器
  private List<Object> resultInfoList = new ArrayList<>();

  public List<Object> getResultInfoList() {
    return resultInfoList;
  }

  @Override
  public void handleResult(ResultContext<? extends ServiceOutsourcingImplement> resultContext) {
    ServiceOutsourcingImplement custTaskResultInfo = resultContext.getResultObject();
    resultInfoList.add(custTaskResultInfo);
    if (resultInfoList.size() == 10000) {
      //
      System.err.println("根据流逝查询的10000条数据做业务逻辑处理");
      //gc
      resultInfoList.clear();
    }
  }
}

第二步开启流逝查询支持(数据库 url 末尾拼接)

&useCursorFetch=true

第三步创建测试用例并设置最大运行内存 50 mb

-Xmx50m

在这里插入图片描述

测试用例如下,一次性查出 20 万条数据。我这里利用到的是 Mybatis 里面的 SimpleExecutor 执行的查询操作(比较原生一点),各位编写普通的 Service 查询代码也一样。

在这里插入图片描述
第四步设置 FeactSize 的大小,由于之前研究 Mybatis 源码的时候,把整个代码克隆到了本地,我就直接在源码里面修改了。依次设置 FetchSize 为 1,100,1000看看他的执行效率如何。
在这里插入图片描述

当设置 FetchSize 为 1时耗时 5844 ms

在这里插入图片描述
当设置 FetchSize 为 1000 时耗时 2134 ms,速度直接快了一倍
在这里插入图片描述

当设置 FetchSize 为 10000 时耗时 2325 ms,可见 FetchSize 的值并不是设置的越大越好。

在这里插入图片描述

最终结果就是使用 50 mb 内存在几秒时间内,成功的查出来几十万条数据,当然数据的流逝的查出来的,各位的业务代码最终肯定是要考虑将结果合并的。如果是做统计功能的话,将多批次得到的统计结果,最终合并就好了。如果是做数据迁移的话,改成流逝迁移就好了,这样内存将趋于平稳。不会一下子 OOM。

总结

本文从 Mybatis 解析结果集源码剖析了,造成 OOM 的原因。

  1. 当没有设置 FetchSize 的值的时候,数据库默认一次性将所有数据返回至 ResultSet,这时候还没有涉及到 Mybatis 的事就已经造成了 OOM,
  2. 设置了 FetchSize 的值,但是流逝处理数据的过程中,你并没有手动的去 GC 回收内存,一样也会 OOM

补充如果当你的 Sql 过长也就是写出了这种 Sql ,当 ids 过多时,Mybatis 会用容器存储所有的 ids ,然后依次遍历 ids 容器进行拼装 Sql ,这种时候也会造成 OOM。到此本文完结撒花!!!!!!!

select * from user where id in
 <foreach collection="ids" index="index" item="item" open="(" separator="," close=")">
        #{item}
    </foreach>

附页-模拟线程池自定义拒绝策略导致的内存泄露问题

设置 vm 参数指定堆内存 10 mb 、dump 文件位置:

-Xmx10m -Xms10m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/Users/zhangzixing/dump/grpc.dump

一次性组装 1000 个任务丢进线程池, 并调用 get()阻塞获取所有任务的执行结果

private static ThreadPoolExecutor oomThreadPool = new ThreadPoolExecutor(1,
        1,
        1,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1),
        new MyThreadFactory("zzh"),
        new MyRejectedPolicy());

自定义拒绝策略里面只打印任务信息,被拒绝的任务也不会抛出异常。

在这里插入图片描述

oom 方法每调用一下发现都卡住了,原因就是被拒绝的任务休眠了而且没有抛出异常,并不会去执行 run 方法,当调用 oomTaskFutureEntry.getValue().get() 这行代码时,由于被拒绝的任务休眠了,将一直阻塞在这里,造成 Future 对象一直没法被回收。每调用一次 oom 方法就会导致服务器内存被占用无法释放。调用次数多了的话,服务内存就会被打满,造成 oom 堆内存溢出的情况。

附页-模拟线程池自定义拒绝策略导致的内存泄露问题排查

用 jps 看一下进程 id 为 71866
在这里插入图片描述
连掉几次 oom 方法, 每隔 5 秒打印一下内存信息,看到 eden 区一下子就被打满了,老年代(O)也满了,开始频繁的进行 full gc,

 jstat -gcutil 71866 5000 

在这里插入图片描述
在等个一分钟,看到进行了 2000 多次的 full gc ,eden 区和老年代内存依然没法被回收,说明代码中一定存在内存泄露。

在这里插入图片描述
接着使用如下命令分析 dump 内存文件

jhat  -J-Xmx2G  /Users/zhangzixing/dump/grpc.dump

在这里插入图片描述

浏览器 http://localhost:7000直接访问就行

在这里插入图片描述

直接搜索本项目的统一前缀包名 zzh,挨个定位一下哪个类发生了内存泄露,最终定位到是 LoginServiceRpcImpl 这个类里面有异常

在这里插入图片描述

点进去发现有几百个 272 字节的对象一直被引用,这些对象就是导致内存泄露的原因

点进去精确定位一下是那些代码有问题,可以看到是LoginServiceRpcImpl -> AbstractExecutorService (ThreadPoolExecutor线程池)-> FutureTask 对象没法被回收导致的
在这里插入图片描述

最终定位原因:oom 方法每调用一下发现都卡住了,原因就是被拒绝的任务休眠了而且没有抛出异常,并不会去执行 run 方法,当调用 oomTaskFutureEntry.getValue().get() 这行代码时,由于被拒绝的任务休眠了,将一直阻塞在这里,造成 Future 对象一直没法被回收。每调用一次 oom 方法就会导致服务器内存被占用无法释放。调用次数多了的话,服务内存就会被打满,造成 oom 堆内存溢出的情况。

在这里插入图片描述

解决方法 get() 方法设置超时时间,或者自定义拒绝策略抛出异常,让其避免内存泄露。

小咸鱼的技术窝

关注不迷路,日后分享更多技术干货,B站、CSDN、微信公众号同名,名称都是(小咸鱼的技术窝)更多详情在主页
在这里插入图片描述

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小咸鱼的技术窝

你的鼓励将是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值