news 2026/6/10 9:28:30

单线程导出百万数据导致 OOM —— 多线程分批导出实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
单线程导出百万数据导致 OOM —— 多线程分批导出实战

单线程导出百万数据导致 OOM —— 多线程分批导出实战

日期:2026年6月9日
分类:后端开发 / 性能优化
标签:#OOM #文件导出 #多线程 #JVM #堆内存 #分批处理


一、问题场景

在一次数据导出需求中,需要一次性导出百万级别的数据到 Excel 文件。单线程实现方案上线后,服务频繁出现 OOM(OutOfMemoryError),导致接口不可用。

现场还原

用户请求导出全量数据 ↓ SELECT * FROM t_order(百万行) ↓ 全部加载到 JVM 堆内存 → 构建 Excel Workbook ↓ 服务器堆内存飙升至 1GB+,老年代被打满 ↓ Full GC 无法回收 → OOM → 服务崩溃

二、为什么单线程导出会 OOM?

2.1 核心原因:内存"蓄水池"效应

单线程导出本质是一个内存蓄水池模式 —— 数据从数据库流出,但在写入磁盘之前全部滞留在堆内存中。

阶段内存占用能否被 GC 回收原因
查询结果集全部数据在 ResultSet/List 中还被引用,GC Root 可达
构建 ExcelWorkbook 对象持有所有行数据正在使用中
写入磁盘数据仍在内存 + 输出流缓冲写入不等于释放

每个中间环节的对象都是强引用,GC 无法回收,内存只能向上增长。

2.2 用 Java Visual VM 来"看见"问题

通过 Java Visual VM 连接运行中的应用,观察堆内存变化:

导入前 → 堆内存 200MB,老年代空闲 80% ↓ 点击导出 → 堆内存开始飙升 ↓ 500MB → 800MB → 1.2GB ... ↓ 老年代被打满,老年代占用 100% ↓ JVM 触发 Full GC → 但无对象可回收(全被引用) ↓ java.lang.OutOfMemoryError: Java heap space

关键观察指标

指标正常OOM 前
老年代占用< 70%100%
Full GC 频率极少连续触发
GC 吞吐量> 99%急剧下降
堆内存曲线波动一路向北

触发 OOM 后,堆内存回收较慢,可以在 Java Visual VM 中手动点击“执行垃圾回收”按钮加速回收,以便快速恢复服务。


三、多线程分批导出解决方案

3.1 核心思路

单线程:一次查全量 → 全部加载内存 → 全量写入 ↑ 问题在这里 多线程:先查总数 → 分页 → 每页是一个子任务 → 线程池并发执行

本质:把一个大"蓄水池"拆成 N 个小"水杯",每个水杯用完即倒掉,内存始终可控。

3.2 实现步骤

第一步:SELECT COUNT(*) → 获取总记录数 total 第二步:计算分页 → 每页 pageSize 条,共 totalPage = total / pageSize 页 第三步:为每一页创建任务 → 每个任务查询一页数据并写文件 第四步:通过线程池执行 → 并发度控制在核心数 × 2 左右 第五步:所有任务完成 → 合并文件 / 返回结果

3.3 代码示例

// 1. 查询总数inttotal=orderMapper.countAll();intpageSize=5000;// 每页 5000 条inttotalPage=(total+pageSize-1)/pageSize;// 2. 创建线程池(固定大小,避免无限创建线程)ExecutorServiceexecutor=Executors.newFixedThreadPool(8);// 3. 使用 CountDownLatch 等待所有任务完成CountDownLatchlatch=newCountDownLatch(totalPage);for(intpage=0;page<totalPage;page++){finalintoffset=page*pageSize;executor.execute(()->{try{// 每次只查一页数据List<Order>pageData=orderMapper.selectByPage(offset,pageSize);// 将当前页数据写入 Excel(流式写入)exportToExcel(pageData,outputStream);// pageData 离开作用域后,GC 即可回收}finally{latch.countDown();}});}latch.await();// 等待所有任务完成executor.shutdown();

3.4 安全配置

// 固定线程数,不要让每个请求都无限创建线程ExecutorServiceexecutor=newThreadPoolExecutor(8,// corePoolSize8,// maxPoolSize60L,TimeUnit.SECONDS,newLinkedBlockingQueue<>(100),// 有界队列,防止任务堆积newThreadPoolExecutor.CallerRunsPolicy()// 拒绝策略:任务回退给主线程);

四、为什么多线程可以避免 OOM?

4.1 关键差异

维度单线程多线程分批
单次查询数据量百万条5000 条 / 页
内存峰值全部数据 + Excel 对象单页数据 + 局部 Excel 对象
对象存活周期直到写入完成(全程强引用)写入一页后方法返回,局部变量失效
GC 时机无(对象全在使用中)每写完一页,该页对象变为垃圾
老年代压力大量长期存活对象晋升老年代短命对象在年轻代即被回收

4.2 内存模型对比

【单线程】 ┌────────────────────────────────────────┐ │ JVM Heap │ │ (1.2 GB 打满) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ 全部百万条数据 + Excel │ │ │ │ (全部为强引用,无法 GC) │ │ │ └──────────────────────────────────┘ │ │ │ └───────────────────────────┬────────────┘ │ OOM ✕ 【多线程分批】 ┌────────────────────────────────────────┐ │ JVM Heap │ │ (稳定在 300MB) │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │第 N 页│ │第N+1页│ │第N+2页│ │第N+3页│ │ │ │5000条 │ │5000条 │ │5000条 │ │5000条 │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ ↑ ↑ ↑ ↑ │ │ 线程1 线程2 线程3 线程4 │ │ │ │ 写入完毕 → 方法返回 → 局部变量失效 │ │ → Minor GC 回收年轻代 → 内存平稳 │ └────────────────────────────────────────┘

4.3 核心原理拆解

原理一:分页查询 — 每次只取少量数据

// 单线程:一把梭 List<Order> all = mapper.selectAll(); // 百万条,内存爆炸 // 多线程:蚂蚁搬家 List<Order> page1 = mapper.selectByPage(0, 5000); // 5000 条,~5MB List<Order> page2 = mapper.selectByPage(1, 5000); // 5000 条,~5MB ...

每次查询返回的数据量从"百万条"降为"5000 条",内存需求从 GB 级降为 MB 级。

原理二:方法作用域 — 局部对象自动失效

executor.execute(()->{List<Order>pageData=mapper.selectByPage(offset,pageSize);exportToExcel(pageData,outputStream);// ← 方法返回,pageData 引用失效// ← 下一轮 Minor GC 即可回收});

每个线程执行完一个分页任务后,方法栈帧弹出,局部变量(pageData、临时 Excel 对象等)失去引用,成为垃圾。

原理三:年轻代回收 — 短命对象不进老年代

对象生命周期: 创建 → 使用 → 释放(几秒内) 这种"朝生夕灭"的对象: → 在年轻代 Eden 区分配 → Minor GC 时直接回收 → 根本不会晋升到老年代 → 老年代一直保持健康水位

原理四:并发写入 — 不等待全量数据

多线程并行写入意味着数据"边查边写",不需要等全部数据就绪才开始写文件。每页数据从查出到写入仅在内存中停留很短时间。

4.4 一句话总结

单线程让所有垃圾"蓄"在堆里直到最后一起回收 —— 蓄爆了。
多线程分批让垃圾"边产边扔",年轻代 GC 就能轻松清理,根本不给老年代添乱。


五、补充优化建议

优化点方案效果
流式 Excel 写入用 EasyExcel / SXSSFWorkbook 替代 XSSFWorkbook避免 Excel 对象本身也占大量内存
游标查询MyBatisResultHandler或流式查询数据库侧不一次加载全量结果集
上传 OSS + 异步通知文件直接上传到对象存储,导出完成后推送通知避免接口超时,提升用户体验
JVM 参数调优-Xmx设置合理上限 + 设置年轻代比例给 GC 留足空间

六、小结

问题根因方案
单线程导出 OOM全量数据一次加载到内存,强引用无法回收分页查询 + 分批处理
老年代被打满大对象 / 长时间存活对象晋升老年代缩短对象生命周期,让年轻代 GC 处理
GC 无法回收对象还被引用链持有方法作用域自然失效

核心认知转变:不是"加内存",而是"减少单次处理的数据量"。大数据量处理的铁律 ——化整为零,分而治之

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/10 9:23:11

信号分解算法选型指南:从EMD到VMD,如何根据你的数据特征避开模态混叠?

信号分解算法实战指南&#xff1a;如何针对数据特性选择最佳方法在工程测量、生物医学、金融分析等领域&#xff0c;非平稳信号的处理一直是技术难点。面对复杂信号中的噪声干扰、趋势成分和瞬时特征&#xff0c;传统傅里叶变换等线性方法往往力不从心。自适应信号分解算法应运…

作者头像 李华
网站建设 2026/6/10 9:17:14

生信分析避坑指南:你的多序列比对为什么总失败?从序列准备到工具选择的5个常见错误

生信分析避坑指南&#xff1a;多序列比对失败的5个关键原因与解决方案刚接触生物信息学的同学&#xff0c;第一次运行Clustal Omega时看到满屏的报错信息&#xff0c;往往会陷入手足无措的境地。上周有位临床医学转生信的博士生向我展示他的比对结果——本该整齐排列的蛋白质序…

作者头像 李华
网站建设 2026/6/10 9:02:40

win11右键菜单太复杂如何更改为win10的简洁菜单教程

Windows 11引入了全新的右键菜单设计&#xff0c;虽然界面更加简洁美观&#xff0c;但许多用户发现每次右键都需要点击"显示更多选项"才能看到完整的菜单选项&#xff0c;这无疑增加了操作步骤&#xff0c;降低了工作效率。本文将详细介绍几种方法&#xff0c;帮助您…

作者头像 李华
网站建设 2026/6/10 8:55:01

模板驱动的文档自动化:从填空题到智能装配流水线

1. 项目概述&#xff1a;用模板把文档生产变成“填空题”你有没有过这种体验&#xff1a;每周要交三份客户方案&#xff0c;每份结构雷同——封面、目录、痛点分析、解决方案、报价页、服务承诺——但每次都要从零新建Word、手动调格式、复制粘贴旧内容、反复检查页眉页脚是否错…

作者头像 李华