Java 项目使用 Apache POI 导出 Excel 时,接口卡顿通常不是一开始就出现,而是在数据规模从几千行上升到几万行、十万行以后突然恶化。很多系统在测试环境只有几百条记录时导出很流畅,一旦放到生产环境导出全量报表,响应时间就会从几百毫秒变成几十秒,甚至因为堆内存耗尽触发 OutOfMemoryError。这个现象的根本原因是 XSSFWorkbook 采用全内存写入模型,所有行、单元格、样式和共享字符串都会驻留在 JVM 堆中,直到最后统一写出,数据量越大,对象创建和垃圾回收的压力就越大。

优化方向不能只盯着加内存,而应该从 POI 的写入模型、样式复用、列宽计算、公式评估和数据查询方式几个层面同时入手。下面按照从核心到外围的顺序逐步拆解。
一、先定位卡顿:XSSFWorkbook 的内存模型为什么不适配大数据量导出
使用 XSSFWorkbook 创建 .xlsx 文件时,POI 需要维护与 Office Open XML 结构对应的对象树。写入单元格不仅会创建 Row 和 Cell 对象,还会在共享字符串表、样式表、工作表 XML 等多个结构中记录关系。因为最终输出要求生成完整的压缩包,POI 默认会等内存中的结构全部构建完成后再序列化到磁盘或响应流。
所以当行数增加时,堆中对象数量呈线性甚至超线性增长。一个 100000 行、每行 15 列的表格,仅单元格对象就可能超过 150 万个,再加上字符串缓存和样式引用,堆占用很容易突破 1GB。GC 为了回收这些短生命周期对象会频繁触发,用户感知就是导出过程中 CPU 飙升但响应迟迟不结束。
还有一个容易被忽略的点:如果代码中调用了 autoSizeColumn,POI 会对每一列扫描所有行来计算宽度,每个单元格都需要读取字体信息并估算字符宽度。这个操作的时间复杂度很高,几万行数据下甚至比写入本身还慢。
二、核心优化:改用 SXSSFWorkbook 实现滑动窗口写入
SXSSFWorkbook 是 POI 为大数据量 .xlsx 导出提供的流式实现。它在内存中只保留最近 N 行数据,超过窗口大小的行会被写入临时 XML 文件,最终由 POI 将这些临时文件合并到完整的 Excel 压缩包中。这样 JVM 堆中不再需要同时保存全部工作表的对象。
创建方法如下。窗口大小建议设置在 100 到 1000 之间,窗口越小,内存占用越低,但临时文件写入和最终合并的开销也会相应增加;窗口越大,内存占用接近普通 XSSFWorkbook。通常 500 或 1000 是性能与内存之间的平衡点。
SXSSFWorkbook workbook = new SXSSFWorkbook(500);
workbook.setCompressTempFiles(true);
Sheet sheet = workbook.createSheet("订单数据");
sheet.setRandomAccessWindowSize(100);
注意 SXSSFWorkbook 创建的临时文件默认会进行 gzip 压缩,可以在构造后通过 setCompressTempFiles 控制。压缩能显著减少磁盘占用,但在临时文件读写频繁时也会消耗少量 CPU。对于大多数 Web 导出场景,保持默认开启即可。
导出完成后必须调用 dispose,否则临时文件可能一直残留。如果使用 try-with-resources,资源释放会更加可靠。
三、样式与列宽:把重复创建降到最低
逐单元格创建 CellStyle 是导出卡顿的另一个常见原因。每创建一个样式,POI 都会在样式表中新增一条记录,样式数量过多会让生成的 xlsx 文件膨胀,同时序列化时间明显增加。正确做法是在写数据前先创建有限几个样式对象,例如标题样式、数字样式、日期样式、普通文本样式,然后在写入时按列或按值类型复用。
CellStyle headerStyle = workbook.createCellStyle();
Font headerFont = workbook.createFont();
headerFont.setBold(true);
headerStyle.setFont(headerFont);
CellStyle textStyle = workbook.createCellStyle();
textStyle.setAlignment(HorizontalAlignment.LEFT);
CellStyle numberStyle = workbook.createCellStyle();
DataFormat format = workbook.createDataFormat();
numberStyle.setDataFormat(format.getFormat("#,##0.00"));
列宽优化同样重要。不要直接对所有列调用 sheet.autoSizeColumn。如果列数不多、数据量很小,可以在导出末尾统一调用,但对于大批量导出,建议根据表头长度设置固定列宽,或者只对前若干行采样估算。比如可以使用 sheet.setDefaultColumnWidth(18),再针对个别字段设置更宽的值。
如果表格中包含公式,避免在服务端调用 evaluateAll 去计算所有公式结果。POI 写入公式后,Excel 客户端打开时会自行计算,服务端预计算会大幅增加 CPU 和内存开销。除非需要生成公式缓存值供其他程序读取,否则不要主动评估。
四、数据查询与导出流程的整体优化
很多接口的卡顿并不只在 POI,而是数据库一次性把几十万条记录加载到内存,再交给 POI 写入。这样即使 POI 已经改成流式写入,应用内存仍然会在查询阶段先被打满。应改为分页查询或游标查询,每批读取 1000 到 5000 条,边读边写入 SXSSFWorkbook。
使用 MyBatis 时,可以通过 Cursor 或分页插件进行流式读取;使用 JDBC 时注意设置合适的 fetchSize,避免驱动一次性拉取全部结果。需要提醒的是,游标查询会长时间占用数据库连接,导出完成前不要提前释放连接,同时要控制并发导出数量,防止多个大任务打满连接池。
对于单次超过 50 万行、用户不需要同步等待的场景,更合适的方案是改成异步任务:请求提交后返回任务号,后台线程生成文件并上传到对象存储,完成后通知用户下载。这样接口响应时间不再受导出耗时影响,也能避免 HTTP 请求超时导致导出中断。
五、可落地的 SXSSF 导出模板与注意点
下面给出一个简化但完整的导出模板,覆盖创建、样式复用、分批写入和写出到响应流。实际项目中可以把数据查询部分替换成自己的分页或游标逻辑。
public void exportLargeData(HttpServletResponse response) throws IOException {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=export.xlsx");
try (SXSSFWorkbook workbook = new SXSSFWorkbook(500)) {
workbook.setCompressTempFiles(true);
Sheet sheet = workbook.createSheet("导出数据");
sheet.setDefaultColumnWidth(18);
CellStyle headerStyle = workbook.createCellStyle();
Font headerFont = workbook.createFont();
headerFont.setBold(true);
headerStyle.setFont(headerFont);
String[] headers = {"订单号", "客户名称", "金额", "创建时间"};
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
}
int rowIndex = 1;
int pageSize = 2000;
Long lastId = 0L;
while (true) {
List<Order> orders = orderService.findByPage(lastId, pageSize);
if (orders == null || orders.isEmpty()) {
break;
}
for (Order order : orders) {
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(order.getOrderNo());
row.createCell(1).setCellValue(order.getCustomerName());
row.createCell(2).setCellValue(order.getAmount().doubleValue());
row.createCell(3).setCellValue(order.getCreatedTime().toString());
}
lastId = orders.get(orders.size() - 1).getId();
if (orders.size() < pageSize) {
break;
}
}
workbook.write(response.getOutputStream());
workbook.dispose();
}
}
代码中的 List<Order> 在 Java 中是普通泛型写法。查询逻辑使用基于自增 ID 的分页方式,避免深分页导致数据库扫描过慢。每批读取 2000 条,既能减少内存压力,又不会因为批次太小导致频繁查询。
还需要注意两点:第一,dispose 调用后不能再对工作簿做任何操作;第二,写出到响应流时不要一次性把整个 Excel 读成字节数组,而应直接写 OutputStream,否则又会把全部文件内容拉回堆内存。
Java POIExcel导出SXSSFWorkbook修改时间:2026-08-24 21:36:35