
1. 报错现场还原java heap space 到底长什么样做 Java 后端的人早晚会撞上这么一堵墙。你在服务器上正常跑着一个服务日志突然刷出一行java.lang.OutOfMemoryError: java heap space at java.util.Arrays.copyOf(Arrays.java:3336) at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124) at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:674) at java.lang.StringBuilder.append(StringBuilder.java:224) at java.io.BufferedReader.readLine(BufferedReader.java:382) at java.io.BufferedReader.readLine(BufferedReader.java:389)如果是处理大文件时遇到这行字尤其是线上环境那基本可以断定堆内存被某个贪心操作一次性吃光了。这不是偶发的小毛病而是代码里某个环节把整个文件或者巨大中间结果往内存里塞的必然结果。先说清楚一个容易混淆的点java.lang.OutOfMemoryError: java heap space只是堆内存溢出的其中一种表现。JVM 内存区域分好几块常见的溢出类型还有 Metaspace 元空间溢出、StackOverflowError 栈溢出、Direct memory 直接内存溢出。它们的报错文本不同、排查手段也不同。内存区域溢出报错典型触发场景Java 堆java.lang.OutOfMemoryError: java heap space大对象、集合膨胀、大文件一次读入元空间java.lang.OutOfMemoryError: Metaspace动态生成类过多、CGlib/反射滥用虚拟机栈java.lang.StackOverflowError递归无出口、深调用链直接内存java.lang.OutOfMemoryError: Direct buffer memoryNIO 频繁分配 DirectByteBuffer 不释放本机内存java.lang.OutOfMemoryError: unable to create new native thread线程数超操作系统上限我见过不少新手把-Xmx调大当万能药结果堆溢出变成 Metaspace 溢出或者干脆服务器物理内存被榨干、整机 OOM Killer 把进程杀了。内存问题必须先定位是哪块内存爆了、什么对象占的再谈怎么修。回到大文件这个场景。它最容易触发堆溢出的原因很朴素一次把文件内容全部加载进堆内存不管文件有多大。普通文本文件几十 MB 的时候不觉得等到几百 MB、几个 GB堆哪怕给了 2G 也一样不够。而且很多人没意识到文件读入内存之后JVM 对字符串、字节数组的副本开销比想象中大得多一个 500MB 的文件读进来实际占用的堆空间可能超过 1.5G——因为中间可能经历了 byte[] → byte[] → String → char[] → String 的多重拷贝。这篇文章就把我在实际项目里处理这个问题的完整过程写出来从报错特征、根因分析、堆转储排查到几种可用方案的取舍和落地写法。适合正在被大文件处理折磨的 Java 开发者也适合做文件上传、导出、批处理相关功能但还没踩过坑的人提前避雷。2. 大文件为何总是压垮堆内存三类典型写法剖析做技术排查第一步永远是复现和定位而不是急着改代码。你会发现很多大文件 OOM 的根本原因都出在少数几种顺手就写了的代码模式上。2.1 整文件读入内存的经典写法最常见、也最致命的一种写法是这种// 一次性把整个文件读成 byte[] byte[] fileBytes Files.readAllBytes(Paths.get(/data/upload/2024/product_export.zip)); // 或者一次性按字符串读入 String content new String(Files.readAllBytes(Paths.get(/data/import/large_csv.csv)), StandardCharsets.UTF_8);你能从日志堆栈里看到Files.readAllBytes内部走的是ByteArrayOutputStream不断扩容的逻辑文件多大这个 byte[] 就有多大。如果你之后再new String(...)堆里又会多出一份char[]String的拷贝峰值内存往往是文件体积的 2~4 倍。我自己测试过一个 1.2GB 的 CSV 文件-Xmx2048m下执行Files.readAllBytes再转字符串GC 疯狂 Full GC最终在字符串转换阶段直接 OOM。原因很简单读文件时堆里已有一个 1.2G 的 byte[]再创建等大的 char[]UTF-8 中文三个字节一个字符英文一个字节一个字符无论哪种 char[] 都要按双字节算两个大对象同时存活2G 堆根本不够。Java 8 之前还有个更老的陷阱FileInputStreambyte[] buffer new byte[(int)file.length()]。这行代码看起来人畜无害实际上一口气申请了文件大小的堆空间。如果文件恰好是 2.1GB而数组下标又是 int 型这行代码在编译期就过不了数组最大长度约 2^31 - 1。即便文件小于这个值堆也大概率扛不住。2.2 逐行读取但行本身巨大第二种典型问题出现在我明明用的是readLine为什么还是 OOM的疑问里。很多 CSV、JSON、日志解析器喜欢用BufferedReader.readLine()一行行处理觉得这样就是流式了。这个思路对普通行是成立的但如果文件里有一行特别长——比如 JSON 大对象、Base64 编码的图片、或者没有换行的超长日志——readLine()内部会用一个 StringBuilder 疯狂 append直到遇到换行符才返回。BufferedReader reader new BufferedReader(new InputStreamReader(new FileInputStream(filePath), StandardCharsets.UTF_8)); String line; while ((line reader.readLine()) ! null) { // 处理这一行 }假设一个 800MB 的 JSON 文件经过压缩工具格式化成一整行压测数据、全量导出经常这么干readLine()会先把这一行完整读入内存——又是 800MB 起步。你以为自己在流式处理其实内存峰值一点不比readAllBytes小。// 线上出现过的一次 OOM 栈就是在 line 处理逻辑里 java.lang.OutOfMemoryError: java heap space at java.util.Arrays.copyOfRange(Arrays.java:3664) at java.lang.String.init(String.java:207) at java.lang.StringBuilder.toString(StringBuilder.java:407) at com.example.ImportService.parseLargeJsonLine(ImportService.java:88)当时排查的结果是单行 JSON 40MB程序开了 16 个线程并发解析每个线程各自持有一份这行的副本峰值 640MB 就这样被干掉了。如果判断会不会 OOM只看文件总大小这种并发放大效应就是最容易漏掉的盲区。2.3 流式处理过程中产生的中间结果膨胀第三种更隐蔽。代码本身确实用了流式接口比如InputStream逐步读取但在处理每个数据块时生成了巨大的中间对象——最常见的是 Base64 编码、字符串 split、或者把若干小对象累积进一个 List 等处理完成后一次性入库。一个典型场景把上传的图片文件做 Base64 编码后存 JSON 字段。Base64 会把 3 字节原始数据编码成 4 字节 ASCII也就是编码结果比原文件大 33%。假设原图 100MB转完 Base64 后字符串至少 133MB如果再把字符串放进容器对象里加上char[]的双倍开销单张图片就能吃掉 400MB 堆。// 错误示范一个大文件转 Base64 字符串内存瞬间翻几倍 byte[] data Files.readAllBytes(file.toPath()); String base64 Base64.getEncoder().encodeToString(data); // 又一份 byte[] char[] jsonObject.put(file, base64);还有 split 类操作如果按分隔符把一个超大字符串切分成数组产生的String[]和每个子字符串对象都会驻留堆中而且子串还共享着原 char[] 的引用——表面看是十几个小字符串实际上背后那个巨大的 char[] 依然死死占着内存无法被回收。我把这三类问题统一归为非流式处理或流式中断文件数据一旦在某一步被完整物化成内存对象后续的所有操作都建立在巨量内存之上。要根治就得把完整物化这一步从代码里彻底消灭。3. 从堆转储到根因定位一次完整的内存排查链路知道大文件导致堆 OOM还不够线上环境不能靠猜。正确做法是保留现场、拿数据、分析堆转储用证据链确认到底是谁占的内存。这套流程我走了很多次每一步都有值得注意的细节。3.1 让 JVM 在 OOM 时自动留下案发现场首先要在启动参数里加上堆转储开关。很多生产事故最可惜的一点是JVM 都 OOM 了现场被重启后清空了只能凭开发同学的记忆猜问题。以下参数可以让你在灾难发生时保留完整的堆快照java -Xms2g -Xmx2g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/dump/ \ -jar app.jar加上-XX:HeapDumpOnOutOfMemoryError之后JVM 在抛出 OOM 的前一刻会生成一个.hprof文件默认路径是启动目录指定HeapDumpPath可以放到固定目录方便收集。这个文件体积可能和堆大小相当——2G 堆就可能生成 2G 的 dump 文件所以磁盘空间要预留。如果没有加这个参数也可以用jmap主动抓取。但注意jmap -dump会触发一次 Full GC老版本 JDK 如此新版有所优化对正在运行的生产服务有停顿影响建议在流量低谷操作。# 先看 Java 进程 PID jps -l # 抓取堆转储live 表示只留存活对象 jmap -dump:live,formatb,file/data/logs/dump/app_live.hprof 123453.2 从堆转储文件到罪魁祸首的分析工具链拿到.hprof之后我建议按这个优先级使用分析工具MATMemory AnalyzerEclipse 基金会出的老牌工具Leak Suspects Report能一键给出最可疑的保留集适合快速拿到结论。VisualVMJVM 自带工具链最好上手的能查看堆 dump、对象实例数适合不熟悉 Eclipse 生态的人。JProfiler / YourKit商业工具花钱买效率大型团队可以配一套。整个分析过程我用一个案例串起来。某个批处理服务每天处理 3GB 左右的交易明细文件某天开始频繁 OOM-Xmx已经调到 4G 依旧扛不住。HeapDumpOnOutOfMemoryError捕获的 dump 文件有 3.8G用 MAT 打开后第一屏就显示byte[]实例总大小约 2.7G占总堆 71%。我点进byte[]实例列表按 retained size 倒序排列发现前几个大对象全是 512MB 左右的数组类型都指向同一个类——SftpDownLoadService。再打开这个对象的引用链Merge Shortest Paths to GC Roots看到的是SftpChannel.get(...)→IOUtils.toByteArray(is)→ 一个 537MB 的byte[]。到这里证据链就完整了这个服务把数据文件先从 SFTP 拉下来然后整包交给IOUtils.toByteArray转成 byte[]再做后续解析。4 个超大数组同时存活时堆就撑爆了。修复方向立刻明确改成流式下载 边读边处理。3.3 运行时监控不用等 OOM 也能预判内存趋势堆转储是事后取证我更推荐在平时就盯住内存趋势提前发现某个操作让内存异常飙升。常用的命令组合# 每 2 秒打印一次 GC 情况包含堆使用率 jstat -gcutil 12345 2000 # 输出示例 S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 5120.0 5120.0 960.0 0.0 40960.0 37888.0 151552.0 148736.0 59840.0 57512.0 7168.0 6912.0 42 0.892 15 3.214 4.106OU是老年代已使用量FGC是 Full GC 次数。如果FGC在某个操作期间快速增长或者OU持续上升而不回落说明内存里堆积了大量存活对象——这在批处理场景里往往就是大文件数据被整体持有。jmap -heap可以看堆配置和当前各区使用率jstat -gcutil则更适合连续观察。两个配合使用能比较准确地判断这次 OOM 是一瞬间申请大对象爆的还是内存慢慢堆积漏的。一瞬间爆的典型特征老年代使用率直线打满Full GC 都回收不了多少空间——因为大对象直接进老年代而且 GC 对超大数组的拷贝成本也高。慢慢堆积的特征YGC 后年轻代回收量持续减少老年代使用率阶梯式上升最终在一次 Full GC 后仍无足够空间才 OOM。3.4 别忘了看 GC 日志最后再补一个我踩过坑的细节JVM 参数里加-verbose:gc或者-Xlog:gc*JDK 11 推荐后者GC 日志里能看到一次 OOM 前的完整 GC 过程。比如你会发现异常频繁的 Full GC或者Allocation Failure不断重试这些都是在堆转储之外还原现场的关键补丁。# JDK 8 风格 -verbose:gc -Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps # JDK 11 风格 -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tagsGC 日志配合堆 dump基本能覆盖 90% 以上的大文件 OOM 排查需求。4. 治本方案选型流式读取与分批处理的落地姿势排查的目的不是给领导汇报我们 OOM 了而是修掉它。而修掉绝不只是把-Xmx调大——那只是推迟爆炸时间。真正有效的方向是减少堆内存里大对象的存续时间和数量。我按不同业务场景给出几套落地方案并解释为什么选它。4.1 方案一分批读取替代整包读入适用场景大文件 CSV、日志、固定格式文本可以按行或按固定大小分块处理。替代Files.readAllBytes最直接的方式是BufferedReader逐行读取。但前面 2.2 说过readLine()对超长行无能为力。更稳的是自定义分批读取要么控制缓冲区大小逐 chunk 处理要么用read(char[], off, len)按固定字符数消费流。try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(filePath), StandardCharsets.UTF_8), 8192)) { String line; int batchCount 0; ListString batch new ArrayList(1000); while ((line reader.readLine()) ! null) { batch.add(line); if (batch.size() 1000) { processBatch(batch); // 你真正的业务逻辑 batch new ArrayList(1000); batchCount; } } if (!batch.isEmpty()) { processBatch(batch); } }这个写法的核心思想是每一时刻堆里只保留一个批次的数据。1000 行 CSV 可能只有几百 KB哪怕里面的字段多、字符串长也不会造成堆压力。真正该注意的是processBatch里别把整个 batch 累积到全局 List——那就前功尽弃了。如果处理超大 CSV 需要高效批量入库可以用 JDBC 的addBatch()/executeBatch()搭配rewriteBatchedStatementstrueMySQL做到边读边写内存占用稳定在一个低水位。4.2 方案二用 NIO 通道 自定义缓冲处理二进制大块适用场景非文本文件、需要按块计算校验和、或者需要将文件分片压缩/加密的场景。用BufferedInputStream或者 NIO 的FileChannel按块读取。以 NIO 为例我常用的写法try (FileChannel channel FileChannel.open(Paths.get(filePath), StandardOption.READ)) { ByteBuffer buffer ByteBuffer.allocateDirect(16 * 1024 * 1024); // 16MB 直接缓冲 while (channel.read(buffer) ! -1) { buffer.flip(); // 处理这一块 processChunk(buffer); buffer.clear(); } }有一点要特别说明ByteBuffer.allocateDirect分配的是堆外直接内存不受堆大小限制但受本机物理内存和 JVM-XX:MaxDirectMemorySize限制。如果你选择直接内存要格外小心释放时机——它不参与堆 GC 的正常回收引用失联后依赖 Cleaner 处理处理不当比堆溢出更隐蔽。小文件、低频操作时我反而推荐ByteBuffer.allocate(16MB)用堆内缓冲虽然多占堆内存但 GC 管得到可控性高。4.3 方案三Excel / XML / JSON 专用流式解析这类格式比较特殊readLine很别扭但把它们整个读进内存又是灾难。ExcelApache POI 用户注意WorkbookFactory.create(is)默认把整个文件加载进内存。改用WorkbookFactory.create(InputStream, String password, boolean readOnly)配合SXSSFWorkbook写或XSSFWorkbook的流式模式。POI 的XSSFReader可以按 Sheet 按行流式读取避免整簿加载。JSON 大数组Jackson 的JsonParser逐 token 解析只保留正在处理的对象Gson用JsonReaderFastjson 的JSONReader也类似。关键是别调parseObject(String fullText)或parseArray(String fullText)。JsonParser parser JsonParserFactory.createParser(new FileInputStream(large.json)); try (parser) { while (!parser.isClosed()) { JsonToken token parser.nextToken(); if (token JsonToken.FIELD_NAME data.equals(parser.currentName())) { // 手动读取单个对象 JsonNode obj parser.readValueAsTree(); processItem(obj); // 每次只保留一个 item } } }XML用 StAXXMLStreamReader按事件流式处理及时丢弃已处理节点不要用 DOM 一次性构建整棵对象树。4.4 方案四超大文件导出用流式响应替代文件全量缓存与导入相对的是导出。很多人做 Excel/CSV 导出时喜欢先把所有数据装进 List再一次性生成文件结果数据量一大List 本身就把堆撑爆。正确姿势是边查边写边输出。数据库层用流式游标setFetchSize控制每次取多少行业务层逐行写入文件流或 HTTP 响应流完全不落整个结果集到内存。// 伪代码Spring MVC 流式导出 CSV GetMapping(/export) public void export(HttpServletResponse response) throws Exception { response.setContentType(text/csv;charsetUTF-8); response.setHeader(Content-Disposition, attachment;filenamebig_export.csv); try (PrintWriter writer response.getWriter()) { try (StreamSomeEntity stream someService.streamAll()) { stream.forEach(entity - writer.println(toCsvLine(entity))); } } }数据库端有几个配套点要留意MySQL JDBC 默认useCursorFetchfalse要流式查询必须设置useCursorFetchtruefetchSize500否则驱动会一次性把结果集拉进内存。PostgreSQL 则要考虑事务隔离级别和autoCommitfalse配合fetchSize才能激活游标流式读取。有些 ORM 框架默认帮你全部物化比如 MyBatis 默认查询返回 List要使用Cursor或StreamingResultHandler才能拿到真正的流式查询。4.5 方案五临时文件降级绕开内存这道坎如果业务逻辑实在没法改造成流式——比如需要先对文件做全量排序再输出或者有几个数据源要合并后再统一处理——那就别让文件数据在堆里活着把它们落盘到临时文件。我这里说的落盘不是把内存对象序列化到磁盘那数据都构建好了内存压力已经产生了而是在源头就写临时文件从网络流读到的内容边收边写/tmp处理时通过File或FileChannel的 mmap 技术按需映射读取。Path tmpFile Files.createTempFile(upload_, .tmp); try (InputStream in multipartFile.getInputStream(); OutputStream out Files.newOutputStream(tmpFile)) { byte[] buf new byte[8192]; int len; while ((len in.read(buf)) ! -1) { out.write(buf, 0, len); } } // 之后用 FileChannel 按需读取堆里不放大对象MappedByteBuffermmap也可以考虑——它把文件直接映射到进程地址空间读写时表面跟内存操作一样但用的是虚拟内存不受 JVM 堆限制。不过 mmap 适合顺序/随机读大文件写入场景要注意文件截断、页面缓存刷盘、以及进程崩溃时数据丢失的风险。生产环境我会把 mmap 视为一种高级武器只在确认适合的场景才用。5. 把这些方案组合起来一个大文件导入接口的完整改造示例上面拆了五个方案实际项目里很少只用其中一种。我把一个真实案例完整过一遍从问题代码到改造后代码的差异就很直观了。5.1 改造前的必然 OOM版本假设需求接收前端上传的 Excel/CSV解析后批量入库。最初的实现PostMapping(/import) public Result importFile(RequestParam(file) MultipartFile file) throws Exception { // 问题1MultipartFile.getBytes() 把整个文件读入堆 byte[] bytes file.getBytes(); // 问题2按字符串处理又复制一份 String content new String(bytes, StandardCharsets.UTF_8); // 问题3Excel 场景下 POI 整簿加载 Workbook workbook WorkbookFactory.create(new ByteArrayInputStream(bytes)); Sheet sheet workbook.getSheetAt(0); ListRow rows new ArrayList(); for (Row row : sheet) { rows.add(row); // 问题4把所有行存在 List 里 } // 入库 insertRows(rows); return Result.success(); }文件 300MB 时getBytes()先弄一个 300M 的 byte[]然后new String又搞一份 600M 的 char[]中文场景POI 内部还可能创建 DOM 树这一个接口瞬间要 1~2G 堆。不死才怪。5.2 改造后的水位稳定版本面对这个需求我会根据文件类型做分流但核心原则一致永远不把整个文件物化到堆里。PostMapping(/import) public Result importFile(RequestParam(file) MultipartFile file) throws Exception { String filename file.getOriginalFilename(); boolean isCsv filename ! null filename.toLowerCase().endsWith(.csv); int imported 0; if (isCsv) { imported importCsvStream(file.getInputStream()); } else { imported importExcelStream(file.getInputStream(), filename); } return Result.success(imported); } private int importCsvStream(InputStream in) throws Exception { int total 0; ListRowData batch new ArrayList(500); try (BufferedReader reader new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; // 跳过表头 reader.readLine(); while ((line reader.readLine()) ! null) { RowData row parseCsvLine(line); batch.add(row); if (batch.size() 500) { total batchInsert(batch); batch.clear(); } } if (!batch.isEmpty()) { total batchInsert(batch); } } return total; } private int importExcelStream(InputStream in, String filename) throws Exception { int total 0; // 关键使用 XSSFReader / SXSSF 流式读取 try (OPCPackage pkg OPCPackage.open(in)) { XSSFReader reader new XSSFReader(pkg); SharedStringsTable sst reader.getSharedStringsTable(); for (Sheet sheet : reader.getSheetsData()) { ListRowData batch new ArrayList(500); IteratorRow rows new XSSFSheetXMLHandler( sst, null, new SimpleSheetContentsHandler() { Override public void endRow(int rowNum) { // 每次 endRow 就拿到一行数据 } }, false, true); // ... 逐行收集批次 } } return total; }这样改造之后接口的内存占用基本是过路财神每 500 行一批处理完立即释放堆水位始终平稳。MultipartFile虽然还是会把上传内容放到临时文件Spring 默认超过阈值会落盘但我们的代码访问的是InputStream没有触发整文件堆内加载。5.3 什么时候该用多线程什么时候千万别用很多同学看到文件大第一反应是多线程并发处理不就能快一点吗。大方向没错但并发模式选择不当会让 OOM 更严重。如果是一个文件挨个读并行解析的意义有限反而因为多个线程同时持有各自的 Buffer/对象让内存峰值成倍放大。我见过一个项目对 8 个 100MB 文件做并发读取直观感觉8 个线程一起干活多快结果堆直接炸了——8 份 100MB 数据同时驻留之前单线程只留 1 份。正确做法是文件内部按块/按行拆分时用单线程读、多线程处理的流水线模式读线程每读到一批就丢给线程池处理完及时回收。多个文件时控制并发数 CPU 核数或核数/2而不是有 10 个文件就开 10 个线程。ExecutorService pool Executors.newFixedThreadPool(Math.max(4, Runtime.getRuntime().availableProcessors() / 2)); // 读线程 while ((line reader.readLine()) ! null) { final String data line; pool.submit(() - processLine(data)); // 后续注意如果提交太快任务积压在队列里同样占内存 }这里还有一个隐藏风险pool.submit()提交的任务如果处理速度跟不上读取速度未处理任务会堆积在线程池队列里内存照样爆。稳妥的做法是用有界阻塞队列CallerRunsPolicy读慢了就让生产线程自己干自然形成背压。ThreadPoolExecutor pool new ThreadPoolExecutor( 4, 8, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new ThreadPoolExecutor.CallerRunsPolicy());6. 出口拦截与防御让 OOM 不再悄然吞噬服务再从工程管理角度聊聊防复发。修好一次 OOM 不算本事线上环境变数多今天我改了导入接口明天导出接口又用同样写法写了一遍后天同事新接了个 FTP 下载模块又踩回去。所以光有解决方案不够还得有防御机制。6.1 JVM 参数调整的误区与正确姿势不少人一键把-Xmx从 2G 改到 8G以为问题解决了。如果代码写的是整文件读入8G 堆确实能扛更大文件但也会带来副作用堆越大GC 停顿时间越长。8G 堆一次 Full GC 可能停顿几百毫秒到数秒高并发接口直接超时雪崩。老年代太大CMS或G1的并发周期会变慢容易积累浮动垃圾。服务器物理内存是有限的堆大了留给堆外、线程栈、元空间的空间就少了可能引发其他类型的 OOM。而且堆调大之后文件再大一点照样爆——治标不治本。正确做法是堆大小按服务的正常峰值内存 x 1.5~2 倍来设置而不是按最大可能文件大小来设置。让文件处理逻辑去适配堆而不是让堆去适配文件。6.2 代码评审清单专治大文件场景的雷我在团队里定了一条规矩涉及文件 IO 的 MR必须对照以下清单检查缺一项都要打回说明理由是否有Files.readAllBytes、FileInputStream.getBytes、IOUtils.toByteArray、MultipartFile.getBytes()这类整文件加载调用是否有new String(byte[])且数组来自整个文件是否在循环外持有大批量集合没有分批处理/释放是否对超大字符串执行了split()、substring()后还保留了原字符串引用前端上传/下载是否设置了文件大小上限服务端是否有对应校验数据库查询是否用了流式游标还是把所有结果集拉进 List有没有对文件大小做分级处理比如 100MB 走专用流程1GB 拒绝或走异步任务分享一个substring的坑JDK 6 及之前substring的返回值共享原 String 的char[]哪怕你只取 1 个字节的字符串背后那个巨大的char[]依然被引用GC 无法回收。JDK 7u6 之后才改为复制但如果你还在维护老环境这确实会变成隐藏的内存黑洞——线上曾经有人把 200MB 字符串拆出几个小字段后整个 200MB 的char[]一直不被释放堆直接吃满。6.3 监控告警别等 OOM 才行动通用监控平台有很多大多是白盒监控。除此之外有条件的团队建议把以下指标纳入告警Full GC 频率超过每分钟 N 次说明堆内存可能异常需要介入。GC 停顿时间P99 超过 300ms直接触发告警。堆使用率老年代使用率超过 80%连续 5 分钟触发预警。大文件接口的耗时/失败率单独看接口级指标避免被整体健康度掩盖。如果用了Spring Boot Actuator Micrometer可以快速把 JVM 的jvm.memory.used等指标暴露出来。团队里没有现成平台时先写好定时脚本jstat采样也能应急。6.4 文件大小上限与异步化策略不要天真地以为系统内存大就能处理任意大小的文件。任何服务都应该有明确的能力边界。我通常这样约定上传接口普通上传限制 50MB大文件走分片上传接口每片 4~8MB由前端负责分片和合并。已上传的大文件处理拆成异步任务用消息队列或独立任务线程池消费避免占用 HTTP 请求线程。超过 2GB 的文件默认拒绝或转离线批处理集群不在常规 Web 服务里硬扛。大文件有一种很适合用前端分片解决的场景。与其让后端一个 HTTP 请求吃进 1GB 文件不如让前端把文件切成 8MB 一片一片一片传后端每片都是小请求内存压力天然被控制住了。之后再由后端或独立服务把这些分片合并——合并本身用流式 IO 落盘不占堆。这块在业界已经有很多成熟方案可参考MinIO的分片上传协议S3 Multipart Upload就是这样设计的它能在对象存储层面规避大文件一次上传的内存/网络双瓶颈。6.5 兜底进程级容灾与快速恢复即便做了一切防御还是可能被突发流量或者上游异常打个措手不及——比如上游突然推了一个 20GB 的小文件过来。这时候需要兜底措施容器环境设置内存 limit让 OOM 时容器快速重启而不是整机卡死。Java 进程启动脚本里监听 OOM 退出码自动摘流量并重启。使用-XX:OnOutOfMemoryError参数可以在 OOM 时执行指定脚本——比如通知、下线节点。-XX:OnOutOfMemoryErrorsh /opt/scripts/oom_notify.sh我不建议用这个参数直接自动重启服务因为 OOM 后 JVM 内部状态可能已经不可靠自动重启不如让编排系统K8s、容器平台做健康检查和拉起更干净。关键是出事要能发现要能恢复要留得住现场。7. 一点个人经验做了这些年 Java 后端我的体会是OOM 本质上不是内存不够而是你让代码在一个地方同时持有太多数据。大文件只是把这个问题暴露出来的放大镜真正该改的是处理数据的方式。所以遇到java.lang.OutOfMemoryError: java heap space别第一时间想加内存而是先回答这三个问题数据是在哪个环节被完整物化到堆里的能不能改成流式处理让内存里永远只有一小批数据这批数据的批量大小和并发度是不是合理可控的把这三个问题答透了OOM 基本就解决了一半。另外建议新项目从写第一行文件处理代码起就坚持流式风格别图省事用readAllBytes。刚开始可能觉得多写几行麻烦但等你在凌晨被线上告警吵醒过一次就会发现这几行代码是最值的保险。