单线程导出百万数据导致 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 可达 |
| 构建 Excel | Workbook 对象持有所有行数据 | 否 | 正在使用中 |
| 写入磁盘 | 数据仍在内存 + 输出流缓冲 | 否 | 写入不等于释放 |
每个中间环节的对象都是强引用,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 无法回收 | 对象还被引用链持有 | 方法作用域自然失效 |
核心认知转变:不是"加内存",而是"减少单次处理的数据量"。大数据量处理的铁律 ——化整为零,分而治之。