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[]是入门级操作,但入门写法里藏着不少坑。
第一个细节:不要用List<Byte>一个一个地存,装箱开销大得离谱,对象头加引用,一个字节能占 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 上可能走 GBK,Linux 上可能走 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 中文环境默认字符集是 GBK,Linux 默认是 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 代码会稳很多。这个习惯帮我避开了至少三次线上事故,也分享给正在读这篇文章的你。