1. ByteArray越界:先搞清楚是哪种边界破了
1.1 一段让人误判的异常现场
前段时间在处理某个老系统的文件上传功能时,控制台反复出现java.lang.ArrayIndexOutOfBoundsException。按照惯性思维,越界就是数组下标写错了,赶紧去查代码里的循环和索引运算,结果翻了一圈都没找到任何可疑的下标操作。一直到凌晨做堆转储分析,才发现异常路径底层站着的其实是ByteArrayOutputStream.toByteArray()。
那一瞬间问题就清楚了:这不是逻辑意义上的数组越界,而是把上传文件整个塞进byte[],直接把堆内存和数组长度的边界撑破了。服务端日志里看到的是ArrayIndexOutOfBoundsException,实际上内存后台早就连续报过OutOfMemoryError: Java heap space。这类问题在 Spring 上传场景里非常典型,尤其是当用户上传压缩包、安装包、设计稿原型之类的大文件时,代码里一个很常见的file.getBytes()或者IOUtils.toByteArray(file.getInputStream())就会让 JVM 瞬间背上巨大的内存压力。
1.2 数组长度上限和堆内存是两道硬边界
很多人忽略了一个底层事实:Java 数组的长度上限受int类型约束,最大也就是Integer.MAX_VALUE - 8,换算过来大概是 2GB 出头。这不是你代码写得对不对的问题,是 JVM 规定死的物理边界。一旦文件接近或者超过这个量级,连数组都分配不出来,必然报越界或者分配失败。
即便文件只有几百 MB,离 2GB 很远,内存边界同样会拦住你。这里有个非常反直觉的坑:MultipartFile.getBytes()看着只返回一份数据,但文件从请求体进入服务进程时已经被框架解析过一轮,原文件占用的临时缓冲还留在那儿,你这一调用又复制了一份字节数组,如果中间还有工具库做二次转换,峰值内存可能就是文件体积的三四倍。举个类比,这就好比快递柜容量是固定的,你非要一次性把整座仓库的货物全塞进去,不爆才奇怪。流式上传每次只搬运一小块,缓冲区只有几十 KB,对 JVM 来说几乎没有存在感。
2. 流式上传方案:为什么比 byte[] 可靠
2.1 什么时候必须放弃 byte[]
我的经验是,别等出问题再换方案,应该先判断场景。表格里的情况只要命中两条,就该把上传链路改成流式:
| 场景特征 | byte[] 方式 | 流式上传方式 |
|---|---|---|
| 文件超过 100MB | 内存吃紧,GC 频繁 | 稳定,内存不受文件大小影响 |
| 并发上传多 | 峰值内存被叠加放大 | 每个请求只占固定缓冲区 |
| 需要滚动校验(MD5、分片等) | 必须全量读完才能算 | 边读边算,读完即校验 |
| 运维对 OOM 零容忍 | 风险较高 | 可预测、可监控 |
| 文件在 1MB 以内 | 完全没问题 | 稍显麻烦,但也不慢 |
如果你的系统只是内部工具,文件基本不会超过几 MB,继续用byte[]完全合理,别为了追求架构上的“正确”而盲目重构。可一旦面对外部用户上传,谁知道他们会扔一个多大的源工程或者视频素材上来?为了系统不崩,还是老老实实走流式。
2.2 选型对照:先想清楚要传到哪里
流式上传不是说非要用某个特定类,核心是整条链路不出现“完整 byte[]”。Spring Web 里的MultipartFile本身就暴露了InputStream,这就是官方留给我们的流式通道。目标位置不同,选型也有讲究:
- 传到本地磁盘:
MultipartFile.getInputStream()配合FileOutputStream或者干脆用transferTo()。 - 传到对象存储:直接调用存储 SDK 的
InputStream上传接口,不要先转 byte[] 再传。 - 传到另一个外部接口:用
InputStreamResource包一层,作为请求 Body 继续转发。 - 消息队列和数据库字段:这类下游一般不适合放大文件,正确做法是先落盘或对象存储,再把引用路径发给下游。
这里有一个容易被忽略的设计点:流式的价值不只是省内存,更重要的是让“文件巨大多大”这件事和“服务进程内存多大”解耦。服务进程只负责搬数据,不负责装数据。下游是磁盘、是对象存储还是另一个 HTTP 服务,都不会让 JVM 堆变成瓶颈。
2.3 整体设计原则:源头可控、中间落盘、下游可断
我习惯把一次上传拆成三段考虑。源头是客户端,如果文件太大,直接在前端做大小限制和分片提示,别等请求打到服务端才想起拒绝;中间是 Spring Boot 应用,解析 multipart 时让框架优先落磁盘临时目录,业务层再从临时文件里读 InputStream;下游则是最终保存位置,最好支持断点或者可重试,否则一个网络抖动就得全部重新来。
这三条原则落到代码上,就是行为可预期。特别是“中间落盘”,很多同事不明白为什么要把上传文件先放临时目录再搬一次,觉得多了一次 IO。实际上现在的 Web 容器解析 multipart 时本来就会根据大小决定放内存还是磁盘,我们显式提高“多少字节以下进内存”的阈值,宁愿让框架从一开始就写临时文件,也不要在进程堆里囤积大对象。IO 慢一点没关系,OOM 是直接影响可用性的事故。
3. 落地实现:三套可直接抄的流式上传写法
3.1 正式动手前先确认解析器
Spring Boot 默认使用StandardServletMultipartResolver,一般不需要额外配置。真正需要注意的是老项目里可能引入过commons-fileupload的CommonsMultipartResolver,两套解析器并存时会行为混乱。如果确定走新方案,要把老解析器从配置里去掉,否则MultipartFile.getInputStream()拿到的流和底层临时文件生命周期可能对不上。
确认方法很简单,看配置里有没有commonsMultipartResolver这个 Bean,有就好好读一遍相关依赖是否真的需要。Spring Boot 的自动配置会优先使用我们自己声明的解析器 Bean,所以混用不是报错,而是行为不可预期,排查起来非常折腾。
3.2 基础写法:InputStream 到 OutputStream 的手动搬运
这是我推荐所有新人都能看懂、老项目改造最稳的一种写法。Controller 层接收MultipartFile,Service 层不调用getBytes(),全程用流:
@PostMapping("/upload") public String upload(@RequestParam("file") MultipartFile file, @RequestParam("dir") String dir) throws IOException { String targetPath = fileStorageService.storeByStream(file, dir); return "saved:" + targetPath; }public String storeByStream(MultipartFile file, String relativeDir) throws IOException { if (file.isEmpty()) { throw new IllegalArgumentException("上传内容为空"); } Path root = Path.of(baseDir).toAbsolutePath().normalize(); Path targetDir = root.resolve(relativeDir).normalize(); if (!targetDir.startsWith(root)) { throw new SecurityException("非法目录参数"); } Files.createDirectories(targetDir); String safeName = sanitizeFileName(file.getOriginalFilename()); Path target = targetDir.resolve(safeName); try (InputStream in = file.getInputStream(); OutputStream out = Files.newOutputStream(target)) { byte[] buffer = new byte[16 * 1024]; int read; while ((read = in.read(buffer)) != -1) { out.write(buffer, 0, read); } } return target.toString(); } private String sanitizeFileName(String original) { if (original == null || original.isBlank()) { return "unnamed_" + System.currentTimeMillis(); } String name = original.replaceAll("[\\\\/]", "_"); return name.startsWith(".") ? "file_" + name : name; }缓冲区大小我一般选 16KB,兼顾性能和内存占用。试过 64KB,大文件时吞吐量略有提升,但内存占用提升四倍,在并发场景里不划算。要记住这个缓冲区只是冰山一角的水杯,不是一次性灌满整个泳池,所以没必要贪大。
3.3 托管写法:transferTo 让容器替你搬文件
如果目标就是本地磁盘,Spring 的MultipartFile还提供了transferTo()。这个方法对我来说最省心,因为某些实现里,如果框架已经把上传内容解析到了临时文件,它可以尝试文件系统层面的移动操作,而不是逐字节 IO 复制,性能体验会更好。
public void storeByTransfer(MultipartFile file, Path targetPath) throws IOException { Path root = Path.of(baseDir).toAbsolutePath().normalize(); Path resolved = targetPath.toAbsolutePath().normalize(); if (!resolved.startsWith(root)) { throw new SecurityException("非法目标路径"); } Files.createDirectories(resolved.getParent()); file.transferTo(resolved); }有一个坑必须提醒:transferTo依赖底层容器的实现,临时目录和目标目录如果跨了文件系统,移动操作可能失败并退化为普通复制,极端情况会抛异常。所以生产环境最好把容器 multipart 临时目录和目标存储目录放在同一个文件系统内,比如都用/data/upload/tmp和/data/upload/files,根目录上层共享同一磁盘。
另外一个更容易踩的坑是目标文件如果已存在,不同容器的实现行为不同,有的会覆盖,有的直接抛错。建议在上传前先做业务层面的幂等判断,别把文件覆盖这种事交给底层的“不可预期行为”。
3.4 隐蔽陷阱:校验逻辑把流又变回 byte[]
很多人改造到一半,发现核心上传已经是流式了,但内存还是暴涨。排查到最后,问题往往出在校验环节。比如要算文件的 MD5,第一反应就是DigestUtils.md5Digest(file.getBytes()),这一下又把整个文件拉回内存。
正确做法是给 InputStream 包一层摘要流:
public String md5FromStream(MultipartFile file) throws IOException { MessageDigest digest = MessageDigest.getInstance("MD5"); try (InputStream in = file.getInputStream(); DigestInputStream dis = new DigestInputStream(in, digest)) { byte[] buffer = new byte[4096]; while (dis.read(buffer) != -1) { // 只管读,摘要会自动累加 } byte[] md5 = digest.digest(); StringBuilder sb = new StringBuilder(); for (byte b : md5) { sb.append(String.format("%02x", b)); } return sb.toString(); } }图片尺寸校验、文件类型嗅探、敏感词扫描也全部要基于流来做。判断文件头几个字节用流读前几十个字节就够了,千万别一上来就getBytes()全量加载。我见过一个项目,上传代码改好了,文件类型判断用的是 POI 的WorkbookFactory.create(file.getInputStream()),内部会把整个 Excel 读进内存,大表格一传就挂。这种隐藏在工具库里的全量加载,比显式调用getBytes()更难发现。
3.5 参数配置:三个字节相关的配置别再拍脑袋写
Spring Boot 的 multipart 配置有三个关键参数,很多人只改max-file-size,漏掉另外两个:
spring: servlet: multipart: enabled: true file-size-threshold: 2MB max-file-size: 2GB max-request-size: 3GB location: /data/upload/tmpfile-size-threshold表示文件大小低于这个值时会暂存在内存中,高于则直接写入临时文件。默认值可能让你以为解析后一定在内存里,实际上生产环境最好显式调到 2MB 左右,让框架从源头就别把大文件放进堆。max-file-size是单个文件上限,max-request-size是整个请求大小上限。如果一个请求里同时传多个文件,后者的值必须大于“所有文件总大小加上表单字段的开销”,否则单文件没超限,请求整体反而先被拦了。
location值得单独说。Spring Boot 如果不配置,底层容器会使用系统默认的临时目录,比如某些 Linux 下的/tmp。问题在于/tmp会被系统定时清理,上传了一半的临时文件可能突然被删除,容易触发各种奇怪异常。最好改成固定业务目录,并且保证该目录有充足磁盘空间。
4. 绕过 ByteArray 边界:分片上传与合并策略
4.1 为什么要分片
就算后端完全流式化,还有一个绕不开的现实:网关、容器、网络链路经常有单请求体大小限制。与其跟这些限制对抗,不如让客户端把文件切成若干分片,每个分片控制在 5MB 到 20MB 之间,这样每个请求都轻量,失败重试成本也低。
分片上传对“ByteArray 越界”问题的意义是双重的:一是每个分片天然不大,服务端完全可以在内存里处理;二是即便某个分片上传失败,不需要重传整个文件。我经历过一次 1.5GB 的压缩包上传,中途断了三次,没有分片就只能反复折磨服务器和用户。分片以后,最多就是重传某一片,体验差距明显。
4.2 合并文件时也要流式
分片上传的最后一步是合并。合并逻辑也很容易写坏:有人喜欢先把所有分片读出来,塞进一个ByteArrayOutputStream,再一次性写目标文件。这种做法把分片省下来的内存又还了回去,等于自己骗自己。
正确的合并姿势是逐片打开 InputStream,逐一写入同一个 OutputStream,并且边写边校验整体校验值:
public void mergeChunks(List<Path> chunkPaths, Path targetFile, String expectedMd5) throws IOException { MessageDigest totalDigest = MessageDigest.getInstance("MD5"); try (OutputStream out = Files.newOutputStream(targetFile)) { for (Path chunk : chunkPaths) { try (InputStream in = Files.newInputStream(chunk); DigestInputStream dis = new DigestInputStream(in, totalDigest)) { byte[] buffer = new byte[16 * 1024]; int len; while ((len = dis.read(buffer)) != -1) { out.write(buffer, 0, len); } } } } String actualMd5 = toHex(totalDigest.digest()); if (!actualMd5.equalsIgnoreCase(expectedMd5)) { Files.deleteIfExists(targetFile); throw new IllegalStateException("合并后校验值不匹配"); } }这段代码的核心要点是:任意时刻内存里只有一份 16KB 的缓冲区,整个合并过程无论文件多大,堆内存占用都是恒定的。校验值也直接复用同一个 DigestInputStream,读完就出结果,不需要额外把文件再整个读一遍。
4.3 秒传与断点续传的取舍
分片之后很自然地会想做秒传和断点续传。秒传的做法是在上传前先计算完整文件的 MD5,服务端查一下是否已经存在,存在就直接关联业务数据,不真正接收文件。断点续传则是让客户端记录哪些分片已经上传成功,再次上传时只补剩余分片。
这两个功能不是免费的,需要额外的分片状态表和存储空间管理。如果业务场景里大文件上传不是高频操作,建议第一阶段只做分片和重试,不要一上来就铺张做秒传。欠的账迟早要还,但没必要一次性全还。
5. 实战排查:遇到这些坑该怎么定位
5.1 常见问题速查表
| 现象 | 直接原因 | 处理动作 |
|---|---|---|
文件一传就ArrayIndexOutOfBoundsException | 业务代码调用了getBytes()或toByteArray() | 搜代码库里的getBytes()、IOUtils.toByteArray,改成流式 |
| 内存缓慢上涨最终 OOM | 校验、缩略图、解析库内部全量读文件 | 用堆转储看大对象是谁,重点排查工具库 |
transferTo抛IllegalStateException | 目标文件已存在,或跨文件系统 | 做幂等检查,临时目录和目标目录放同一文件系统 |
| 上传后临时文件找不到 | 系统/tmp被清理 | 显式配置 multipart.location 到固定目录 |
| 单文件没超限但请求被拒 | max-request-size小于文件总大小 | 将 request size 调大,多文件场景按总和算 |
| 上传很慢且 CPU 高 | 小 Buffer 频繁系统调用,或在大循环里加密压缩 | 适当调大 Buffer,性能测试后定值 |
5.2 怎么快速定位是哪一行把 byte[] 拉爆的
遇到上传场景内存暴涨,第一步是别猜,用数据说话。Linux 下先看jmap -histo或者上一个任务的mat堆转储分析,重点看两个东西:一是byte[]实例数量和占用大小,二是调用栈里有没有工具类的“ReadAllBytes”“ToByteArray”“CopyLarge”之类的方法名。
在实际项目中,堆转储里往往能看到几十个巨大的byte[],大小刚好和某些上传文件体积对应。这时候去反查这些大数组的保留链,基本就能定位到代码位置。这种方式比肉眼 review 高效得多,因为很多人写代码时根本没意识到某个方法是全量读取。
还有一个非常实用的排查技巧:在测试环境把堆内存调小,比如-Xmx128m,然后上传一个 50MB 的文件。如果系统立刻打回原形,说明链路里一定有一个吃内存的胖子。修好后,同样条件下跑 100MB、500MB 的文件,内存曲线应该是一条接近直线的低频波动。
5.3 临时文件的完整生命周期管理
临时文件不是上传完就没责任了。Spring 容器解析 multipart 后,临时文件一般会在请求结束时被自动删除,但如果你把文件流交给异步线程继续处理,请求结束可能就把临时文件删了,导致异步任务读到一半文件消失。解决方式有两种:一是异步任务里尽早把 InputStream 消费完,二是把临时文件移动到某个持久化目录后再交给异步处理。
我在一个上传转码服务里踩过这个坑。当时请求处理里把MultipartFile交给线程池,然后立即返回“处理中”,结果线程池里的转码任务拿到的File已经是空路径。后来改成在请求线程内先把文件转移到磁盘目录,再给异步任务一个路径字符串,问题才彻底消失。记住一个原则:MultipartFile的生命周期通常不超出请求方法,想跨线程处理数据,必须先完成落盘。
对象存储场景也有类似的坑。有些 SDK 的上传接口支持InputStream,看起来是流式,但如果它对InputStream做了整体读取,或者要求你先提供 contentLength,底层可能还是要先把流全部读完才能计算长度。这种情况下,先落盘、再以文件路径方式上传到对象存储,反而是更稳的做法,消耗的只是磁盘 IO,不是堆内存。
6. 踩坑几次后我给自己定下的三条铁律
第一,凡是上传接口,代码仓库里禁止出现file.getBytes()。这个要求可能有点绝对,但小文件场景用getInputStream()并不会带来额外复杂度,反而能让后续扩展大文件支持时少改很多代码。我见过太多“先图省事,后返工”的案例,与其事后从getBytes()一行行拆解,不如一开始就统一流式入口。
第二,任何流式处理都要配一个明确的超时和失败清理机制。流式上传把内存问题转移成了 IO 问题和生命周期问题,如果中途断开,InputStream 可能一直挂住,临时文件也可能残留。所以上传方法里尽量使用 try-with-resources,保证流一定关闭;同时加一个定时任务,清理超过两小时未完成合并的分片和临时文件。
第三,压测的时候一定要测多文件并发,不要只测单文件大文件。并发场景下的内存曲线才是真实曲线,因为每个请求就算只占 16KB 缓冲区,几百个请求同时涌进来也有几十 MB 的缓冲,加上框架本身的临时文件句柄、请求线程栈,压力会成倍放大。提前压测,能避免很多上线之后才爆出来的内存事故。
现在写上传接口,我基本不再担心“文件多大”这个问题,因为服务进程内根本不放大文件,真正要操心的是磁盘空间够不够、下游存储稳不稳、客户端网络好不好。把 ByteArray 越界问题从根上绕开之后,上传功能反而变成了整个链路里最没存在感的模块。