Java大文件上传内存优化:分片与流式处理全解析

发布时间:2026/9/24 4:06:37
Java大文件上传内存优化:分片与流式处理全解析 大文件上传是很多 Java 后端同学绕不开的坑。今天聊的场景很具体浏览器端要往服务器传几个 GB 的文件前端把分片逻辑做了但传到一半浏览器越来越卡服务端 Java 内存一路飙高最后直接 502。我维护的那个 Java 上传处理插件也踩过类似的坑后来把方案整体调整了一遍从浏览器端的文件读取方式到后端 multipart 解析和文件合并逻辑逐个环节做了优化内存曲线才算真正平缓下来。这篇文章就把这套优化思路和可复用的代码分享出来适合正在做文件上传功能、或者准备面试时聊大文件上传方案的 Java 后端和全栈开发者。先说明一下标题里的“Java插件”指的是 Java 后端负责接收和处理上传请求的上传组件/模块不是浏览器里的 Java Applet那个时代早结束了。现代方案里浏览器端负责分片Java 后端负责流式接收和合并两端配合才能把内存占用压下去。接下来我会先讲清楚内存到底消耗在哪再给前端和后端的优化细节最后是一套可以直接抄的落地方案。1. 先搞清楚大文件上传的内存究竟消耗在哪一侧很多同学遇到内存问题时第一反应是“服务端该加内存了”或者“浏览器垃圾回收没做干净”。但真正常见的原因是开发者在两端都用了“一次性把整个文件塞进内存”的方式。要优化得先知道内存在哪一步被吃掉。1.1 浏览器端的“隐形膨胀”File、Blob 与 ArrayBuffer 的区别浏览器里input typefile选中的文件是一个File对象。File本质上是Blob的子类它更像一个文件引用/句柄浏览器并不会在用户选择文件那一刻就把整个文件内容读进 JavaScript 内存。这一点很关键意味着“选择大文件”本身不会让网页卡死。真正让内存爆掉的操作常见于下面两种写法FileReader.readAsDataURL(file)把整个文件转成 Base64 字符串。Base64 会让数据体积额外膨胀约 33%一个 1GB 的文件转完大概要 1.33GB 的字符串内存再加上转换过程中的中间对象内存峰值完全失控。FileReader.readAsArrayBuffer(file)把整个文件一次性读成 ArrayBuffer。虽然不像 Base64 膨胀得那么夸张但 1GB 的文件就需要 1GB 以上的 ArrayBuffer 空间在浏览器里基本等于自杀。读取完之后如果不主动把引用置空这个 ArrayBuffer 或字符串还会一直留在内存里等待垃圾回收。如果同时上传多个文件或上传过程中还有其他页面逻辑浏览器标签页直接崩溃是常态。所以浏览器端优化的第一原则是不要全量读取文件内容按需切片读取并且让分片数据尽快发送出去。File.slice(start, end)返回的依然是一个Blob引用它不会立刻把分片数据加载到内存只有在发送请求的时候浏览器才会流式读取这一小段内容。这个差异就是内存占用天壤之别的根源。1.2 Java 端的“隐形陷阱”multipart 解析与 getBytes再看服务端。Java 处理文件上传最常见的是 Spring Boot 的MultipartFile。很多教程会教你先multipartFile.getBytes()然后写入本地文件。问题就出在这里getBytes()会强制框架把 multipart 表单里的文件部分完整读入一个byte[]。有人可能会说“Spring 不是默认会把大文件写入临时文件吗不会占内存啊。”这个说法只对了一半。Spring Boot 的 multipart 解析确实有“先内存、后磁盘”的机制ServletRequest 解析上传数据时如果某个文件大小超过file-size-thresholdSpring Boot 默认配置是 0即所有文件直接落盘会先写入临时文件。但这和后续业务代码无关getBytes()一旦被调用框架仍然会把临时文件内容整个加载到 JVM 堆内存里。如果前端没有做分片一个 2GB 文件直接 POST 给 Java 接口那么容器比如 Tomcat解析 multipart 时可能先把文件写到磁盘临时目录业务代码调用getBytes()时JVM 堆内存瞬间多出 2GB 的 byte 数组多个用户同时上传JVM 直接OutOfMemoryError。所以 Java 端的优化原则也很简单接收分片文件时永远不要调用getBytes()要用getInputStream()流式写入磁盘合并分片时也不要收集所有分片的 byte[] 再拼接要用 FileChannel 或固定缓冲区管道式写入。2. 前端侧优化不让浏览器把整个文件塞进内存前端是“第一道防线”。如果浏览器端把整个文件读进内存后端再怎么优化也救不回来。这一节讲具体怎么改。2.1 正确姿势File.slice() 生成分片 FormData 发送先看一段常见的错误代码很多人是这么写的const reader new FileReader(); reader.onload async (e) { const base64Data e.target.result; await fetch(/upload, { method: POST, body: JSON.stringify({ fileName: file.name, data: base64Data }) }); }; reader.readAsDataURL(file);这段代码在文件超过几百 MB 时几乎必然导致浏览器崩溃。正确做法是用file.slice()分片再通过FormData发送const file input.files[0]; const CHUNK_SIZE 5 * 1024 * 1024; // 5MB 一个分片 let start 0; let index 0; while (start file.size) { const chunk file.slice(start, start CHUNK_SIZE); const formData new FormData(); formData.append(file, chunk, file.name); formData.append(index, index); formData.append(totalChunks, Math.ceil(file.size / CHUNK_SIZE)); formData.append(identifier, generateFileId(file)); // 用文件唯一标识如 md5 // 发送这里先不管并发下一小节控制 await sendChunk(formData); start CHUNK_SIZE; index; }关键点在于file.slice()不会把整个文件载入内存FormData.append(file, chunk)里的 chunk 是一个 Blob浏览器在 XHR/fetch 真正发送时会按需读取该分片的底层数据。这比“全量读入 一次性上传”的内存占用低好几个数量级。分片大小怎么选我习惯在 5MB 到 20MB 之间。如果网速较快、服务端带宽也充足可以选大一点减少请求次数如果移动端用户多建议 2MB 到 5MB因为移动端内存更紧张弱网下大分片失败重传的成本也高。总原则是分片大小 × 并发数 × 2前后端各一份在途数据不能逼近用户的可用内存。2.2 并发控制别让分片“一拥而上”分片有了很多同学会顺手写一个Promise.all把全部分片一次性丢给浏览器去请求。这样做的后果有两个浏览器对同一域名的 HTTP/1.1 并发连接数有限制一般是 6 个左右多余的请求会排队并发数过多时内存中同时处于“发送中/待发送”状态的分片引用变多网络抖动时还会造成大量重试反而拖慢整体速度。推荐用简单的并发池控制把同时上传的分片数量限制在 3 到 6 个。一段很轻量的实现如下async function uploadWithConcurrency(tasks, limit 4) { const results []; const executing new Set(); for (const task of tasks) { const promise Promise.resolve().then(task); results.push(promise); executing.add(promise); const clean () executing.delete(promise); promise.then(clean, clean); if (executing.size limit) { await Promise.race(executing); } } return Promise.all(results); }实际使用的时候把分片发送函数包成一个个 task 传进去即可。这样同一时间最多只有 4 个分片请求在途内存在前端的占用基本就是 4 × 分片大小。2.3 进阶Web Worker、哈希校验与流式上传如果再往下做可以考虑这三点优化。第一分片后如果要做秒传或断点续传通常需要计算文件哈希比如 md5。但整个文件哈希计算如果放在主线程界面会卡死。一个常见方案是把文件读入 ArrayBuffer 分块计算哈希——注意这里容易犯错误不能readAsArrayBuffer整个文件而要按 1MB 或 2MB 一块用 FileReader 或crypto.subtle.digest逐段计算。逐块计算时上一块内容计算完就释放内存不会累积。更稳妥的做法是把哈希计算放到 Web Worker 里避免阻塞 UI 渲染。第二现代浏览器支持file.stream()它返回一个 ReadableStream。配合 fetch 的duplex: half参数可以把文件流直接作为请求体发送服务端就能一边接收一边处理。这种方式的内存占用更低但需要后端支持流式读取而且兼容性要提前确认。日常上传功能用FormDataslice已经足够流式上传可以作为一个优化方向去探索。第三不要“收集完所有分片再统一上传”。有人喜欢先把全部分片转成 ArrayBuffer 放进一个数组然后循环发送。这等于变相把整个文件塞进内存。正确的做法是生成一个分片立刻加入发送队列发送完成后置空该分片引用GC 才能及时回收。3. Java 插件/后端的优化从 MultipartFile 到流式处理前端把分片控制好之后压力就来到 Java 后端。很多“内存占用高”的问题不是 JVM 没给够内存而是代码里做了太多整文件级别的操作。3.1 Spring Boot multipart 配置到底该怎么调先看配置。Spring Boot 中 multipart 相关配置集中在spring.servlet.multipart.*下spring: servlet: multipart: max-file-size: 50MB max-request-size: 55MB file-size-threshold: 0 location: /data/upload-tmpmax-file-size单个文件大小上限。分片上传时它表示单个分片的大小上限建议比前端分片大小再大一些。max-request-size整个请求体上限。因为分片请求里除了文件还有少量表单字段所以这个值要比分片大小略大。file-size-threshold超过这个阈值的文件才会写入临时文件低于阈值的文件会缓存在内存中。想降低内存占用把它设成 0让所有上传文件都走临时文件。location临时文件目录。最好放在独立分区避免系统盘被打满。这里有个反直觉的点有人觉得把file-size-threshold调大小文件走内存、速度更快。但如果是大文件上传场景这一步应该反过来让文件尽早落盘宁可多点磁盘 IO也不能让大量文件内容堆积在 JVM 堆内存里。任何一个大文件分片的“内存缓冲”都是潜在的 OOM 源头。3.2 不要用 getBytes()改用 getInputStream() 写盘配置只是第一步业务代码才是关键。下面是一个典型的 Java 后端分片接收接口和很多“反面教材”最大的区别是全程没有调用getBytes()没有new byte[(int) file.getSize()]而是用固定缓冲区流式拷贝。PostMapping(/upload/chunk) public ResponseEntityString uploadChunk( RequestParam(file) MultipartFile chunk, RequestParam(index) int index, RequestParam(identifier) String identifier) throws IOException { // 每个文件一个独立目录目录名用 identifier 标识 Path chunkDir Paths.get(UPLOAD_DIR, identifier); Files.createDirectories(chunkDir); Path chunkFile chunkDir.resolve(index .part); try (InputStream in chunk.getInputStream(); OutputStream out Files.newOutputStream(chunkFile)) { byte[] buffer new byte[64 * 1024]; // 64KB 缓冲区 int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } return ResponseEntity.ok(chunk received); }这段代码的核心是把 64KB 的byte[]复用到底不管分片是 5MB 还是 20MB堆内存占用都只有几十 KB。有人会觉得“每次分配新 byte[] 不也一样吗”但其实看 GC 压力一次性分配 20MB 数组和使用 64KB 循环缓冲对新生代的影响完全不同。前者在并发场景下会造成频繁的 Young GC甚至因为大对象直接进入老年代导致 Full GC 变多。InputStream一定要用 try-with-resources 关闭否则临时文件不会释放、文件句柄会泄漏。这是最容易被忽视也最容易造成后续磁盘和内存问题的一个细节。3.3 如果需要更底层Part.getInputStream() 与 FileItemIterator如果默认的MultipartFile满足不了需求比如想绕开 Spring 的 multipart 解析流程、在解析过程中做文件类型校验、或者希望更精细地控制临时文件位置可以用更底层的 API。Servlet 3.0 的HttpServletRequest可以直接拿到PartPostMapping(/upload/chunk) public ResponseEntityString uploadChunk(HttpServletRequest request) throws Exception { Part part request.getPart(file); String identifier request.getParameter(identifier); int index Integer.parseInt(request.getParameter(index)); Path chunkFile Paths.get(UPLOAD_DIR, identifier, index .part); try (InputStream in part.getInputStream(); OutputStream out Files.newOutputStream(chunkFile)) { byte[] buffer new byte[64 * 1024]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } return ResponseEntity.ok(chunk received); }如果使用的是 Apache Commons FileUpload可以采用FileItemIterator的方式逐个遍历 item。相比把所有 item 解析进内存/临时文件这种方式是“边解析边处理”的流式模型对内存更友好。不过对于 Spring Boot 项目默认的StandardServletMultipartResolver已经够用只有需要极致的控制力时才建议替换。还有一个小提示不同容器的 multipart 解析实现不一样。Tomcat 默认使用DiskFileItemJetty 和 Undertow 也各有自己的实现。换容器之后最好压测一下临时文件路径和内存占用别想当然认为“配置一样结果就一样”。4. 落地实现一套可抄的分片上传方案前面讲的原理和配置最终要落成代码。这一节给一个前后端打通的最小完整方案包含并发控制、分片接收、乱序合并以及参数怎么算。4.1 前端分片与并发池代码用一个简单的 HTML JS 示例说明。关键函数都加了注释核心逻辑集中在startUpload里。const CHUNK_SIZE 5 * 1024 * 1024; const CONCURRENCY 4; function generateFileId(file) { // 实际项目建议用 SparkMD5 分块计算这里用名称大小最后修改时间做简化 return ${file.name}_${file.size}_${file.lastModified}; } function uploadChunk(file, chunkIndex, totalChunks, identifier) { return new Promise((resolve, reject) { const start chunkIndex * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const chunk file.slice(start, end); const formData new FormData(); formData.append(file, chunk, file.name); formData.append(index, chunkIndex); formData.append(totalChunks, totalChunks); formData.append(identifier, identifier); const xhr new XMLHttpRequest(); xhr.open(POST, /upload/chunk); xhr.upload.onprogress (e) { if (e.lengthComputable) { // 更新当前分片进度 } }; xhr.onload () (xhr.status 200 ? resolve() : reject(new Error(chunk ${chunkIndex} failed))); xhr.onerror () reject(new Error(chunk ${chunkIndex} network error)); xhr.send(formData); }); } async function startUpload(file) { const totalChunks Math.ceil(file.size / CHUNK_SIZE); const identifier generateFileId(file); const tasks []; for (let i 0; i totalChunks; i) { tasks.push(() uploadChunk(file, i, totalChunks, identifier)); } await uploadWithConcurrency(tasks, CONCURRENCY); await fetch(/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ identifier, fileName: file.name, totalChunks }) }); }uploadWithConcurrency就是上个章节那个并发池函数。用 XHR 而不是 fetch是因为 XHR 的upload.onprogress在上传进度回调上更成熟如果你想统一用 fetch需要自己实现进度目前fetch的上传进度支持不如 XHR 方便。4.2 Java 后端接收与合并接口后端接口分为两个接收分片、合并分片。接收分片的代码前面已经给了这里重点说合并。分片可能乱序到达也可能有重传。稳妥的合并方式是在接收分片时按index把每个分片存成独立文件合并时先检查分片数量是否齐全然后用FileChannel.transferTo按顺序写入目标文件。由于分片都是定长的最后一个分片可能不足所以要按实际文件大小截断。PostMapping(/upload/merge) public ResponseEntityString merge(RequestBody MergeRequest request) throws IOException { Path chunkDir Paths.get(UPLOAD_DIR, request.getIdentifier()); Path target Paths.get(UPLOAD_DIR, request.getFileName()); int totalChunks request.getTotalChunks(); for (int i 0; i totalChunks; i) { Path part chunkDir.resolve(i .part); if (!Files.exists(part)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(chunk i missing); } } try (FileChannel out FileChannel.open(target, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { for (int i 0; i totalChunks; i) { Path part chunkDir.resolve(i .part); try (FileChannel in FileChannel.open(part, StandardOpenOption.READ)) { long size in.size(); in.transferTo(0, size, out); } Files.deleteIfExists(part); } } Files.deleteIfExists(chunkDir); return ResponseEntity.ok(merge done); }如果需要支持乱序分片写入也可以使用RandomAccessFile按 offset 写入try (RandomAccessFile raf new RandomAccessFile(targetFile, rw)) { raf.seek((long) index * CHUNK_SIZE); // 用 FileChannel 从分片文件 transferTo 到 raf.getChannel() }这种方式可以把“先收齐再合并”改成“边收边写”但要注意同时只允许一个合并任务对同一个文件写避免并发写同一位置导致数据错乱。我的建议是先按分片文件收齐再合并逻辑简单、出错率低如果分片数量特别多导致合并耗时过长再考虑乱序直写方案。4.3 参数计算分片大小、并发数与内存公式很多人问“分片大小到底怎么定”其实可以用一个简单公式估算两端的内存峰值内存峰值 ≈ 分片大小 × 并发数 × 2浏览器侧在途数据 服务端在途数据 各层缓冲区开销假设分片 5MB、并发 4那前后端叠加的在途数据大约 40MB再加上 JVM 堆里的连接缓冲、临时 Buffer、GC 余量等实际占用会在 100MB 以内。如果服务端 JVM 最大堆内存只有 512MB这个参数就很安全如果分片 50MB、并发 8内存峰值就能到 800MB 以上离 OOM 不远了。再考虑网络因素分片越大单请求耗时越长重传成本越高分片越小请求数越多服务端每请求的开销IO、线程调度也会上升。所以“5MB 分片 × 4 并发”和“10MB 分片 × 3 并发”都是比较均衡的起点重点是要针对自己的服务端配置做压测。5. 上线后最容易踩的坑与排查技巧方案做完了并不代表万事大吉。我实际运行这个上传插件时遇到过不少问题很多是文档里不会写明白的。5.1 常见问题速查表现象可能原因排查方式解决方案浏览器上传时标签页崩溃前端用了 FileReader 全量读取或 Base64打开 DevTools Performance 看堆内存曲线改为 file.slice() FormData 分片Java 服务 OOM业务代码调用 getBytes() 或收集全部分片到内存生成 heap dump查看 byte[] 大对象改 getInputStream() 流式写入调小 file-size-thresholdNginx 返回 413Nginx 请求体大小限制小于前端分片大小检查 nginx error.log调整 client_max_body_size或者调小前端分片合并后文件损坏分片乱序、未按 index 校验、最后一个分片长度处理错误对比原始文件 md5合并前校验分片完整性按 index 顺序传输最后按文件实际大小截断临时目录被占满上传中断后分片没有清理查看 location 目录启动定时任务清理超过 24 小时未合并的分片上传速度很慢连接一直排队前端并发数过大超过浏览器连接限制看 Network 面板限制并发在 3-6 个这里重点说两个容易忽略的点。第一个是 Nginx。很多项目前端直连 NginxNginx 默认client_max_body_size是 1MB分片 5MB 时会直接 413。很多人会把它调到 50MB、100MB但这会带来新的问题如果某个请求真的传了一个超大 bodyNginx 和 Java 服务都会很吃力。更好的做法是结合分片大小来设置比如前端分片 5MBclient_max_body_size设成 6MB 或 8MB留一点冗余给表单字段。第二个是临时文件清理。如果用户上传到一半关掉网页那些分片文件会一直残留在服务器上。时间一长占满磁盘不说服务端在扫描目录时还会读到脏数据。我的做法是加一个定时任务定期清理修改时间超过 24 小时且没有对应合并记录的目录Scheduled(cron 0 0 3 * * ?) public void cleanExpiredChunks() throws IOException { Path uploadDir Paths.get(UPLOAD_DIR); if (!Files.exists(uploadDir)) return; long now System.currentTimeMillis(); try (StreamPath dirs Files.list(uploadDir)) { dirs.filter(Files::isDirectory).forEach(dir - { try { Files.walk(dir) .filter(p - Files.isRegularFile(p)) .filter(p - now - Files.getLastModifiedTime(p).toMillis() 24 * 3600 * 1000L) .findFirst() .ifPresent(p - deleteDirQuietly(dir)); } catch (IOException ignored) { } }); } }5.2 断点续传与秒传的扩展思路这个方案的天然优势是容易扩展断点续传和秒传。断点续传前端在上传前先调后端接口带上identifier查询哪些分片已经存在。返回一个已接收的分片 index 列表前端只上传缺失的分片。后端查询逻辑很简单扫描对应目录下的.part文件即可GetMapping(/upload/status) public SetInteger uploadedChunks(RequestParam String identifier) { Path chunkDir Paths.get(UPLOAD_DIR, identifier); if (!Files.exists(chunkDir)) return Collections.emptySet(); try (StreamPath files Files.list(chunkDir)) { return files.map(p - Integer.parseInt(p.getFileName().toString().replace(.part, ))) .collect(Collectors.toSet()); } catch (IOException e) { return Collections.emptySet(); } }秒传前端上传前先算文件哈希后端通过哈希判断服务器上是否已存在完全相同的文件存在就直接返回“上传成功”不用真的传数据。哈希计算要放在 Web Worker 里并且分块读取、逐块更新哈希状态避免一次性读取整个文件。5.3 最后再分享两个小技巧第一个是关于内存观测。前端可以用performance.memory.usedJSHeapSize粗略观察页面堆内存但注意这个 API 只在 Chromium 内核下有效。后端可以用jmap -dump生成堆 dump重点看byte[]和char[]的占用如果发现有大量几十 MB 级别的数组基本可以断定代码里有全量读取文件的操作。第二个是关于“手动触发 GC”这个话题。有同学在 Electron 之类的桌面应用里用过--expose-gc配合global.gc()定时释放内存这在特定环境里确实有效。但浏览器 Web 页面不能依赖这种手段也不要试图用window.gc去解决大文件上传的内存问题正确思路永远是“从源头减少大对象”而不是等内存爆了再去触发回收。上传组件的内存优化本质上是让数据像水管一样流过去而不是先用水桶把水全部接住再倒。我个人的体会是大文件上传的内存问题十有八九不是“机器内存不够”而是代码把整份数据在内存里复制了好几份。把“全量读取”改成“流式处理”前后端各改几行代码内存占用就能降一个数量级。你先按这个方案把 demo 跑起来再拿一个 1GB 左右的文件压测一下看看内存曲线的前后对比会比任何理论都直观。