1. 先把问题摆正输入流是“一次性”的资源存放方式决定了后续所有操作写 Java 的 IO 代码绝大多数场景逃不出一个动作把 InputStream 里的数据读出来然后放到某个地方。这个动作实在太常见常见到很多人根本不把它当回事。我见过不少两三年经验的开发拿到 InputStream 顺手就是readAllBytes()接下来是转 String、转对象还是转文件完全看当时的心情。等线上出了内存溢出或者乱码问题再回头排查才发现问题恰恰出在“读完以后我把数据放哪了”这个最基础的环节。InputStream 不是一个可以反复读取的数据副本它更像一份“只能翻一次”的菜单。你调用一次read()数据就从底层来源文件、Socket、HTTP 响应体、数据库大字段进入内存而且指针只会往前移动。读过了就是读过了绝大多数流都不支持回头再读。这个“一次性”属性决定了数据从进入你代码的那一刻起你就必须决定它的去向——放进内存、落成文件、写进数据库还是丢弃。“先放着待会儿再说”这个选项在输入流这里是不存在的。1.1 为什么“读完就没了”会直接影响存放策略FileInputStream 读完了之后大不了重新打开文件再读。但底层来源如果是 HTTP 请求的ServletInputStream、Socket 里的网络流、压缩包里的一个条目流那读完就真的没了重新获取的成本极高。尤其是网络流读一半断掉重连、重传都是额外开销。所以动手read()之前必须先拿定主意这份数据是要短期使用的内存对象还是要长期保存的资产或者只是中转一下、转发给下一个消费者。这一步想不清楚后面全是补救操作。举一个我实际遇到过的例子有人写文件下载功能先把整个输入流readAllBytes()读成一个超大byte[]再把这个数组写到本地磁盘。数据进内存一份ByteArrayOutputStream里又缓存一份toByteArray()再复制一份内存占用瞬间翻好几倍。高峰期一来GC 都来不及回收直接 OOM。如果一开始就决定“数据要落盘”完全没必要让整份文件在内存里过夜边读边写才是正解。1.2 存放方式选错会引发哪些连锁问题我把这些年见过的“存放方式选错”导致的问题列一个清单后面几章会逐一展开内存溢出把 1GB 的文件直接readAllBytes()堆内存直接被打爆。乱码字节流没指定字符集就new String(...)本地正常、线上乱码。数据截断只调用一次read(buffer)以为读满了实际只读了一部分后面数据全丢。文件句柄泄漏流没关闭Windows 上文件被占用删不掉Linux 上文件描述符被耗尽。无法重复消费同一个流既想算 MD5 又想落库读一次就没了第二遍拿不到数据。这些问题看起来五花八门根源其实都指向同一个决策点读取输入流之后到底用什么形态、放在哪里。想清楚这一点至少能避开 70% 的 IO 故障。2. 四种主流存放方式字节数组、字符串、文件落盘、数据库 BLOB存放方式没有绝对的好坏只有合不合适。我按“内存消耗、可重复读、适用规模”三个维度把最常用的四种方式拆开讲每一条都会给出可以直接用的代码。2.1 字节数组最直接的存放但有两个细节必须知道把输入流读成byte[]是入门级操作但入门写法里藏着不少坑。第一个细节不要用ListByte一个一个地存装箱开销大得离谱对象头加引用一个字节能占 16 个字节以上。老老实实byte[]。第二个细节readAllBytes()虽方便但没有任何大小限制。// Java 9 提供适合数据量可控且明确较小的场景 byte[] data inputStream.readAllBytes();如果你不确定来源大小或者数据可能增长建议自己写一个循环读取public byte[] readAll(InputStream in) throws IOException { // 初始容量设置为 32 或 available() 的提示值减少扩容次数 ByteArrayOutputStream out new ByteArrayOutputStream(Math.max(32, in.available())); byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } return out.toByteArray(); }这里有个容易被忽略的细节in.available()只表示当前不阻塞情况下可读的字节数对网络流来说常常返回 0完全不能代表真实大小所以它只能用来给ByteArrayOutputStream一个初始容量提示不能拿它判断文件大小或分配缓冲区。还有一个隐蔽的内存点ByteArrayOutputStream.toByteArray()会再复制一份数据。也就是说读完 100MB 的数据内存里实际可能同时存在两份 100MB 的数组。大流量场景下这个翻倍不可忽视。2.2 字符串字符集不显式声明就是在给自己埋雷把字节流转成字符串是文本类数据最常用的存放方式。但我看过太多“反面教材”// 反面教材依赖平台默认字符集Windows 上可能走 GBKLinux 上可能走 UTF-8 String text new String(in.readAllBytes());这段代码在本地开发环境跑得好好的部署到 Linux 服务器就乱码原因就是开发机和服务器默认字符集不同。正确写法永远是显式指定字符集String text new String(in.readAllBytes(), StandardCharsets.UTF_8);如果是按行处理文本建议直接用BufferedReader包装逐行读取并存放避免整份文本一次性进内存try (BufferedReader reader new BufferedReader( new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { // 每一行单独处理或存放 } }存放成 String 还有一个内存层面的考量Java 8 及以前String 底层是char[]一个中文字符占 2 字节再加上对象头、哈希缓存等开销实际内存占用比原始字节高不少。Java 9 之后引入了压缩字符串单字节编码场景会好一些但如果你要处理的是上百 MB 的日志或大报文尽量不要整份转成 String 驻留内存按行处理或者直接落盘更稳妥。2.3 文件落盘应对大流量输入的正解当输入流的数据量不确定、可能很大时文件是最可靠的存放介质。Files.copy是最简洁的方案Path target Paths.get(/data/tmp/, fileName .part); try (InputStream in source) { Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); }Files.copy(InputStream, Path)内部会帮你循环拷贝JDK 实现里用的是 8KB 缓冲区效率有保障。如果你需要自己控制缓冲或者想在拷贝过程中顺带做点事情比如计算摘要、统计进度那就手动写try (InputStream in source; FileOutputStream out new FileOutputStream(target.toFile())) { byte[] buf new byte[8192]; int len; while ((len in.read(buf)) ! -1) { out.write(buf, 0, len); } }为什么缓冲区选 8192 而不是更小或更大太小会导致系统调用频繁太大对吞吐提升有限而且 8KB 到 64KB 范围内性能差异并不大。如果你在做高吞吐场景可以调到 32KB 到 64KB 试试配合BufferedInputStream效果更明显。落盘最大的好处是文件可以反复读取用new FileInputStream(path)想读几次读几次内存压力始终可控读完之后删除文件即可。大文件下载、导出报表、日志归档这类场景我都优先考虑落盘。2.4 数据库 BLOB结构化系统里的最终归宿如果数据最终要和业务记录绑定存储比如电商系统的订单附件、商城的资质文件、用户的头像那存放的终点往往是数据库表的一个 BLOB 列。这里的关键技巧是PreparedStatement.setBinaryStream()可以直接把输入流交给 JDBC 驱动不需要先在内存里读成byte[]省一次大对象的堆内存占用。PreparedStatement ps conn.prepareStatement( insert into t_attachment(owner_id, file_name, file_content) values (?, ?, ?)); ps.setLong(1, ownerId); ps.setString(2, fileName); ps.setBinaryStream(3, in); ps.executeUpdate();读取时反过来直接从 ResultSet 拿流try (ResultSet rs ps.executeQuery()) { if (rs.next()) { try (InputStream blobIn rs.getBinaryStream(file_content)) { Files.copy(blobIn, Paths.get(savePath), StandardCopyOption.REPLACE_EXISTING); } } }这里有一个非常实战的坑JDBC 驱动在executeUpdate()执行时才会真正消费传入的 InputStream所以同一个流不能被“先用一次再入库”。比如你想先算一下文件的 MD5再把文件写库如果只持有同一个流算完摘要流已经到底了入库时写入的就是一个空文件。处理办法有两个要么把流先落盘成临时文件再从临时文件开两个流分别做校验和入库要么先把数据读成byte[]摘要和入库都用这个数组来操作。小文件用后者简单大文件用前者稳妥。2.5 四种存放方式横向对比存放方式适用规模内存消耗可重复读典型场景字节数组MB 级以下高整份驻留内存数组可多次遍历接口报文、小文件、加解密字符串小到中型文本高比 byte[] 额外多 char[] 开销可多次处理JSON 解析、文本处理文件落盘任意大小低流式写盘可反复重开大文件下载、导出、日志数据库 BLOB中大型中取决于驱动实现通过 SQL 反复读取业务附件、合规存档数据库存放也有自己的边界超大文件几百 MB 以上直接塞 BLOB 会让数据库表膨胀、备份变慢业界常见做法是文件放对象存储或本地磁盘数据库只存路径和元数据。所以选择存放方式本质上是根据数据体量和消费方式来权衡。3. 真实业务场景里怎么选型接口报文、大文件下载、导入导出、接口防护方式讲完了关键还是落到真实业务里怎么选。这一章我用几个高频场景来演示决策过程。3.1 小响应体和接口报文体字节数组为主用 HTTP 客户端调第三方接口接收一个 JSON 响应这种数据量通常只有几 KB 到几十 KB直接readAllBytes()再转 String 完全没问题。判断标准很简单数据产生方可以保证体量上限吗如果接口文档写了响应上限 1MB那就放心读。如果接口文档没说上限但数据可能随着业务增长变大那就别赌。我见过一个真实事故一个导出接口刚开始返回 60KB 的 CSV代码用readAllBytes()接跑了一年多没事。后来业务量涨了一次导出 1.2GB当天接口所在服务直接 OOM运维半夜打电话。这种“刚开始没问题、后来越来越大”的场景从一开始就应该按落盘设计而不是按小报文设计。经验法则凡是数据量会随业务增长变化的输入一律走流式落盘。3.2 多商户商城导出与导入临时文件加流式处理结合 Spring Boot MyBatis 这类常见的多商户商城项目商品导入导出是绕不开的场景。导入场景里用户上传一个 .xlsx 文件MultipartFile.getInputStream()拿到一个输入流。如果直接让 POI 或 EasyExcel 从流去解析数据量一大框架本身也会缓存大量数据在内存里。我把上传文件先落盘到临时目录再让解析框架读文件这样内存压力最小PostMapping(/import) public String importProducts(MultipartFile file) throws IOException { Path temp Files.createTempFile(import-, .xlsx); try (InputStream in file.getInputStream()) { Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); } try { // 用 EasyExcel 或 POI 读取 temp 文件逐行处理 } finally { Files.deleteIfExists(temp); } }注意deleteOnExit()并不可靠它只在 JVM 正常退出时才执行服务常驻进程里等于没删。临时文件用完必须主动清理最好在finally或 try-with-resources 里删除。导出场景正好反过来MyBatis 查出数据用流式写响应输出流。这里的方向是“输出”但核心思想一致——不要让数据在内存里堆积。用response.getOutputStream()边查边写配合 MyBatis 的游标查询几万行数据导出也不会内存飙升。3.3 附件上传和转发先落盘还是先入库要看下一个消费者是谁很多系统里附件上传进来之后不是说马上完事而是要丢进消息队列由另一个服务异步处理。这种场景下如果你只把数据存在内存里的一个byte[]消息消费者拿到的是什么要么你序列化这个 byte[]要么把文件存到共享存储消费者自己去读。实践里最稳的做法是先落盘到本地或对象存储消息里只带路径。这样消费者可以反复读取、失败重试不用依赖内存里的临时对象。一句话总结选型逻辑存放方式要考虑“谁会在什么时间以什么方式再来消费这份数据”。如果只有一个消费者且立即消费内存就行如果有多个消费者、延迟消费、可能重试落盘或入库更合适。3.4 接口防护存放容量本身就是一道防线很多接口要防爬虫、防恶意请求其中一种攻击方式就是向服务端发送超大的请求体。如果一个 POST 接口的 Controller 方法直接readAllBytes()攻击者发一个 2GB 的 body你的内存立刻告急。这种问题不完全靠业务代码解决Web 容器层面可以做限制比如 Tomcat 的maxPostSize、maxSwallowSize参数代码层面也应该对读取长度做保护。下面这个工具方法就是“读入的同时限制总量”public byte[] readWithLimit(InputStream in, long maxBytes) throws IOException { ByteArrayOutputStream out new ByteArrayOutputStream(); byte[] buf new byte[8192]; long total 0; int len; while ((len in.read(buf)) ! -1) { total len; if (total maxBytes) { throw new IOException(input exceeds limit: maxBytes bytes); } out.write(buf, 0, len); } return out.toByteArray(); }这段代码的意义不仅是防内存溢出还把一个不确定大小的输入变成“有上限的输入”。只要读入动作有上限、有边界后续的存放方式就可控了。4. 高频故障排查内存、乱码、截断、句柄泄漏讲完选型这篇内容最有价值的部分来了真实故障怎么排查。我按四条典型的报错链路来复盘读者可以照着这个思路去排查自己的问题。4.1 内存溢出从报错到定位根因现象是OutOfMemoryError: Java heap space堆转储之后看支配树发现有个巨大的byte[]线程栈指向某个下载接口。这种问题排查链路其实很固定先 dump 堆找最大的对象再顺着线程栈找到调用点最后回看代码果然是readAllBytes()一把梭。修复方案分两种如果接口目的是把数据传给客户端那就不要读进内存再写直接用InputStream到OutputStream的管道式转发边读边写如果数据必须落盘就用第 2 章的文件落盘方式让数据在磁盘上流转。真正常见的坑是“读到内存里中转一下”在某些人看来这是“最省事的写法”但在大文件场景里这就是 OOM 的根源。4.2 乱码问题九成是字符集一成是 BOM乱码问题我印象最深的一次同事用new String(fileBytes)解析一份 UTF-8 编码的配置文件本地 Windows 上测试一切正常部署到 Linux 服务器后全部变成乱码。原因就一句话Windows 中文环境默认字符集是 GBKLinux 默认是 UTF-8不显式指定字符集就是看天吃饭。排查乱码的正确顺序是先检查代码读取时有没有显式指定字符集没有就改再确认数据源本身的字符集HTTP 头里的Content-Type带不带 charset最后看有没有 BOM 干扰。BOM 是个隐蔽问题。UTF-8 文件如果带 BOM开头会有三个字节EF BB BF读入转成字符串后会变成一个\uFEFF字符。处理 JSON 时它会导致解析失败处理 CSV 时第一列表头会莫名多出一个字符。如果代码里用Files.readString(path)JDK 不会帮你剥 BOM需要自己跳过或者用BOMInputStream这类工具包装一层。4.3 读取不完整为什么循环 read 才是唯一可靠写法InputStream.read(byte[])的契约很容易被误解它返回 -1 表示读到了流的末尾返回 n 表示“实际读到的字节数”n 不一定等于缓冲区长度。本地读取小文件时往往一次就装满缓冲区这给了很多人“一次读一定能读满”的错觉。到了网络流场景一次read()可能只返回几十字节甚至 0 字节这段数据写完剩下的就丢了。所以读取输入流的完整写法永远是循环int len; while ((len in.read(buf)) ! -1) { // 处理 buf[0] 到 buf[len-1] }Java 11 之后还有一个readNBytes(int len)它最多读取指定长度的字节数但也不保证填满适合“一次只取一部分”的场景。比如读 HTTP chunked 分块数据用readNBytes(1024)按块处理逻辑比手动循环清晰一些。但无论如何不要写一次性read(buf)就以为读完了。4.4 流未关闭文件占用和文件描述符耗尽流没关闭的故障往往不是立刻爆发的而是慢慢积累。Windows 上最常见的表现是“文件被另一个进程占用删不掉”Linux 上则是文件描述符耗尽报Too many open files。这类问题的排查可以用lsof -p pid看进程打开了哪些文件如果大量文件被同一个 Java 进程持有且没有关闭迹象基本就是代码里有流泄漏。正确的关闭姿势是 try-with-resources它保证close()一定会被调用即使中途抛出异常try (InputStream in new FileInputStream(path); OutputStream out new FileOutputStream(dest)) { // 业务处理 }如果你在finally里手动 close注意close()本身也可能抛异常一个粗心的finally里漏了 try-catch照样会导致后面的关闭代码不执行。能用 try-with-resources 就不要手写 finally。5. 完整性与一致性数据不是“读到了”就结束了还要保证没读偏、没读漏存放方式解决的是“放哪里”但还有一个容易忽略的问题你怎么确定这份数据是完整的这个问题的答案也直接影响存放动作怎么做。尤其是热搜词里反复出现的“数据一致性”在输入流场景下就是要保证“落地的内容和上游发出的内容完全一致且不会有半截文件污染正式数据”。5.1 Content-Length 不能盲目信任HTTP 响应场景响应头里的Content-Length是个参考值但你不能盲信。有些服务器用的是 chunked 编码根本不返回 Content-Length有些服务器网络异常时连接断开实际传输内容比 Content-Length 少。读取的时候自己累计长度然后和声明值比对是最基本的完整性检查long expected response.headers().contentLength().orElse(-1L); long actual 0; byte[] buf new byte[8192]; int len; while ((len in.read(buf)) ! -1) { actual len; // 边读边落盘 } if (expected 0 actual ! expected) { throw new IOException(content length mismatch, expected expected , got actual); }长度不一致时已经写了一半的临时文件不能留着当正式文件用必须删除或者标记为不完整重新拉取覆盖。这正是“存放方式要支持重试”的原因。5.2 校验和比对SHA-256 是最后防线长度一致不代表内容没被篡改或损坏。重要文件的接收方通常会对比内容的摘要值比如上游给一个 SHA-256你本地算一个两个一致才认为数据传输完整。计算摘要可以边读边算不需要读两份MessageDigest md MessageDigest.getInstance(SHA-256); try (InputStream in source; OutputStream out new FileOutputStream(target.toFile())) { byte[] buf new byte[8192]; int len; while ((len in.read(buf)) ! -1) { md.update(buf, 0, len); out.write(buf, 0, len); } } String actualDigest HexFormat.of().formatHex(md.digest());注意这里要“先落临时文件、校验通过再改名成正式文件”不要直接写到正式路径。等校验通过了一个Files.move()原子替换正式文件才对外可见。这和生产环境里“先写临时表、提交事务后再更新正式表”是同一个套路。5.3 读一半失败怎么办重试要有上限动作要可覆盖输入流在读取途中有可能抛出IOException这时已经写入本地临时文件的数据只覆盖了一部分。重试的逻辑不能是“在原文件后面追加”因为流中断后剩下的内容不能保证接着上次的位置是完整的最好整份覆盖重写。重试还必须有上限和退避策略否则上游网络一抖动你的线程会全部卡在重试循环里。一个基本做法最多重试 3 次每次间隔按指数递增1 秒、2 秒、4 秒。如果重试仍然失败保留临时文件方便事后排查但不要把它当成正式数据入库。落库场景同理先写临时表或者带状态标识的记录全部数据确认完整后再把状态置为成功避免数据库里存着“只写了一半”的 BLOB。6. 我的实操习惯每次写读取代码前先回答三个问题我现在的编码习惯是这样的凡是遇到读取输入流的代码动笔之前先花一分钟回答三个问题——这份数据最大可能是多大读完以后谁会来消费它如果读到一半失败了重试和清理怎么做想清楚这三个问题存放方式基本自己就浮出来了。第三个问题最容易被新手忽略但它偏偏最重要。落盘方案天然支持重试、支持清理内存方案一旦失败只能整个重来。这也是为什么我处理不明确大小的输入时宁可多写十几行代码先落临时文件也不愿意贪图readAllBytes()的一行便利。每次看到一行的readAllBytes()我都会下意识追问一句这个流的数据量有上限吗如果没有那一行代码就是一颗定时炸弹。把存放方式当作一个独立的决策来做而不是写完读取代码之后顺带补的一步你的 IO 代码会稳很多。这个习惯帮我避开了至少三次线上事故也分享给正在读这篇文章的你。
阅读完成 · 觉得有帮助?