平时接上传链路优化的需求十次里有八次都会遇到同一个组合Java插件/服务端模块、浏览器端大文件分片上传、内存占用。上个月刚处理完一个网盘类项目的案例Chrome标签页传一个2GB的文件传到一半直接卡死服务端Java进程的堆内存也一路猛涨频繁Full GC最后整个上传任务被OOM中断。排查下来发现问题既不在分片算法上也不在带宽上而是从浏览器到服务端的这条链路里有太多地方都在复制数据、堆积引用把两边内存都当成了临时仓库。这篇文章我就把这次优化的完整思路和实操步骤拆开来讲。如果你正在做Java后端上传模块或者前端处理大文件上传时被内存飙升困扰可以参考这条链路逐层做减法。文里所有的配置、代码、指标都是我实跑过的你拿来照着改方向基本不会跑偏。1. 内存到底烧在哪一条上传链路里的四个隐形大户1.1 浏览器端File.slice并不复制数据readAsArrayBuffer才是真凶很多前端同行会把内存问题归结为“文件太大浏览器放不下”这个说法其实不准确。文件在浏览器里是一个File对象它的真实数据大部分在磁盘上并不是常驻内存的。File.slice()生成分片时返回的Blob对象只是一个“区间引用”它知道自己在原文件里的起始位置和长度并不会真的把这一段数据拷贝进JS堆。既然切片不占大内存那内存是谁吃掉的是我见过的两种常见操作第一种是把分片读成ArrayBuffer再做后续处理const buffer await file.slice(start, start size).arrayBuffer(); // 接下来把 buffer 塞进 FormData 或 body这一步会强制浏览器把分片对应的磁盘数据完整读入JS堆。一个4MB的分片这里就多出4MB的新内存。如果同时有10个并发分片就是40MB的ArrayBuffer常驻。更怕的是再用FileReader.readAsDataURL()把分片转成base64字符串数据会膨胀三分之一而且JS字符串在V8内部按字符存储内存占用直接翻倍。一个2GB的文件如果被人用readAsDataURL整段读浏览器卡死一点不冤。1.2 并发分片上传队列里住着一排ArrayBuffer第二个容易被忽略的地方是并发窗口。许多人为了“跑满带宽”一上来就写const tasks chunks.map(chunk uploadChunk(chunk)); await Promise.all(tasks);如果文件被切成200个分片这句话会把200个上传请求同时发出去。且不说HTTP/1.1下同一域名通常只有6个并发连接后面的请求都在排队就说内存层面每个分片在被传输前如果都经过arrayBuffer()拆过一遍那么队列里可能同时躺着几十份分片数据。浏览器要对这些请求做body缓冲、网络发送缓存、失败重试保留堆积起来非常可观。这里还没有算上TCP层面的发送缓冲和浏览器内部的HTTP缓存。上传进度越慢排队的分片越多内存水位就越高最后表现为整个网页的滚动都开始掉帧甚至直接无响应。1.3 Java服务端MultipartFile.getBytes()是把分片整段拉进堆服务端的内存大户往往是同一个习惯拿到MultipartFile后不管是做大小校验、MD5还是落盘先调一声byte[] bytes file.getBytes();这个调用会把分片数据完整复制到JVM堆里。如果一个分片8MB同时有5个线程在处理就是40MB的byte[]。如果服务端的线程池还是无界的请求堆积后JVM里会同时存在大量等待处理的分片数据引用GC根本来不及回收。有人会说Spring Boot默认的StandardServletMultipartResolver不是会把大请求写到临时文件吗这是对的但那说的是Servlet容器解析multipart/form-data时超过阈值的数据会落到临时文件而不是说MultipartFile对象本身是懒加载的。一旦你调了getBytes()它就会把Part中的数据全部读进内存。很多上传接口收到一个几十MB的分片服务端JVM立刻跳一个内存尖峰几乎都是这一句代码造成的。1.4 合并阶段完整文件压缩成一个byte[]是最容易爆的场景最后一个大户在合并分片阶段。很多人把分片接收得挺好但合并时图省事写出类似这样的逻辑Listbyte[] all new ArrayList(); for (Path p : chunkFiles) { all.add(Files.readAllBytes(p)); } byte[] full new byte[totalSize]; // 再把所有 all 里的内容拷贝进 full如果文件是1GB这一步会先在List里累积1GB的byte[]再复制出另一个1GB的完整数组瞬间吃掉2GB堆内存。这在4G堆的机器上基本就是OOM的代名词。合并完再做一次Files.readAllBytes()算MD5又来一遍。可以说合并阶段是整条链路里“性价比”最高的优化点几乎不用费劲就能把峰值砍掉一半以上。2. 先定契约再动手分片大小、并发窗口与元数据协议内存问题不是靠某一端单独能解决的。我的习惯是先把前后端之间的“上传契约”定下来让两端都按同一个内存预算运行。这样后面所有代码才有据可依。2.1 分片大小为什么我建议选在2MB到8MB分片大小直接影响内存峰值的量级。选太小的分片比如256KB确实单分片内存很低但一个2GB文件要切8000片请求数量爆炸服务端连接数、IOPS、TCP拥塞控制都会成为瓶颈总的效率反而更差。选太大的分片比如64MB带宽利用率会好一些但任何一片失败后重试成本都很高瞬时内存尖峰也很难压住。在实际项目里默认给4MB起步弱网用户降到2MB内网或千兆带宽可以上调到8MB。这个区间的好处是单个分片在网络上的传输时间通常在0.5秒到2秒之间失败后重试成本可控内存峰值也很容易计算——并发3个分片每个4MB就是12MB的瞬时缓冲绝大多数机器都轻松承受。2.2 并发窗口浏览器端建议3到5服务端必须是“有界队列”并发窗口并不是越大越好。大文件上传的瓶颈通常是带宽而不是CPU无脑开10个并发反而会造成分片互相争抢带宽大量请求超时然后进入“超时-重试-再超时”的死亡循环。重试一次就多一份分片数据在内存里多存活一段时间。我这里推荐的方案是浏览器端并发窗口设为3到5具体数值由服务端通过配置下发服务端接收分片的线程池必须是有界的比如核心线程5个队列最多100个拒绝策略选择CallerRunsPolicy。这样即使浏览器端开了10个并发服务端也不会一次性接收10个分片进内存多出来的请求会在Tomcat连接层排队。连排队线程都有上限内存自然可控。2.3 前后端统一的分片元数据协议让分片上传可续传、可去重前后端至少要约定这几个字段字段含义说明uploadId上传会话ID服务端生成浏览器端持有用于标记同一文件chunkIndex分片序号从0开始服务端以此拼接顺序totalChunks总分片数服务端在合并前按这个数检查完整性fileName原始文件名合并后恢复真实文件名totalSize文件总大小做后端最终校验防止分片缺漏实际操作时我会在POST /upload/chunk的表单字段里带上前四个文件本体放在file字段。服务端收到分片后先检查uploadId chunkIndex是否已经存在如果已经存在且大小一致直接返回成功不做重复写入。这个“幂等检查”能省掉大量断点续传场景下的重复IO和内存开销。3. 浏览器端的内存减法别再把分片整块读进ArrayBuffer3.1 用Blob直接当请求体而不是arrayBuffer()之后再造一次前面说过File.slice()得到的是一个Blob引用最优雅的用法是直接把它作为请求体发出去让浏览器底层自己流式读取磁盘数据。async function uploadChunk(file, session, chunkIndex) { const start chunkIndex * session.chunkSize; const end Math.min(file.size, start session.chunkSize); const blob file.slice(start, end); const form new FormData(); form.append(uploadId, session.uploadId); form.append(chunkIndex, chunkIndex); form.append(file, blob, ${session.fileName}.part${chunkIndex}); const response await fetch(/upload/chunk, { method: POST, body: form }); if (!response.ok) { throw new Error(chunk ${chunkIndex} failed); } }注意这段代码从头到尾没有调用blob.arrayBuffer()。FormData里放Blob时浏览器知道它是一个可流式读取的数据源不会强制先在内存里生成一份完整拷贝。这是整个浏览器端内存优化的基础操作改掉这一个习惯内存曲线立刻就会降下来。3.2 用“队列并发上限”替代Promise.all有了流式发送还要管住并发。我会用一个固定worker数的消费队列来做async function runWithConcurrency(tasks, limit) { const queue tasks.slice(); const workers Array.from({ length: Math.min(limit, queue.length) }, async () { while (queue.length) { const task queue.shift(); await task(); } }); await Promise.all(workers); } const tasks []; for (let i 0; i session.totalChunks; i) { tasks.push(() uploadChunk(file, session, i)); } await runWithConcurrency(tasks, session.maxConcurrency);关键点在于queue.shift()之后当前分片的task一旦执行完函数里的blob、form、response局部变量都变成不可达对象下一次GC就能回收。而Promise.all一次性创建所有task的方式虽然代码短但所有task闭包都会持有blob引用直到全部任务完成大文件场景下内存几乎是线性上涨。3.3 让UI线程喘口气用Web Worker处理分片准备如果还要做分片MD5、进度统计、甚至断点续传的已传列表维护这些活不建议全部放在主线程。尤其是分片数量上千的时候主线程一直忙用户点页面上的按钮都会卡。我的做法是把“切片准备”放到Web Worker里Worker读取文件列表、计算分片起点、维护队列然后通过postMessage把单个分片的ArrayBuffer转移给主线程。这里有个容易被忽略的细节postMessage传ArrayBuffer时要用Transferable方式传也就是postMessage(buffer, [buffer])这样是所有权转移而不是深拷贝不会产生额外内存。如果只是做切片不读数据更简单直接让Worker把Blob的offset和size传给主线程主线程再去file.slice()因为Blob本身是引用几乎不占内存。4. Java服务端的内存减法流式落盘与分片合并技巧4.1 服务端接口从InputStream直接写临时分片Java侧的核心约束和浏览器端类似不要调getBytes()直接用getInputStream()流式处理。PostMapping(/upload/chunk) public ResponseEntityVoid uploadChunk( RequestParam(uploadId) String uploadId, RequestParam(chunkIndex) int chunkIndex, RequestParam(file) MultipartFile file) throws IOException { Path chunkDir uploadRoot.resolve(uploadId); Files.createDirectories(chunkDir); Path chunkFile chunkDir.resolve(chunk- chunkIndex); try (InputStream in file.getInputStream()) { Files.copy(in, chunkFile, StandardCopyOption.REPLACE_EXISTING); } return ResponseEntity.ok().build(); }这段代码的妙处在于Files.copy(InputStream, Path)内部会以8KB左右的缓冲区循环读取每读一小段就写一次磁盘整个分片数据不会在堆里形成连续大块。哪怕分片是64MB内存增量也只是几十KB的流缓冲。前提很简单千万别在调用链里提前执行file.getBytes()也不要为了校验大小先file.getInputStream().readNBytes((int) file.getSize())。如果你继承了老项目使用的是CommonsMultipartResolver那还要注意它的sizeThreshold参数。低于阈值的Part会驻留内存高于阈值才落临时文件。建议保持默认的2KB或者4KB阈值别故意调大阈值越大JVM堆里堆积的未落盘数据就越多。4.2 分片临时目录的生命周期与清理策略分片落盘后磁盘上的临时文件数量会非常大。如果不管理一次上传任务失败后分片文件就永远留在服务器上既吃磁盘又费内存目录扫描、元数据读入。我的做法是以uploadId为目录隔离分片并维护一个uploadSession表记录创建时间、总片数、已收到的片索引位图。再用一个定时任务清理超过比如2小时未合并的会话Component RequiredArgsConstructor public class UploadSessionCleaner { private final UploadSessionRepository sessionRepository; Scheduled(fixedDelay 30 * 60 * 1000L) public void cleanExpired() { ListUploadSession expired sessionRepository.findByStatusAndCreateTimeBefore( UploadStatus.UPLOADING, LocalDateTime.now().minusHours(2)); for (UploadSession session : expired) { Path dir uploadRoot.resolve(session.getUploadId()); FileUtils.deleteQuietly(dir.toFile()); sessionRepository.delete(session); } } }清理逻辑越早做越好。如果磁盘上躺了几天前的分片文件服务重启或full GC扫描时都会变慢。合并成功的会话也应立即删除分片目录只留最终文件。4.3 合并阶段用FileChannel把分片粘起来合并分片时最忌讳先加载到内存再写。正确姿势是用FileChannel.transferTo()做内核态数据传输让数据从分片文件直接搬运到目标文件不经过JVM堆。public void mergeChunks(String uploadId, int totalChunks, Path targetPath) throws IOException { Path chunkDir uploadRoot.resolve(uploadId); try (FileChannel out FileChannel.open(targetPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { for (int i 0; i totalChunks; i) { Path chunkPath chunkDir.resolve(chunk- i); try (FileChannel in FileChannel.open(chunkPath, StandardOpenOption.READ)) { long position 0; long size in.size(); while (position size) { long transferred in.transferTo(position, size - position, out); if (transferred 0) { throw new IOException(transferTo made no progress); } position transferred; } } } } }这里有一个必须记下的坑在Linux上transferTo对于超大文件可能出现单次只传输一部分字节的情况不能假设一次调用就全部传完。上面这个while循环就是处理这种情况的标准写法。如果只写一句in.transferTo(0, in.size(), out)2GB以上的文件合并结果可能会缺失数据这个问题当年坑了我一晚上。合并过程中的MD5校验同样可以用DigestInputStream包住文件输入流边读边算哈希而不是等合并完再Files.readAllBytes()。内存占用可以忽略不计。4.4 “配置下发”才是Java插件对浏览器端优化的真正价值标题里的“Java插件”在我的实践里指的是独立的上传模块/Starter。它做的最有价值的事情不是接收分片而是反哺配置给浏览器端让浏览器端在发起请求之前就知道该怎么约束自己的内存。我在这个项目里加了一个配置接口GetMapping(/upload/config) public UploadConfig getUploadConfig(RequestParam String fileName) { long size probeSize(fileName); return UploadConfig.builder() .chunkSize(size 1024L * 1024 * 1024 ? 4 * 1024 * 1024 : 8 * 1024 * 1024) .maxConcurrency(size 1024L * 1024 * 1024 ? 3 : 5) .maxRetries(3) .build(); }浏览器端在上传开始前先问一句拿到chunkSize和maxConcurrency后再构建任务队列。这样一来不同大小的文件自动匹配不同的内存预算小文件用大分片减少请求数大文件用小分片配合低并发保护内存。这是纯前端写死配置做不到的灵活性也是“Java插件”在这条链路里的正确定位。5. 实测与踩坑从频繁Full GC到平稳内存水位5.1 优化前的表现我在测试环境复现了一次完整流程机器是2核4GJVM最大堆内存设了2GB文件是一个1GB的测试数据包。优化前的代码毛病很典型浏览器端10并发每个分片16MB先arrayBuffer()再塞FormData服务端MultipartFile.getBytes()写入合并时Files.readAllBytes()汇总。结果不用猜浏览器标签页进程内存最高到1.1GB页面已经明显卡顿服务端JVM在接收分片阶段老年代就占了1.6GBFull GC平均几秒一次合并阶段直接抛了OutOfMemoryError: Java heap space。用jmap导了一份堆dump用MAT扫出来的结果也很直白byte[]实例占总堆内存的71%其次才是上传会话对象和HTTP请求对象。这说明内存几乎全部耗在“数据拷贝”上而不是业务逻辑上。5.2 优化后的数据改造完成后我跑了同尺寸文件浏览器端4MB分片并发3不调用arrayBuffer标签页进程内存稳定在300MB左右服务端流式落盘FileChannel合并JVM堆设置调到1GB老年代稳定在600MB上下全程几乎没有Full GC合并阶段内存尖峰从原来的2GB降到不足100MB多出来的主要是临时目录管理和元数据。整个上传耗时反而更短了因为分片超时和重试次数大幅下降。这说明内存优化和上传效率不是对立的很多时候它们指向同一个根因无节制地复制数据和并发堆积。5.3 三个没提前想到的坑第一弱网环境下的降级策略。我最初只做了并发限流但弱网下3个并发也一样会大量超时。前端把失败分片重新放入队首后失败分片引用的Blob一直不释放内存又慢慢涨回去了。后来改成同一分片失败3次就把整个上传流程降级为串行发送并且每次发起前显式清掉上一次的变量引用内存才稳下来。第二transferTo的循环问题。我在前面的代码里已经写了这里再强调一遍不要看到一次调用返回-1或0就认为结束了。写一个while循环确保所有字节都搬完。这个问题只在文件较大或者某些Linux文件系统上偶然出现没有准备的话会非常难排查。第三临时分片目录不能放/tmp。服务器重启后/tmp被系统清理用户上传到一半的会话突然全部失效再次续传时服务端发现分片缺失前端又得从头传。我把分片目录改到应用专属的upload/temp并挂到独立磁盘分区同时用会话表配合定时任务做生命周期管理这个问题彻底消失。6. 排查链路里最有用的三个工具动作6.1 Java侧Arthas看存活对象JFR看GC压力服务端内存异常时先用Arthas的dashboard命令看内存分布重点观察老年代增长速率和byte[]实例数量。如果想深挖可以加上JFR记录-XX:StartFlightRecordingfilenameupload.jfr,duration60s,settingsprofile60秒内复现一次上传就能从JFR的GC事件里看到每次Full GC之前是什么对象在膨胀。再配合MAT分析一份jmap -dump:live,formatb,fileheap.hprof的堆dump基本能一锤定音。我遇到的所有服务端上传内存问题最后都归结到byte[]还没跑出过这个范围。6.2 浏览器侧Chrome任务管理器加Performance面板浏览器端的内存问题我一般先看Chrome自带的任务管理器确认是哪个标签页进程在涨。确定是上传页后打开DevTools的Performance面板录制一次上传过程观察Memory栏的堆曲线。蓝线呈阶梯状不断抬高且不回落几乎可以断定有某个循环持有分片引用蓝线在任务完成时能回落到初始水位说明引用关系是健康的。另一个实用技巧是录制时勾选Memory选项配合Frames视图看页面是否有长时间未释放的JS内存。大文件上传卡顿多发生在GC频繁被触发、主线程忙于回收时看到Memory面板呈锯齿状不断陡升陡降就是并发分片瞬时堆积的典型图像。6.3 日志比指标更早暴露问题最后分享一个排查习惯在分片上传接口里打一条包含uploadId, chunkIndex, receiveSize, costMs的日志在合并接口里再打总耗时和最终文件大小。内存指标是滞后指标日志里的响应时间和分片大小异常才是先行信号。当你发现单分片接收耗时突然从500ms涨到5秒通常意味着JVM正在频繁GC这时候再去拉GC日志和堆dump排查路线会非常清晰。这些工具和方法我用过之后最大的体会是大文件上传的内存优化本质上是在管理“数据的副本数量”。后端少调一次getBytes()前端少调一次arrayBuffer()内存就能肉眼可见地降下来。你也不妨在自己的项目里照这个思路做一次压力测试改动不大但效果会很明显。
