广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何用Apache POI高效写入JSON数组数据到Excel?

把JSON数组写入Excel是数据导出中的常见需求,但数据量一旦达到数万行,内存占用和写入速度就会成为瓶颈。Apache POI的XSSFWorkbook会把整张工作簿所有单元格对象都保留在堆内存中,而SXSSFWorkbook只保留一个可配置的窗口行数,超出部分会写入临时磁盘文件,从根本上降低内存峰值。本文围绕Apache POI 4.x或5.x版本,结合Jackson解析器,整理一套可以直接用于生产的写入方案。

如何用Apache POI高效写入JSON数组数据到Excel?

为什么必须从XSSFWorkbook切换到SXSSFWorkbook

XSSFWorkbook基于OOXML结构,每个Row和Cell都是独立对象,包含大量字符串、样式和关系引用。当写入十万行、二十列的数据时,内存中可能同时存在数百万个对象,很容易导致堆空间耗尽。SXSSFWorkbook通过rowAccessWindowSize参数控制留在内存中的行数,默认值是100,这意味着只有最近100行会保留为可操作对象,更早的行被写入临时文件,内存占用几乎固定。

实测对比显示,在JVM堆设置为512MB的环境下,XSSFWorkbook写入五万行数据时通常会出现频繁Full GC甚至OOM,而SXSSFWorkbook在默认窗口下可以平稳完成三十万行写入。使用SXSSFWorkbook时需要注意,它不支持随机访问已经刷出的行,如果需要修改历史行,必须重新设计数据流。因此最佳实践是采用一次性顺序写入模型:解析一行JSON,立即写入一行Excel,写完就交给POI管理。

窗口大小的选择需要权衡内存和临时磁盘IO。窗口越大,可回退修改的行越多,但内存占用越高;窗口越小,临时文件读写越频繁。对于纯追加式导出,建议设置为200到500之间。如果单行数据列数很多,比如超过100列,可以适当降低到100。代码中通过new SXSSFWorkbook(300)即可指定窗口大小。

JSON数组解析与列映射的两种思路

JSON数组通常来自接口响应或文件读取。如果整个JSON文件被一次性加载为JSONArray,内存中会同时保存原始字符串、解析后的Map和Excel对象,反而没有发挥SXSSFWorkbook的优势。更推荐的方案是使用Jackson的JsonParser流式读取数组元素,做到边解析边写入。Jackson的readValues方法返回一个MappingIterator,可以逐个反序列化为Map<String,Object>,不会把整个数组对象全部保留在内存。

下面这段代码展示如何利用ObjectMapper逐行读取JSON数组并交给POI写入。注意JsonParser需要关闭,否则文件句柄可能泄漏。

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.core.JsonParser;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.ss.usermodel.Row;
import java.io.File;
import java.io.FileOutputStream;
import java.util.Map;
public class JsonToExcel {
    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        try (JsonParser parser = mapper.getFactory().createParser(new File("data.json"));
             MappingIterator<Map<String, Object>> it = mapper.readValues(parser, Map.class);
             SXSSFWorkbook wb = new SXSSFWorkbook(300);
             FileOutputStream out = new FileOutputStream("output.xlsx")) {
            SXSSFSheet sheet = wb.createSheet("Data");
            int rowIndex = 0;
            while (it.hasNext() && rowIndex < 1000000) {
                Map<String, Object> record = it.next();
                Row row = sheet.createRow(rowIndex++);
                writeRecord(row, record);
            }
            wb.write(out);
            wb.dispose();
        }
    }
    private static void writeRecord(Row row, Map<String, Object> record) {
        // 列顺序需要提前约定,避免每行查找列索引
        Object[] values = {record.get("id"), record.get("name"), record.get("amount"), record.get("createTime")};
        for (int i = 0; i < values.length; i++) {
            Object v = values[i];
            if (v == null) {
                row.createCell(i).setBlank();
            } else if (v instanceof Number) {
                row.createCell(i).setCellValue(((Number) v).doubleValue());
            } else {
                row.createCell(i).setCellValue(v.toString());
            }
        }
    }
}

列映射方面,如果JSON键与Excel表头完全一致,可以直接按照JSON对象键的顺序写入;但生产数据往往包含多余字段或需要重命名,因此建议维护一个List<ColumnDef>,每个ColumnDef包含JSON字段名、Excel列标题和类型转换器。这样后续增加列或调整顺序时不需要修改写入循环。

另一种思路是先把JSON数组转成List<Map>再写入,这种方案代码简单,但只适合数据量小于一万行的场景。如果服务端接口已经返回了完整的JSONArray字符串,可以使用JSONArray的迭代器逐项处理,但要注意不要同时保留原始字符串和解析结果。

批量刷盘与样式对象复用

SXSSFWorkbook内部已经通过临时文件处理了行数据,但调用write(OutputStream)之前,最近窗口内的行仍留在内存中。对于超大导出任务,可以定期调用sheet.flushRows(keepRows)手动把指定行数之前的数据刷到临时文件。这个方法接收一个int参数,表示保留最近多少行不刷。例如每写入1000行调用sheet.flushRows(100),可以进一步稳定内存曲线。

样式的创建成本很高,每个CellStyle都会写入到工作簿的样式表中,并且存在上限。很多开发者在循环中为每个单元格创建新样式,结果不仅样式数量爆炸,还会拖慢写入速度。正确做法是预先创建好表头样式和数据样式,写入时按需引用。下面的代码给出了样式复用和表头写入的完整示例。

import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.IndexedColors;
public class StyleHelper {
    private final CellStyle headerStyle;
    private final CellStyle dateStyle;
    private final CellStyle numberStyle;
    public StyleHelper(SXSSFWorkbook wb) {
        headerStyle = wb.createCellStyle();
        Font headerFont = wb.createFont();
        headerFont.setBold(true);
        headerStyle.setFont(headerFont);
        headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
        headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        dateStyle = wb.createCellStyle();
        dateStyle.setDataFormat(wb.getCreationHelper().createDataFormat().getFormat("yyyy-mm-dd hh:mm:ss"));
        numberStyle = wb.createCellStyle();
        numberStyle.setDataFormat(wb.getCreationHelper().createDataFormat().getFormat("#,##0.00"));
    }
    public CellStyle getHeaderStyle() { return headerStyle; }
    public CellStyle getDateStyle() { return dateStyle; }
    public CellStyle getNumberStyle() { return numberStyle; }
}

日期和数字格式需要特别注意。JSON中的日期可能是字符串,直接写入会变成文本,Excel无法参与日期计算。建议在列映射中识别日期字符串,并使用SimpleDateFormat解析后写入java.util.Date对象,再应用日期样式。数字同理,如果金额以字符串形式出现,应转换成BigDecimal或double并设置数字格式,避免科学计数法显示。

关闭自动列宽也很重要。POI在自动调整列宽时需要遍历单元格内容,这在流式模式下非常昂贵。对SXSSFSheet,调用sheet.trackAllColumnsForAutoSizing()会加载所有列到内存,破坏流式特性。最佳实践是根据经验预设列宽,或者对前1000行样本计算宽度后一次性设置。例如sheet.setColumnWidth(0, 12 * 256),这里的256是字符宽度单位。

多Sheet拆分与资源清理

单个Sheet的最大行数约为1048576,对于更大量数据需要拆分到多个Sheet。按照固定行数拆分是最简单的策略,比如每二十万行新建一个Sheet。拆分时要注意,每个SXSSFSheet都有自己独立的临时文件,窗口大小按Sheet分别计算,因此总内存占用会随着Sheet数量增加而上升。对于特别大的任务,可以考虑分批导出多个Excel文件,再压缩打包。

资源释放是容易被忽略的环节。SXSSFWorkbook在write之后必须调用dispose()来删除底层临时文件。如果不调用,临时文件会残留在系统的temp目录,长时间运行的服务会耗尽磁盘空间。使用try-with-resources时,SXSSFWorkbook实现了Closeable,可以自动调用dispose,所以上面的代码块有try (... ; SXSSFWorkbook wb = ...)。但需要注意,如果中途抛出异常,try-with-resources会保证清理。

最后给出一个可复用的服务方法骨架,把JSON流解析、列映射、样式和拆分逻辑串联起来,方便在实际项目中替换数据源和列定义。

import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.streaming.SXSSFSheet;
import org.apache.poi.ss.usermodel.Row;
import java.io.OutputStream;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
public class ExcelExportService {
    public void export(Iterator<Map<String, Object>> records,
                       List<ColumnDef> columns,
                       OutputStream outputStream,
                       int rowsPerSheet) throws Exception {
        try (SXSSFWorkbook wb = new SXSSFWorkbook(200)) {
            StyleHelper styles = new StyleHelper(wb);
            int sheetIndex = 0;
            int rowIndex = 0;
            SXSSFSheet sheet = null;
            Row currentRow = null;
            while (records.hasNext()) {
                if (rowIndex % rowsPerSheet == 0) {
                    sheet = wb.createSheet("Sheet" + (++sheetIndex));
                    rowIndex = 0;
                    writeHeader(sheet, columns, styles.getHeaderStyle());
                }
                Map<String, Object> record = records.next();
                currentRow = sheet.createRow(rowIndex + 1);
                writeDataRow(currentRow, record, columns, styles);
                rowIndex++;
                if (rowIndex % 1000 == 0) {
                    sheet.flushRows(100);
                }
            }
            wb.write(outputStream);
        }
    }
    private void writeHeader(SXSSFSheet sheet, List<ColumnDef> columns, CellStyle headerStyle) {
        Row header = sheet.createRow(0);
        for (int i = 0; i < columns.size(); i++) {
            header.createCell(i).setCellValue(columns.get(i).getExcelTitle());
            header.getCell(i).setCellStyle(headerStyle);
        }
    }
    private void writeDataRow(Row row, Map<String, Object> record, List<ColumnDef> columns, StyleHelper styles) {
        for (int c = 0; c < columns.size(); c++) {
            ColumnDef col = columns.get(c);
            Object value = record.get(col.getJsonField());
            // 根据列类型转换并设置样式
            col.writeCell(row.createCell(c), value, styles);
        }
    }
}

这段代码中将列定义抽象为ColumnDef接口,具体转换逻辑放在实现类中,可以灵活处理日期、数字、枚举和自定义格式。实际使用时,还可以加入表头冻结、自动筛选和单元格下拉校验等增强功能,但核心原则仍然是流式解析、顺序写入、样式复用和及时刷盘。

广告位 · 文章底部(后台-模块管理替换此区域)