Java POI 导出 Excel 卡顿怎么办?优化技巧

来源:IT编程作者:北京GEO公司头衔:草根站长
导读:本期聚焦于北京GEO公司创作的《Java POI 导出 Excel 卡顿怎么办?优化技巧》,敬请观看详情。同一份10万行订单数据,用XSSFWorkbook导出耗时约30秒且堆内存一度涨到1.2GB,改成SXSSFWorkbook配合窗口行数1000后,耗时降到6秒,内存稳定在200MB以内。这个差距不是来自硬件,而是POI内部对临时文件与内存驻留对象的处理策略不同。本文围绕Java POI导出Excel卡顿问题,从SXSSFWorkbook流式写入、样式复用、避免自动列宽、关闭公式评估、分批查询与临时文件压缩等维度展开,给出一套可落地的优化方案。文章会先说明卡顿和内存占用高的根本原因,再给出改造代码示例,最后补充导出流程中容易被忽略的配置项。如果你正在维护涉及大数据量报表导出的Java服务,按这些点逐项检查,通常可以把导出性能提升数倍,同时避免Full GC和内存溢出。

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

Java POI 导出 Excel 卡顿怎么办?优化技巧

优化方向不能只盯着加内存,而应该从 POI 的写入模型、样式复用、列宽计算、公式评估和数据查询方式几个层面同时入手。下面按照从核心到外围的顺序逐步拆解。

一、先定位卡顿:XSSFWorkbook 的内存模型为什么不适配大数据量导出

使用 XSSFWorkbook 创建 .xlsx 文件时,POI 需要维护与 Office Open XML 结构对应的对象树。写入单元格不仅会创建 RowCell 对象,还会在共享字符串表、样式表、工作表 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

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。