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

为什么必须从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接口,具体转换逻辑放在实现类中,可以灵活处理日期、数字、枚举和自定义格式。实际使用时,还可以加入表头冻结、自动筛选和单元格下拉校验等增强功能,但核心原则仍然是流式解析、顺序写入、样式复用和及时刷盘。

