杰瑞科技汇

Java导出Excel大数据,如何避免内存溢出?

避免一次性将所有数据加载到内存中,我们需要采用流式处理或分批写入的方式,将数据“流”向 Excel 文件,而不是在内存中构建一个巨大的数据集。

Java导出Excel大数据,如何避免内存溢出?-图1
(图片来源网络,侵删)

下面我将从问题根源、主流解决方案、代码示例和最佳实践四个方面,详细讲解如何处理大数据导出。


问题根源:为什么大数据导出会 OOM?

当我们使用像 Apache POI 这样的库时,如果不加选择,很容易踩坑。

  1. HSSFWorkbook (.xls 格式):

    • 工作原理:所有数据都存储在内存中,当数据量很大时,会瞬间消耗大量内存,极易导致 OOM。
    • 限制.xls 格式本身最多支持 65536 行、256 列,对于“大数据”场景根本不适用。
  2. XSSFWorkbook (.xlsx 格式):

    Java导出Excel大数据,如何避免内存溢出?-图2
    (图片来源网络,侵删)
    • 工作原理:早期版本(如 3.x)与 HSSFWorkbook 类似,也是全内存操作,虽然 .xlsx 格式本身支持百万行数据,但 XSSFWorkbook 在写入时,会先将整个文档结构(包括样式、公式等)都加载到内存中,再填充数据,数据量巨大时依然会 OOM。
    • 改进:新版本的 POI(如 4.x+)对 XSSFWorkbook 做了优化,引入了 SXSSFWorkbook,这是解决大数据问题的关键。

直接使用 HSSFWorkbook 或旧版 XSSFWorkbook 来处理大数据是导致 OOM 的主要原因。


主流解决方案

Apache POI 的 SXSSFWorkbook (推荐)

这是目前处理大数据导出最主流、最高效的方案。

  • 核心原理SXSSFWorkbookXSSFWorkbook 的一个流式实现,它结合了 XSSFSXSSF 的优点。

    • 它使用 临时文件 来存储不常访问的数据(已经写入到 Excel 但屏幕滚动后就看不见的数据行)。
    • 它只保留一个固定大小的 “行访问窗口” 在内存中(只保留最近写入的 100 行)。
    • 当新数据写入导致窗口溢出时,最旧的数据就会被“刷”(flush)到硬盘中,从而释放内存。
    • 注意SXSSFWorkbook 生成的 .xlsx 文件是 只读的,因为它依赖临时文件,你无法在生成后立即修改它(比如添加公式、图表等复杂操作),但作为数据导出功能,这完全足够。
  • 优点

    • 内存占用极低:与数据量大小基本无关,只取决于窗口大小。
    • 性能高:写入速度快,适合百万甚至千万级数据导出。
    • API 兼容:使用方式与 XSSFWorkbook 非常相似,学习成本低。
  • 缺点

    生成的文件是只读的。


代码示例 (使用 SXSSFWorkbook)

下面是一个完整的、可运行的示例,演示如何使用 SXSSFWorkbook 导出百万条数据。

添加 Maven 依赖

确保你的 pom.xml 中有 POI 依赖,推荐使用较新版本。

<dependencies>
    <!-- POI Core -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi</artifactId>
        <version>5.2.3</version> <!-- 使用较新版本 -->
    </dependency>
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
        <version>5.2.3</version>
    </dependency>
    <!-- 为了方便生成测试数据,使用 Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.24</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

Java 代码实现

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import java.io.FileOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class BigDataExportDemo {
    public static void main(String[] args) {
        // 1. 模拟从数据库或其他数据源获取数据
        // 在实际项目中,这里应该是一个分页查询的循环
        List<User> allUsers = generateMockUsers(1_000_000); // 生成100万条模拟数据
        // 2. 定义文件输出路径
        String filePath = "D:/temp/big_data_export.xlsx";
        // 3. 开始导出
        long startTime = System.currentTimeMillis();
        System.out.println("开始导出 Excel...");
        try {
            exportWithSXSSFWorkbook(allUsers, filePath);
        } catch (IOException e) {
            e.printStackTrace();
            System.err.println("导出失败!");
            return;
        }
        long endTime = System.currentTimeMillis();
        System.out.println("导出成功!文件路径: " + filePath);
        System.out.println("总耗时: " + (endTime - startTime) / 1000.0 + " 秒");
    }
    /**
     * 使用 SXSSFWorkbook 导出大数据
     * @param dataList 数据列表
     * @param filePath 文件路径
     */
    public static void exportWithSXSSFWorkbook(List<User> dataList, String filePath) throws IOException {
        // 1. 创建 SXSSFWorkbook 对象
        // 参数 100 是指在内存中保留的行数,超过100行的数据会被写入临时文件
        // 这个值可以根据你的内存大小和实际情况调整
        try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
            // 2. 创建工作表
            Sheet sheet = workbook.createSheet("用户数据");
            // 3. 创建样式(可选,但推荐)
            CellStyle headerStyle = createHeaderStyle(workbook);
            CellStyle dateStyle = createDateStyle(workbook);
            // 4. 创建表头
            Row headerRow = sheet.createRow(0);
            headerRow.createCell(0).setCellValue("ID");
            headerRow.createCell(1).setCellValue("姓名");
            headerRow.createCell(2).setCellValue("年龄");
            headerRow.createCell(3).setCellValue("创建时间");
            headerRow.createCell(4).setCellValue("账户余额");
            // 应用表头样式
            for (int i = 0; i < 5; i++) {
                headerRow.getCell(i).setCellStyle(headerStyle);
            }
            // 5. 写入数据
            AtomicInteger rowNum = new AtomicInteger(1); // 从第2行开始写数据
            dataList.forEach(user -> {
                Row row = sheet.createRow(rowNum.getAndIncrement());
                row.createCell(0).setCellValue(user.getId());
                row.createCell(1).setCellValue(user.getName());
                row.createCell(2).setCellValue(user.getAge());
                Cell dateCell = row.createCell(3);
                dateCell.setCellValue(user.getCreateTime());
                dateCell.setCellStyle(dateStyle);
                row.createCell(4).setCellValue(user.getAccountBalance().doubleValue());
                // 每写入1000行,可以手动调用一下 flush(),让数据更早地写入磁盘,减少内存压力
                // 但通常在 finally 或 try-with-resources 中自动 flush 就足够了
                if (rowNum.get() % 1000 == 0) {
                    System.out.println("已处理 " + rowNum.get() + " 行数据...");
                }
            });
            // 6. 自动调整列宽(可选)
            // 注意:自动调整列宽在数据量巨大时可能比较耗时
            for (int i = 0; i < 5; i++) {
                sheet.autoSizeColumn(i);
            }
            // 7. 写入到文件
            // 使用 try-with-resources 确保 FileOutputStream 被关闭
            try (FileOutputStream out = new FileOutputStream(filePath)) {
                workbook.write(out);
            }
            // 8. 清理临时文件
            // 当 workbook 被关闭时(try-with-resources 会自动调用),临时文件会被自动删除
            // 如果没有使用 try-with-resources,则需要手动调用 workbook.dispose()
            // workbook.dispose();
        }
    }
    // 创建表头样式
    private static CellStyle createHeaderStyle(SXSSFWorkbook workbook) {
        CellStyle style = workbook.createCellStyle();
        Font font = workbook.createFont();
        font.setBold(true);
        style.setFont(font);
        style.setAlignment(HorizontalAlignment.CENTER);
        style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
        style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        return style;
    }
    // 创建日期样式
    private static CellStyle createDateStyle(SXSSFWorkbook workbook) {
        CellStyle style = workbook.createCellStyle();
        style.setDataFormat(workbook.createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss"));
        return style;
    }
    // 模拟生成大量数据
    private static List<User> generateMockUsers(int count) {
        List<User> users = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            users.add(new User((long) i, "用户_" + i, 18 + (i % 50), new Date(), new BigDecimal("1000." + (i % 100))));
        }
        return users;
    }
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    static class User {
        private Long id;
        private String name;
        private Integer age;
        private Date createTime;
        private BigDecimal accountBalance;
    }
}

其他解决方案及最佳实践

EasyExcel (阿里巴巴开源)

如果你不想用 POI,或者觉得 POI 的 API 有些繁琐,阿里巴巴的 EasyExcel 是一个绝佳的选择。

  • 核心原理:同样是基于 SXSSFWorkbook,但做了更进一步的封装和优化,它通过 AnalysisEventListener 监听器模式,将读取和写入都变成了事件驱动,内存控制得非常好。
  • 优点
    • API 极简:代码量比 POI 少很多,可读性高。
    • 内存占用同样极低
    • 功能强大:支持复杂的表头、动态表头、数据转换等。
    • 社区活跃:国内使用广泛,文档和案例丰富。

EasyExcel 写入示例(简化版):

// 1. 准备数据(可以是任何集合,甚至直接从数据库分页查询)
List<User> data = ...; 
// 2. 写入文件
String fileName = "D:/temp/easyexcel_export.xlsx";
// 通过 write() 和 sheet() 指定文件和工作表
// 通过 doWrite() 写入数据
EasyExcel.write(fileName, User.class).sheet("用户数据").doWrite(data);

可以看到,EasyExcel 的代码量大大减少,非常适合快速开发。

CSV 文件

如果你的需求只是将数据导出为表格形式,不关心 Excel 的复杂格式(如合并单元格、公式、图表等),CSV 是最轻量、最高效的选择。

  • 核心原理:CSV 是纯文本格式,每一行就是一条记录,用逗号分隔字段,它不涉及任何复杂的文件结构,写入过程就是简单的字符串拼接和文件写入。
  • 优点
    • 性能最高,内存占用最小:几乎可以忽略不计。
    • 兼容性好:可以被 Excel、WPS、文本编辑器等几乎所有软件打开。
    • 实现简单:几行代码就能搞定。
  • 缺点
    • 无格式:无法设置字体、颜色、边框等。
    • 数据类型问题:所有数据都是字符串,需要自行处理。

Java 写入 CSV 示例:

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class CsvExportDemo {
    public static void main(String[] args) throws IOException {
        List<User> users = generateMockUsers(1_000_000);
        String filePath = "D:/temp/big_data_export.csv";
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(filePath, StandardCharsets.UTF_8))) {
            // 写入表头
            writer.write("ID,姓名,年龄,创建时间,账户余额");
            writer.newLine();
            // 写入数据
            for (User user : users) {
                String line = String.format("%d,%s,%d,%s,%.2f",
                        user.getId(),
                        user.getName(),
                        user.getAge(),
                        user.getCreateTime(), // 注意:日期需要格式化
                        user.getAccountBalance());
                writer.write(line);
                writer.newLine();
            }
        }
        System.out.println("CSV 导出完成!");
    }
    // ... generateMockUsers 和 User 类同上 ...
}

最佳实践总结

  1. 首选方案

    • 需要 .xlsx 格式和基本样式:使用 SXSSFWorkbook (POI) 或 EasyExcel,EasyExcel 更推荐,因为它更简单。
    • 只需要纯数据表格,对格式无要求:使用 CSV,性能和效率最优。
  2. 数据源处理

    • 不要一次性查询所有数据! 这是最重要的原则,如果你的数据来自数据库,一定要使用 分页查询,在循环中,每次只查询一页数据(1000 或 5000 条),然后写入 Excel,再查询下一页。
    • 伪代码示例
      int pageSize = 5000;
      int pageNum = 1;
      List<User> pageData;
      do {
          pageData = userMapper.selectPage(pageNum, pageSize); // 分页查询
          writeToExcel(pageData); // 将当前页数据写入 Excel
          pageNum++;
      } while (!pageData.isEmpty());
  3. 性能优化

    • 减少样式操作:频繁地创建和应用样式会消耗性能,尽量复用 CellStyle 对象。
    • 禁用自动列宽sheet.autoSizeColumn() 在数据量大时非常耗时,如果可以,手动设置一个固定的列宽。
    • 使用 try-with-resources:确保 WorkbookFileOutputStream 等资源被正确关闭,避免文件句柄泄漏。
    • 考虑多线程:如果数据预处理耗时较长,可以将数据预处理和 Excel 写入放到两个线程中,使用队列进行数据传递,实现流水线作业。
  4. 用户体验

    对于超大数据导出,可以考虑提供一个异步任务接口,用户点击导出后,立即返回一个任务 ID,让用户可以继续其他操作,后台通过一个定时任务或消息队列来执行导出,完成后通过邮件或 WebSocket 通知用户下载,这能避免因导出时间过长导致 HTTP 请求超时。

分享:
扫描分享到社交APP
上一篇
下一篇