
真的我在 Java 项目里跟 Excel 打了这么多年交道提到“用 Java 操作 Excel”绝大多数人第一反应就是 Apache POI。而 POI 家族里出场率最高、坑也最多的就是这个 XSSFWorkbook。今天我不打算照着官方文档念一遍 API而是想从原理到实际踩坑把 XSSFWorkbook 这东西掰开揉碎聊清楚包括它为什么能吃内存、为什么处理大文件容易 OOM、日常读写怎么设计才稳以及面试里被问烂的那几个点到底该怎么答。这篇文章适合刚接触 POI 的新手也适合已经用 POI 写过几个工具类、但遇到内存溢出或者文件损坏问题还没真正搞懂原因的同学。我会结合真实项目场景来讲尽量让每个人读完都能直接照着落地。1. XSSFWorkbook 背后到底是个什么东西1.1 先把 POI 的“三兄弟”分清楚Apache POI 是一个纯 Java 的开源库用来读写 Microsoft Office 格式的文件。早期 POI 主要做的是 .xls也就是 Excel 97-2003 的二进制格式对应的是 HSSF 这套 API。后来 Office 2007 开始默认用 .xlsx也就是基于 OOXMLOffice Open XML标准的格式POI 就推出了 XSSF 这套 API。所以你现在看到的类名其实已经暗示了它的技术路线HSSFWorkbook操作 .xls二进制格式Horrible Spreadsheet FormatXSSFWorkbook操作 .xlsxXML 文本格式XML Spreadsheet FormatSXSSFWorkbookXSSF 的流式版本专门解决大数据量写入时的内存问题你打开任何一个 .xlsx 文件其实它就是一个 zip 压缩包。不信的话你可以把后缀改成 .zip 然后解压看看里面会有一堆 XML 文件。XSSFWorkbook 做的事情本质上就是把这一堆 XML 按照 OOXML 规范解析成 Java 对象操作完再重新打包成 zip。1.2 XSSFWorkbook 怎么跟 OOXML 对应起来我们拿一个最简单的 .xlsx 解压开来看它的核心结构大概是这样的[Content_Types].xml -- 声明各个 content type _rels/.rels -- 根级关系文件 docProps/app.xml -- 应用属性如标题、作者 docProps/core.xml -- 核心属性如创建时间 xl/workbook.xml -- 工作簿定义 sheet 列表 xl/_rels/workbook.xml.rels -- 工作簿和各 sheet 的映射关系 xl/styles.xml -- 样式表单元格样式都在这里 xl/worksheets/sheet1.xml -- 第一个工作表真正的单元格数据 xl/sharedStrings.xml -- 共享字符串表XSSFWorkbook 加载的时候就是由OPCPackage先把这个 zip 包打开然后按照 OPCOpen Packaging Conventions规范把各个 part 读取出来。XSSFWorkbook自己对应xl/workbook.xml你调用getSheetAt(0)时它会通过关系映射找到xl/worksheets/sheet1.xml再由XSSFSheet解析里面的row和c节点。这种设计的好处是解耦每个 part 各管各的关系清晰。坏处就是——整个文件被完整解析进内存每个单元格、每个样式都是一个个 Java 对象这就是后面内存爆掉的根源。1.3 单元格对象的层级关系我见过不少人写代码直接new XSSFWorkbook()然后在里面塞数据但问他Row和Cell是怎么从Workbook里拿到的会答得含糊。其实这套层级非常简单直观跟 Excel 的物理结构完全一致Workbook workbook new XSSFWorkbook(); Sheet sheet workbook.createSheet(示例); Row row sheet.createRow(0); Cell cell row.createCell(0); cell.setCellValue(Hello POI);Workbook - Sheet - Row - Cell一层套一层。在实际业务里我们基本不会直接操作底层 XML都是通过这套对象模型。但你要心里有数你每一次createCell都是在内存里 new 了一个XSSFCell对象而这个对象内部还有CellType、样式索引、可能还有注释、超链接等附属信息。数据量一上来内存就是这么被吃掉的。2. 核心原理为什么 XSSFWorkbook 容易内存溢出2.1 从 DOM 和 SAX 的对比说起如果你写过 Java 解析 XML应该对 DOM 和 SAX 这两个模式有印象。DOM 是先把整个 XML 读进内存构建树结构然后你再随便操作SAX 是边读边触发事件不保留整棵树因此内存占用极低。XSSFWorkbook 的加载逻辑本质上就是 DOM 模式。它会把整个 workbook 相关的内容全部加载成对象包括所有 sheet、行、单元格、样式、共享字符串。这种模式的好处非常明显你可以在任意位置随机读写改一个单元格不影响其他部分API 用起来极度舒适。但代价就是内存。如果你的 Excel 有 10 万行、每行 20 列那就是 200 万个单元格。每个XSSFCell对象再带上它的样式引用、类型信息、字符串值等等随便算算都是几百 MB 起步。再加上共享字符串表sharedStrings如果很大内存还会再飙一截。我在一个实际项目里遇到过从数据库导出 30 万行数据到 Excel用原生XSSFWorkbook直接写堆内存给了 2G 还是 OOM。后来换成SXSSFWorkbook才压下来这个下面细说。2.2 到底多少数据量会触发 OOM这个问题没有标准答案因为跟你的列数、单元格内容长度、是否带样式、JVM 堆大小都有关系。但我可以给一个大家实测下来比较有共识的参考范围1 万行、10 列以内XSSFWorkbook随便用毫无压力。5 万行、20 列左右开始有内存压力但给个 512M 到 1G 一般还能扛。10 万行以上强烈建议别用XSSFWorkbook硬写了考虑SXSSFWorkbook或者 EasyExcel 这类方案。当然了读取也是一样。如果你只是要读一个 10 万行的 xlsx用XSSFWorkbook全量加载内存占用也很可观。更合理的方式是用 POI 的 eventmodel也就是 SAX 方式配合XSSFReader去流式读取后面的实操章节会有示例。2.3 SXSSFWorkbook 的滑动窗口机制SXSSFWorkbook是 XSSFWorkbook 的流式版本它解决写入大文件问题的思路非常有意思窗口滑动。你可以把它理解成一台只看得见当前一段数据的机器只有窗口内的 Row 对象存在于内存里窗口外的行会被刷到磁盘上的临时文件里然后从内存中移除。默认窗口大小是 100 行意思是内存里最多只保留 100 个 Row 对象其余的都写到临时文件了。窗口大小可以通过构造函数调SXSSFWorkbook workbook new SXSSFWorkbook(200);SXSSF 写出来的文件格式仍然是标准的 .xlsx所以用户拿到的文件没有任何区别。但它的限制也很明显只能用写入场景不能随机读取已有的行。你想打开一个已有文件然后往中间某一行插入数据抱歉做不到。这也是很多人踩坑的地方拿SXSSFWorkbook去读模板文件结果发现根本读不到原来的内容。注意SXSSFWorkbook写完后要调用dispose()释放临时文件否则磁盘上会残留临时文件。我见过有项目上线后服务器 /tmp 目录被撑爆的就是这个原因。2.4 读大文件的正确姿势XSSFReader 与事件模式刚才说了XSSFWorkbook读取是 DOM 模式那 POI 其实也提供了 SAX 模式就是XSSFReader配合SheetContentsHandler。这个方案专门用来流式读取 sheet 里的单元格数据不构建完整对象模型内存占用大幅下降。核心流程是这样的你先用OPCPackage.open(文件流)打开压缩包然后通过XSSFReader拿到 SharedStringsTable 和每个 sheet 的输入流再用XMLReader去解析sheet1.xml在解析过程中通过回调把单元格数据吐出来。这一段代码比XSSFWorkbook直接遍历要繁琐得多而且读出来的单元格没有类型信息、没有样式、没有公式完全要靠你自己根据t属性判断是字符串还是数字。但它能解决真实场景里“必须读一个 100MB 的 Excel”这种硬需求。OPCPackage pkg OPCPackage.open(inputStream); XSSFReader reader new XSSFReader(pkg); SharedStringsTable sst reader.getSharedStringsTable(); XMLReader parser XMLHelper.newXMLReader(); parser.setContentHandler(new SimpleSheetHandler(sst)); parser.parse(new InputSource(reader.getSheetsData().next()));这段代码里SimpleSheetHandler需要你继承DefaultHandler自己去解析c节点网上有很多现成的例子但核心要点就一个别用 XSSFWorkbook 去开大文件用事件模式。3. 实操用 XSSFWorkbook 写出一个能生产级用的 Excel3.1 引入依赖的正确姿势Maven 项目里直接加dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency注意版本POI 4.x 和 5.x 在部分 API 上有差异5.x 之后HSSFColor这类类名迁移到了org.apache.poi.hssf.usermodel包下用法略有不同。建议新项目直接用 5.x老项目升级时重点关注CellType和DateUtil相关改动。如果你还需要操作 Excel 的图表、VBA 之类的高级功能可能还要额外引入poi-ooxml-full这个扩展包。大部分场景下poi-ooxml就够了。3.2 创建带样式的工作簿实际业务里导出的 Excel 很少是纯数据通常需要表头加粗、背景色、边框、列宽调整、冻结窗格等等。我用一段实际项目中比较典型的写法来演示try (Workbook workbook new XSSFWorkbook()) { Sheet sheet workbook.createSheet(订单数据); // 设置列宽单位是 1/256 字符宽度 sheet.setColumnWidth(0, 10 * 256); sheet.setColumnWidth(1, 30 * 256); // 创建表头样式 CellStyle headerStyle workbook.createCellStyle(); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); headerStyle.setAlignment(HorizontalAlignment.CENTER); Font headerFont workbook.createFont(); headerFont.setBold(true); headerStyle.setFont(headerFont); // 创建表头行 Row headerRow sheet.createRow(0); String[] headers {订单号, 客户名称, 金额}; for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); } // 冻结首行 sheet.createFreezePane(0, 1); }这里面有个小细节setColumnWidth的单位是 1/256 个字符宽度所以10 * 256表示这一列大约能显示 10 个字符。如果你直接写10列宽会窄到几乎看不见这是很多人刚开始用 POI 时容易困惑的点。另外样式对象CellStyle是在 Workbook 级别创建的不是 Sheet 级别。同一个工作簿里相同样式的单元格应该复用同一个 CellStyle 对象不要每行都createCellStyle()一次否则文件体积会迅速膨胀内存也受影响。3.3 单元格写入的坑字符串、数字、日期、公式单元格写入看起来简单但里面的坑比想象中多。最典型的是日期。如果你直接cell.setCellValue(new Date())默认格式会是m/d/yy这种样式很不符合国内习惯。正确的做法是设置单元格格式CellStyle dateStyle workbook.createCellStyle(); dateStyle.setDataFormat(workbook.getCreationHelper().createDataFormat().getFormat(yyyy-mm-dd hh:mm:ss)); Cell cell row.createCell(3); cell.setCellValue(new Date()); cell.setCellStyle(dateStyle);再说公式。POI 写入公式用的是setCellFormula但如果你只是写入公式而不调用求值器这个单元格在 Excel 里打开时会重新计算所以显示值是对的。但如果你用 Java 读取这个单元格的值拿到的是公式字符串而不是计算结果。这就需要FormulaEvaluator出场FormulaEvaluator evaluator workbook.getCreationHelper().createFormulaEvaluator(); CellValue value evaluator.evaluate(cell); double numericValue value.getNumberValue();FormulaEvaluator在读取别人生成的 Excel 时也很有用。很多系统导出的 Excel 里某些列是公式你直接用getNumericCellValue()会报错得先判断单元格类型再决定怎么取值。3.4 合并单元格与多级表头合并单元格是报表需求里的常客。比如一个季度统计表顶部一行要跨 3 列显示“第一季度”。实现方式sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 2));这段代码的含义是从第 0 行到第 0 行、第 0 列到第 2 列合并成一个单元格。要注意的是合并之后你只需要给左上角的单元格赋值其余单元格在读取时会是空值。还有一个常见的坑合并单元格之后如果你遍历行读取数据只有合并区域左上角的 cell 有值其他 cell 是 null。所以业务上解析这种报表时要做好空值处理。多级表头说白了就是“先合并行再合并列”。比如第一行是“订单信息”横跨订单号、客户名称两列第二行是具体的字段名。这种结构我一般建议先用二维数组把表格结构画出来再写代码填充别边写边调整很容易乱。3.5 大数据量写入的三种方案怎么选如果数据量到了 5 万行以上就不能无脑用XSSFWorkbook了。我按实际项目经验把方案分成三档第一档数据量 1 万行以下直接用XSSFWorkbook代码简单样式随便玩内存没压力。第二档5 万到 20 万行用SXSSFWorkbook。窗口大小按你的实际内存情况调默认 100 就行。注意 SXSSF 对样式有限制同一窗口内最多只能有有限数量的样式实际上是因为样式表styles.xml是常驻内存的样式数量过多同样会撑爆内存。所以大数据量导出时我只用有限的几种样式绝不对每个单元格单独设置样式。第三档50 万行以上说实话不太建议用 POI 直接导 Excel 了。可以考虑先导 CSV或者用 EasyExcel 这类基于 SAX 的框架。CSV 体积小、写入快就是没有格式、没有多 sheet但作为数据导出完全够用。提示还有一个很容易被忽略的性能杀手——频繁刷盘。SXSSFWorkbook的行数据会写入临时文件如果你打开了setCompressTempFiles(true)CPU 消耗会增加但磁盘占用减少。要不要开取决于你的服务器磁盘 IO 和 CPU 负载。4. 高频问题排查与实战经验4.1 内存溢出的排查清单如果你已经被OutOfMemoryError搞到头大按下面这个顺序排查确认是否用XSSFWorkbook加载了超大文件。如果是换成SXSSFWorkbook写入或者事件模式读取。看是不是样式太多导致styles.xml巨大。统计一下你createCellStyle调了多少次把重复样式合并。看是不是共享字符串表巨大。xlsx 文件里所有字符串都会进 sharedStrings大量重复文本会非常占内存。如果你发现字符串重复度高可以考虑用setCellValue时直接传枚举值或者事先去重。检查是否把InputStream包在了Workbook外面没关。POI 在读取时如果用的是WorkbookFactory.create(InputStream)文档流关闭与否会影响内存回收建议用完后workbook.close()。另外我很推荐大家用jvisualvm或者arthas去看一下堆内存里到底是什么对象占了大头。很多时候猜测半天一看快照就明白了。4.2 为什么读出来的数字变成了科学计数法这个几乎是 100% 会碰到的问题。Excel 单元格里的长数字比如订单号、身份证号在 POI 读取时默认按数字处理如果单元格格式是“常规”POI 读出来可能是个 double打印出来就成了科学计数法。解决方案不复杂关键是要先判断单元格格式if (cell.getCellType() CellType.NUMERIC) { double value cell.getNumericCellValue(); // 如果这个单元格本来存的是文本型数字cell.getCellType() 其实是 STRING // 所以读出来是科学计数法大概率是源文件里存的就是数字 }如果你想彻底避免最稳妥的做法是在判断时用DataFormatterDataFormatter formatter new DataFormatter(); String value formatter.formatCellValue(cell);DataFormatter会按照 Excel 的显示格式来格式化单元格数字就不会显示成科学计数法了。强烈建议在解析 Excel 时统一用这个工具类。4.3 模板填充后文件损坏这是一个高频翻车现场你准备了一个带格式的 xlsx 模板用XSSFWorkbook打开填充数据再写出去结果用户打开文件提示“文件已损坏是否尝试恢复”。这类问题大多有几个原因。一个是模板里带有图片、图表、VBA 等复杂对象POI 对这些对象的支持不够完整重新写出时可能丢失或写入错误数据。解决办法是模板尽量保持简单尤其是图表这种东西POI 支持有限尽量避免。另一个原因是模板里的某个 sheet 或者样式在写入时被 POI 重新计算结果后产生了不兼容。我遇到过一次是因为模板里有一个单元格的值是公式且引用了外部数据源POI 在求值后写出去Excel 打开就报错。最实用的排查技巧用 POI 打开重写后的文件如果 POI 自己能正常读一般说明文件结构没问题如果打不开多半是模板里带了不兼容的元素。那就把模板里的高级功能拆掉改用代码创建样式。4.4 并发导出线程安全问题我之前在给一个系统做报表模块时直接用static的Workbook对象给多个用户并发导出结果出现了一部分用户下载的文件数据串了。原因很简单XSSFWorkbook不是线程安全的多个线程同时操作同一个 workbook 实例轻则数据错乱重则直接抛异常。解决方案两种一是每个请求都新建Workbook实例推荐反正创建成本可接受二是用 ThreadLocal 给每个线程一个独立的 workbook 实例。简单粗暴的结论就是别共享 Workbook 实例。4.5 面试高频题XSSFWorkbook 和 SXSSFWorkbook 的区别这个几乎是 Java 面试里 POI 方向的必问题。标准答法是XSSFWorkbook 是 DOM 模式一次性加载整个工作簿到内存支持随机读写但大数据量下容易 OOM。SXSSFWorkbook 是流式写入维护一个滑动窗口内存中只保留固定行数超过的写到磁盘临时文件适合大数据量写入但不支持读取已有行也不能随机修改。有的面试官还会追加问一句SXSSFWorkbook 的临时文件什么时候删除答案是调用dispose()或者workbook.close()的时候。这两个方法底层都会清理临时文件但如果你持有SXSSFWorkbook对象却没调用临时文件会留在系统临时目录。4.6 常见问题速查表问题原因解决写入 10 万行 OOMXSSFWorkbook 全量加载换 SXSSFWorkbook日期显示为数字未设置日期格式createDataFormat 并 setCellStyle长数字变科学计数法单元格类型是数字用 DataFormatter 格式化模板填充后文件损坏模板含图表/VBA/外部引用简化模板公式求值后写死并发导出数据串共享 Workbook 实例每个请求 new 一个 Workbook读取公式单元格报错单元格类型是 FORMULA用 FormulaEvaluator 求值5. 横向对比与选型建议5.1 POI 与 EasyExcel、FastExcel 的取舍如果你经常处理 Excel应该知道阿里开源的 EasyExcel 这两年很火。EasyExcel 底层也是基于 POI 的但它默认使用 SAX 模式读写所以内存占用远低于原生XSSFWorkbookAPI 也更简洁。我在实际项目中对比过同样导出 30 万行数据POI 的SXSSFWorkbook内存占用大约 200M 左右EasyExcel 能做到 100M 以内而且代码量少不少。但 EasyExcel 也有它的局限性。它对复杂模板的支持不如原生 POI 灵活尤其是当你需要在固定位置插入大量合并单元格、复杂样式、多级表头时EasyExcel 反而会让你绕圈子。而原生 POI 的好处恰恰在于完整性和可控性样式、合并、公式、批注、数据验证、图片几乎所有 Excel 能力都能操作。FastExcel 是 poi 的一个包装器这个不太建议选了维护活跃度不如 EasyExcel而且并没有本质上的性能优势。5.2 到底该选哪个我的建议我根据实际项目经验给一个比较务实的推荐如果你只是做简单的数据导出导入数据量又大优先考虑 EasyExcel省事省内存。如果你的需求涉及复杂报表、模板填充、样式精细控制尤其是要做多 sheet 的复杂报表直接原生 POI 更稳。如果两者都有可以考虑两种混用复杂模板用 POI 处理大数据量平面表用 EasyExcel 导出。这里多说一句EasyExcel 的读性能虽然好但它的 API 抽象程度比较高出了问题排查起来不太直观。而 POI 的报错信息往往更明确定位问题更快。线上运维角度讲poi 的“丑但可靠”反而是优势。5.3 从 Python 生态反观 Java 的 Excel 处理热搜词里也有“python写入excel”和“pymupdf to excel”说明很多人也在用 Python 处理 Excel。Python 生态里 openpyxl 和 pandas 处理 Excel 确实非常爽pandas 一行df.to_excel()就完成导出。但如果你在 Java 项目里真的不建议为了导 Excel 去套一个 Python 服务运维成本太高。看清楚 POI 和其他方案的关系本质上是精度和性能的权衡。Python 处理 Excel 走的是“快速分析”路线Java 走的是“工程化集成”路线。POI 在 Java 里能跟 Spring、MyBatis、各种定时任务框架无缝整合单这一个优势就足够让它成为 Java 后端的事实标准。6. 实操中的一些心得体会到最后我想分享几个这些年积累的小经验不一定写进文档里但很管用。第一做 Excel 导入功能时一定要对空行和多余空白字符做处理。很多用户给的 Excel 看起来只有几行数据实际上格式刷把几千行的样式都带上了POI 遍历时会发现row null的情况特别多造成解析结果里出现大量空记录。判断条件统一用row ! null并且row.getPhysicalNumberOfCells() 0。第二如果业务允许导出的 Excel 尽量不用公式直接把计算好的值写进去。这样既减少了FormulaEvaluator的使用场景也避免不同 Excel 版本打开时公式重算导致的显示差异。第三处理大文件时一定要做好分页或者分批读取不要同时在数据库查全量数据再往 Excel 里塞。我以前做过一个导出功能数据量一大数据库连接先被拖垮然后才是内存问题。后来改成流式查询 分批写入两个问题都解决了。第四不要忘了workbook.close()。这是个非常基础但容易被忽略的操作尤其是在用连接池或者长生命周期对象时。XSSFWorkbook持有 zip 包的输入流不关闭会占用文件句柄在 Linux 服务器上文件句柄用光是很麻烦的。说白了XSSFWorkbook 本身并不神秘就是一个把 OOXML 的 XML 文件映射成 Java 对象模型的解析器。它的上限和下限都由这个设计决定——易用而吃内存。理解这一点你在项目里用它的思路就会清晰很多小文件直接用大文件换流式方案复杂模板用完整 API把工具用在它最合适的场景里。上面提到的所有坑都是我在真实项目里踩过或者帮别人排查过的。如果你也是做 Java 后端、经常跟 Excel 打交道建议先把自己项目里导出的逻辑理一理看看有没有还在用XSSFWorkbook硬扛大数据量的地方趁早优化掉省得线上爆内存的时候半夜爬起来看日志。