
单线程导出百万数据导致 OOM —— 多线程分批导出实战日期2026年6月9日分类后端开发 / 性能优化标签#OOM #文件导出 #多线程 #JVM #堆内存 #分批处理一、问题场景在一次数据导出需求中需要一次性导出百万级别的数据到 Excel 文件。单线程实现方案上线后服务频繁出现 OOMOutOfMemoryError导致接口不可用。现场还原用户请求导出全量数据 ↓ SELECT * FROM t_order百万行 ↓ 全部加载到 JVM 堆内存 → 构建 Excel Workbook ↓ 服务器堆内存飙升至 1GB老年代被打满 ↓ Full GC 无法回收 → OOM → 服务崩溃二、为什么单线程导出会 OOM2.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. 查询总数inttotalorderMapper.countAll();intpageSize5000;// 每页 5000 条inttotalPage(totalpageSize-1)/pageSize;// 2. 创建线程池固定大小避免无限创建线程ExecutorServiceexecutorExecutors.newFixedThreadPool(8);// 3. 使用 CountDownLatch 等待所有任务完成CountDownLatchlatchnewCountDownLatch(totalPage);for(intpage0;pagetotalPage;page){finalintoffsetpage*pageSize;executor.execute(()-{try{// 每次只查一页数据ListOrderpageDataorderMapper.selectByPage(offset,pageSize);// 将当前页数据写入 Excel流式写入exportToExcel(pageData,outputStream);// pageData 离开作用域后GC 即可回收}finally{latch.countDown();}});}latch.await();// 等待所有任务完成executor.shutdown();3.4 安全配置// 固定线程数不要让每个请求都无限创建线程ExecutorServiceexecutornewThreadPoolExecutor(8,// corePoolSize8,// maxPoolSize60L,TimeUnit.SECONDS,newLinkedBlockingQueue(100),// 有界队列防止任务堆积newThreadPoolExecutor.CallerRunsPolicy()// 拒绝策略任务回退给主线程);四、为什么多线程可以避免 OOM4.1 关键差异维度单线程多线程分批单次查询数据量百万条5000 条 / 页内存峰值全部数据 Excel 对象单页数据 局部 Excel 对象对象存活周期直到写入完成全程强引用写入一页后方法返回局部变量失效GC 时机无对象全在使用中每写完一页该页对象变为垃圾老年代压力大量长期存活对象晋升老年代短命对象在年轻代即被回收4.2 内存模型对比【单线程】 ┌────────────────────────────────────────┐ │ JVM Heap │ │ (1.2 GB 打满) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ 全部百万条数据 Excel │ │ │ │ (全部为强引用无法 GC) │ │ │ └──────────────────────────────────┘ │ │ │ └───────────────────────────┬────────────┘ │ OOM ✕ 【多线程分批】 ┌────────────────────────────────────────┐ │ JVM Heap │ │ (稳定在 300MB) │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │第 N 页│ │第N1页│ │第N2页│ │第N3页│ │ │ │5000条 │ │5000条 │ │5000条 │ │5000条 │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ ↑ ↑ ↑ ↑ │ │ 线程1 线程2 线程3 线程4 │ │ │ │ 写入完毕 → 方法返回 → 局部变量失效 │ │ → Minor GC 回收年轻代 → 内存平稳 │ └────────────────────────────────────────┘4.3 核心原理拆解原理一分页查询 — 每次只取少量数据// 单线程一把梭 ListOrder all mapper.selectAll(); // 百万条内存爆炸 // 多线程蚂蚁搬家 ListOrder page1 mapper.selectByPage(0, 5000); // 5000 条~5MB ListOrder page2 mapper.selectByPage(1, 5000); // 5000 条~5MB ...每次查询返回的数据量从百万条降为5000 条内存需求从 GB 级降为 MB 级。原理二方法作用域 — 局部对象自动失效executor.execute(()-{ListOrderpageDatamapper.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 无法回收对象还被引用链持有方法作用域自然失效核心认知转变不是加内存而是减少单次处理的数据量。大数据量处理的铁律 ——化整为零分而治之。