接手过一个老项目的附件导入导出功能,里面清一色是FileInputStream读完再FileOutputStream写回。功能跑得通,但线上时不时冒出中文报表乱码、处理大清单卡到超时,甚至偶尔报Too many open files。我把里面所有和流相关的代码重新理了一遍,也算是把 Java 里常用的流知识整体梳理成了一张网。这篇就把我的理解完整记录下来:从流的本质和分类,到常用流怎么选型、装饰链怎么组合、NIO 时代有哪些更省心的替代,再到我实际踩过的坑和排查思路。适合刚接触 Java IO 的新人,也适合写过流代码但遇到问题还要靠搜索的开发者。
1. 先搞懂“流”到底是什么
1.1 数据管道类比
“流”这个名字听着抽象,其实就是一个数据管道。数据在 Java 程序和外部世界之间单向流动,从外部读进程序叫输入流,从程序写到外部叫输出流。可以把InputStream理解为自来水管:一端是文件、网络套接字或内存数组,另一端是程序,数据像水一样按顺序流过来,程序读多少就拿多少,没读到的还留在管子里等下次取。OutputStream则反过来,把程序里的数据排放出去。这种单向性解释了为什么同一个文件既要读又要写时,往往需要分别维护两个不同的流对象。
这个管道模型还有一个重要特点:数据是“串行”的。流不像集合那样可以随意跳跃访问某个位置,读过了就过去了,除非用支持随机访问的RandomAccessFile或者把整个文件映射成字节数组。所以大多数基于流的读写逻辑都是顺序处理,配合循环把数据一段段搬运完。理解了这一点,再看skip、mark、reset这些少用的方法时,就不会觉得神秘。
1.2 字节流与字符流的底层差异
Java IO 的抽象根上是四个类:InputStream、OutputStream处理字节,Reader、Writer处理字符。所有流类都是从这四个基类派生的。
字节流处理的是原始二进制数据,比如图片、音频、压缩包、协议报文,一次最少一个字节,完全不带编码概念。代码里读出来的每个值都是 0 到 255 的整数,拼接成什么含义完全由业务解释。字符流则面向人类可读文本,内部其实还是字节流,只是外面多了一层字符编码解码器:读取 UTF-8 编码的文件时,字符流负责把若干字节组合成一个 Unicode 字符,再转成 Java 里的char或String。
很多初学者问过同一个问题:“为什么FileReader读中文会乱码,换成new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)就好了?”答案就在这里。FileReader是InputStreamReader的简便子类,但它默认使用 JDK 启动时探测到的平台字符集,在 Windows 中文环境里通常是 GBK,于是 UTF-8 编码的中文文件被 GBK 解码,自然出现了乱码。字符流的本质是“字节流加编码规则”,这个观念越早建立,后面就越不容易被编码问题折磨。
1.3 为什么日常代码里总是层层包装
单独一个FileInputStream能力很有限:每次read()都可能触发一次系统调用,性能差;不支持按行读取;也不带缓冲。java.io解决这个问题的方式是“过滤器流”,也就是装饰器模式:用一个流包装另一个流,每包一层就叠加一层能力,而且层与层之间可以自由组合。
new BufferedInputStream(new FileInputStream(file))这样写,等于在原始文件流外面加了一个默认 8KB 的缓冲桶,数据不再是逐字节从磁盘搬,而是一次搬一大块进内存,再从内存逐字节用。new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))则是在字节流之上加了编码转换,再加了按行读取的能力。这个“套娃”设计是理解所有流组合的关键,也是面试里经常提到的装饰器模式在 JDK 中最典型的落地场景。
2. 日常开发中出场率最高的常用流盘点
2.1 基础字节流:文件的起点和终点
FileInputStream和FileOutputStream是最基础的节点流,直接对接文件系统。FileInputStream构造时要给定一个文件路径或File对象,文件不存在会抛FileNotFoundException;FileOutputStream默认覆盖目标文件,想追加内容就传第二个参数true。它们本身没有缓冲区,也不做任何编码处理,适合读写原始数据。
ByteArrayInputStream和ByteArrayOutputStream也是节点流,不过数据源不是磁盘而是内存里的字节数组。前者把一个byte[]包装成流,方便用流的方式读内存数据;后者在内存中维护一个可增长的字节数组,常用于程序内部需要临时缓存输出内容的场景,比如把图片字节拼到一块再统一写出。
2.2 缓冲流:不用白不用的默认选择
BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter是四个最常用的缓冲流。它们各自包在对应的普通流外面,默认缓冲区大小是 8192 字节。作用一句话:减少底层系统调用次数,把多次小读写合并成批量大读写。
举个例子,不包缓冲流时,每写一个字节到FileOutputStream都可能触发一次写磁盘操作,性能可想而知。包上BufferedOutputStream后,数据先攒到 8KB 缓冲区里,攒满了才真正写一次磁盘。很多性能问题不是算法问题,而是少了这一层缓冲包装。所以只要做文件读写,默认第一反应应该是加缓冲。
2.3 转换流:编码处理的唯一正确姿势
InputStreamReader和OutputStreamWriter是字节流和字符流之间的桥。它们接收一个字节流,再按指定字符集解码成字符流,或者把字符按指定字符集编码成字节流。关键点是构造时必须显式传Charset,我建议一律用StandardCharsets.UTF_8,不要依赖平台默认值。
这两个流在日常代码里很少单独出现,更多是作为BufferedReader或BufferedWriter的里层存在,比如读取一个 UTF-8 编码的日志文件,推荐组合是new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))。很多人记不住这么多层,其实可以用Files.newBufferedReader(path, charset)代替,效果一样,签名更清爽。
2.4 数据流与对象流:从“按字节搬运”到“按结构读写”
DataInputStream和DataOutputStream支持直接读写 Java 基本类型和字符串,比如writeInt、writeUTF、readDouble。它们按固定字节布局把数据写到流里,读的时候按同样的顺序取回,适合自定义二进制协议或紧凑格式存储。需要注意读写顺序必须严格一致,否则解析出来全是错的,而且很难排查。
ObjectInputStream和ObjectOutputStream做的是 Java 对象序列化:把实现了Serializable接口的对象整体写成一串字节,之后可以完整恢复。这个很方便,但坑也不少。对象里的serialVersionUID一旦随类结构变化而不同,反序列化会抛InvalidClassException;读回来的对象通过反射构造,绕过了构造方法,某些安全校验可能失效。所以序列化对象建议显式写死一个serialVersionUID,并谨慎对待类结构变更。
2.5 打印流:最被忽视的“输出流”
PrintStream和PrintWriter专门用来输出格式化文本,System.out就是一个PrintStream。它们的优势是print、println、printf特别好用,而且这些方法不会抛出受检异常,只在内部设置一个错误标记,用checkError()才能查到。写入目标可以是文件、字节数组或其他输出流。
区别是PrintStream输出字节,PrintWriter输出字符且支持指定字符集和自动刷新。写文本文件时,PrintWriter配BufferedWriter是常规操作;写日志时把PrintStream包装进装饰链也很常见。我一直把打印流当成“写文本的便利工具”,而不是追求底层性能的主通道。
| 常用流 | 包装/对接目标 | 典型使用场景 | 备注 |
|---|---|---|---|
| FileInputStream / FileOutputStream | 文件 | 原始字节读写 | 无缓冲 |
| BufferedInputStream / BufferedOutputStream | 字节流 | 批量读写大文件 | 默认8KB缓冲 |
| InputStreamReader / OutputStreamWriter | 字节流与字符集 | 编解码转换 | 务必显式指定字符集 |
| BufferedReader / BufferedWriter | 字符流 | 按行读写文本 | 配合转换流使用 |
| DataInputStream / DataOutputStream | 字节流 | 读写基本类型/字符串 | 顺序敏感 |
| ObjectInputStream / ObjectOutputStream | 字节流 | 对象序列化 | 注意类版本控制 |
| PrintStream / PrintWriter | 字节流或字符流 | 格式化输出 | 不抛受检异常 |
3. 流的组合与装饰链:一份可直接抄的读写方案
3.1 从最原始的文件复制说起
先用最基础的节点流实现文件复制,流程是:创建输入流打开源文件,创建输出流写目标文件,循环从输入流读一块数据到字节数组,再写到输出流。
try (InputStream in = new FileInputStream(source); OutputStream out = new FileOutputStream(target)) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }这里有两个关键细节。第一,read(buffer)不一定每次都把缓冲区填满,所以必须用返回值len作为写入长度,不能直接out.write(buffer),否则会把上一次残留的数据也写进去,导致文件内容重复或错位。第二,缓冲区大小 8192 是反复实测后比较平衡的值,太小则系统调用频繁,太大则占用内存且收益递减。很多线上问题其实不是流本身有问题,而是循环逻辑写错了。
3.2 为什么要再包一层缓冲流
基础版已经用了byte[]批量读写,实际项目中我还会再包一层缓冲流,尤其是写文件频繁或数据量大的时候。
try (InputStream in = new BufferedInputStream(new FileInputStream(source)); OutputStream out = new BufferedOutputStream(new FileOutputStream(target))) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }类比一下:FileInputStream每次read都像拎着一个小杯子去水龙头接水,一来一回全是开销;BufferedInputStream相当于在中间放了一个大水桶,先把桶灌满,再一杯一杯从桶里取,只有在桶空时才再去接。即使外层已经用了 8KB 的数组,内层缓冲依然能减少底层同步和系统调用次数。实测下来,某些场景读写大文件时,加与不加差距能到数倍甚至更多。
3.3 try-with-resources 自动关闭
上面的示例已经用了try-with-resources,这是 Java 7 引入的语法,要求流对象实现AutoCloseable。资源声明放在try括号里,代码块结束后 JDK 会自动按逆序调用close(),也就是后声明的先关闭。这样可以彻底告别手写finally { close }的繁琐和遗漏。
注意声明顺序就是打印顺序:out在in之后声明,所以会先关out再关in。这个顺序很重要,因为输出流可能还有缓冲数据没写完,先关输出流能确保这些数据刷到磁盘;如果先关输入流,输出流照常工作也不会出问题,但反过来先关输出流再读,数据就不完整了。装饰链长的场景更要靠这个机制兜底。
3.4 字符流处理文本的标准模板
读取 UTF-8 文本并按行处理,我建议直接用这个模板:
Path path = Path.of("data.txt"); try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; while ((line = reader.readLine()) != null) { // 业务处理 } }Files.newBufferedReader内部就是new BufferedReader(new InputStreamReader(new FileInputStream(path.toFile()), charset)),只是把啰嗦的嵌套封装掉了。如果业务逻辑可以写成函数式管道,还可以配合Files.lines(path, charset)得到一个Stream<String>,但那个流必须在一个try-with-resources里使用,后续会专门讲。写文本时同理,用Files.newBufferedWriter指定 UTF-8,需要格式化再用PrintWriter包一层。
3.5 flush 与关闭顺序的讲究
缓冲流为什么要flush?因为数据先写在内存缓冲区,没满之前不会真正落到目的地。close()内部会先调用flush(),所以正常关闭时不会丢数据。但有一种场景必须手动flush:同一个流对象要跨长生命周期持续写入,而下游立刻需要看到数据。比如写日志、写网络响应,写完一批就该刷一次,否则对面一直等不到内容。
关闭顺序和刷新还有一层关系。装饰链BufferedOutputStream -> FileOutputStream中,close最外层时,BufferedOutputStream会先flush自己的缓冲区,再关闭内层FileOutputStream。所以千万不要在图省事时手动先关内层的FileOutputStream,不然外层缓冲区的数据就再也写不进去了。经典错误是先fileOut.close()再bufferedOut.close(),结果写文件缺尾,排查还特别费劲。我的经验是:只关最外层,里层交给装饰链自己收尾。
4. 从流到 NIO:Files、Channel 与缓冲区的新玩法
4.1 小文件直接交给 Files 工具类
如果文件不大,完全可以不用手动搭装饰链,java.nio.file.Files提供了一堆一键方法。
Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING); byte[] allBytes = Files.readAllBytes(sourcePath); List<String> allLines = Files.readAllLines(sourcePath, StandardCharsets.UTF_8);Files.copy底层根据文件大小自动选择复制策略,比自己写循环复制更省心。readAllBytes适合配置文件、小图片这类体量可控的场景,一句话拿回全部字节;readAllLines适合需要一次性加载所有文本行的场景。但它们都有一个前提:文件大小适合放入内存。拿几百 MB 的日志文件直接readAllBytes,内存立刻吃紧,GC 还救不回来。所以小文件用Files,大文件请继续走流式路线。
4.2 大文件流式处理与 FileChannel
处理大文件的核心原则是“不把整个文件放入内存”,而是分块读取。用传统 IO 的缓冲流已经能解决大部分需求,但追求更高吞吐时,FileChannel是更好的选择。它属于 NIO,读写都围绕ByteBuffer展开。
try (FileChannel in = FileChannel.open(sourcePath, StandardOpenOption.READ); FileChannel out = FileChannel.open(targetPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) { ByteBuffer buffer = ByteBuffer.allocateDirect(8192); while (in.read(buffer) != -1) { buffer.flip(); while (buffer.hasRemaining()) { out.write(buffer); } buffer.clear(); } }这里每个方法都有含义:read把数据从通道读进缓冲区,返回 -1 表示读完;flip把缓冲区从写模式切换成读模式;write把缓冲区内容写到输出通道;clear恢复写模式。使用allocateDirect时,缓冲区直接分配在 JVM 堆外,少一次堆内拷贝,大数据量场景性能更好。如果只是单机复制文件,FileChannel还有个杀手锏transferTo可以直接在内核态搬数据,连用户态缓冲都省了。
4.3 内存映射:MappedByteBuffer
FileChannel.map可以把文件的某段区域直接映射到进程虚拟内存,应用像操作字节数组一样访问文件内容,内核按需把缺失的页加载进来。
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { MappedByteBuffer mapped = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size()); while (mapped.hasRemaining()) { byte b = mapped.get(); // 处理字节 } }优点是读写大文件时不用反复执行系统调用,数据看起来就在内存里;缺点也很明显:映射占用地址空间,文件被映射后如果被外部程序改写,行为可能难以预期。我的经验是,追求超高吞吐的自定义文件格式解析可以上,普通业务文件读写反而没必要,省下的时间会花在排查映射导致的诡异问题上。
4.4 Files.list 与 Files.walk 目录遍历
Files还提供了一系列目录遍历方法。Files.list(dir)返回当前目录下一层路径的Stream<Path>,Files.walk(dir, depth)可以递归遍历到指定深度,按需配合Filter找出符合后缀的文件。
try (Stream<Path> stream = Files.walk(rootPath, 5)) { stream.filter(p -> p.toString().endsWith(".log")) .forEach(System.out::println); }这两个方法返回的Stream跟 IO 流一样需要关闭,因为它底层持有目录的句柄。不在try-with-resources里用完就关,长时间运行的程序会打开大量目录句柄,最终触发句柄耗尽的报错。这个场景和第 6 章要讲的Stream概念有交叉,先在这里提个醒。
5. 那些年我在流上翻过的车:排查链路完整复盘
5.1 中文乱码:先确认解码方向,再确认字符集
翻车案例来自一个导出报表功能:读模板文件填充数据后输出,Windows 服务器上跑得好好的,换到 Linux 服务器上,导出的 Excel 里中文全部变成问号。查了一遍代码,发现读取模板用的是new FileReader(template),写输出用的是new OutputStreamWriter(..., "UTF-8")。输入依赖平台字符集,输出固定 UTF-8,两边编码不一致,编码链路就断了。
排查顺序是:先确认源文件到底什么编码,再看读取端用什么解码,最后看写入端用什么编码。后来我把所有文本流的读写都改成显式指定UTF-8,FileReader、FileWriter一律不用,乱码问题再也没复发。这类问题的本质不是测试环境不同,而是代码里存在隐式的平台依赖。
5.2 数据神秘丢失:BufferedOutputStream 没 close
另一个案例是程序正常退出,但生成的文件大小永远是 0。代码里写了new BufferedOutputStream(new FileOutputStream(outFile)),写完数据后直接结束线程,没有调用close()。因为缓冲区没满,数据还躺在内存里,线程结束不会替你 flush,文件自然就是空的。
排查时我先确认数据确实写过,再逐步给BufferedOutputStream构造参数加true(自动刷新)或手动flush(),问题立刻消失。这也是为什么我建议所有资源都要用try-with-resources:它保证close必然执行,等于给缓冲数据上了保险。如果代码里还残留手写close的旧逻辑,不妨顺手迁到新的资源管理方式。
5.3 Too many open files:句柄泄漏的完整复盘
遇到过最隐蔽的坑是日志模块偶尔报Too many open files。单看代码,每条日志都是new PrintWriter(file, "UTF-8"),写完立刻close(),逻辑上没问题。但后来发现,写日志的线程发生异常时会走另一个分支,那个分支里忘了close(),写失败的流对象一直挂在那里,句柄越积越多,最终把进程的文件描述符耗尽。
排查时我先看异常堆栈,确认不是业务 SQL 问题;接着用系统工具查看进程打开的句柄数量,一眼就看到大量残留的.log文件句柄;再根据句柄数量随时间上升的速度,定位到异常分支缺了关闭操作。修复就是把资源声明放进try-with-resources,让异常路径也能自动关闭。从那以后,我对所有持有系统资源的对象都坚持“声明即关闭范围可见”的原则。
5.4 性能突然差 100 倍:单字节 read 黑洞
一个老模块读取文件做校验,2MB 文件要处理几分钟。代码是while ((i = input.read()) != -1)一次读一个字节,而且外层还没缓冲。单字节read()每次都潜在触发系统调用,哪怕数据已经在内存,也要反复穿越 JNI 边界,性能自然被拖垮。
修复很简单:换成八字节数组批量读,或包一层BufferedInputStream。性能直接提升两个数量级。遇到这种问题,先看读取模式,再看缓冲区存在与否,基本能找到根因。后来我把这条经验总结成一句话:“没有缓冲的逐字节读写,是流性能问题的第一嫌疑犯。”
5.5 反序列化失败:类版本编号不一致
对象流最经典的问题就是serialVersionUID。同事改了实体类的一个字段名,没有更新版本号,结果之前序列化到文件的对象在反序列化时报InvalidClassException,生产数据直接读不回来。
排查时先对比两个时间点的类定义,确认字段变化;再看serialVersionUID,发现两个版本一致,但类结构已经变了,所以套用了老版本的序列化数据后结构对不上。修复分两步:一是新代码显式声明serialVersionUID,二是写兼容逻辑,老数据做字段映射或重建。此后我对所有参与序列化的类都要求显式声明版本号,宁可多写一行,也不赌编译器自动生成的稳定性。
5.6 排查流问题的心法
复盘这几个案例后,我沉淀出一套排查顺序:先看读写方向和数据长度,确认数据是否真的进了流;再看缓冲区状态,有没有 flush 和 close;再看编码和解码器是否一致;最后看系统资源(句柄、内存)有没有泄漏。流问题看起来五花八门,本质上都绕不开“数据流向、缓冲落盘、编码解码、资源关闭”这四个环节,按顺序排查,很少走弯路。
6. 别把 IO 流和 Java 8 Stream 混为一谈
6.1 两种“流”是两套完全不同的东西
标题里的“流”在 Java 里其实有两个含义:一个是java.io的数据流,另一个是 Java 8 引入的java.util.stream.Stream。它们共享同一个中文字面,但一个是字节/字符管道,一个是集合数据的函数式处理管道。
Stream处理的是内存中的元素序列,支持filter、map、collect这类声明式操作。它的数据来源通常是集合、数组或生成器函数,处理过程是惰性求值的,只有遇到collect等终止操作才真正开始计算。IO 流则是对外部数据源的顺序访问,到达终端后数据就没了。两者的本质差异是“外部系统的顺序 I/O”和“内存数据的函数式变换”。
6.2 核心差异对照
| 维度 | java.io 流 | java.util.stream.Stream |
|---|---|---|
| 数据来源 | 文件、网络、内存数组等外部源 | 集合、数组、生成器等内存数据 |
| 核心操作 | 读写字节/字符 | filter/map/reduce 等函数式操作 |
| 消费方式 | 单向、一次性 | 单项、一次性,但可并行 |
| 资源释放 | close() 关闭底层资源 | 部分持有 I/O 资源的 Stream 也要 close() |
| 用途 | 数据传输与落盘 | 数据处理与计算 |
最容易混淆的场景是Files.lines(path, charset),它返回一个Stream<String>,但背后打开的是文件 IO。不少开发者拿到这个流后没有关闭,或者在一个普通集合的Stream中先入为主地认为可以反复消费,结果要么句柄泄漏,要么第二次消费时报“stream has already been operated upon or closed”。
6.3 Files.lines 的正确用法
把大的文本文件按行交给函数式管道处理,是很优雅的写法,但必须记得这个Stream持有文件句柄,要用try-with-resources包住:
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) { long count = lines.filter(line -> line.contains("error")) .count(); System.out.println(count); }这个写法既能享受函数式 API 的简洁,又不会泄漏资源。如果只是普通集合转出来的Stream,不持有外部资源,确实不需要关闭,但养成在流上不反复使用的习惯总没错。
6.4 我的个人实践建议
我现在处理文件时有一套固定取舍:小文件优先Files.readAllBytes或readAllLines;中等文件按行处理用Files.newBufferedReader;需要函数式管道就Files.lines并配try-with-resources;大文件二进制读写优先FileChannel配合堆外缓冲区;追求极致复制性能就Files.copy或transferTo。这套取舍不是为了炫技,而是每类方法背后对应不同的资源成本和内存模型,选对了,线上问题能少一大半。
回到开头说的那个老项目,我最终把附件模块全部改造了一遍:文本统一显式指定UTF-8,所有流都用try-with-resources包裹,大文件换成缓冲流分块读写,小文件直接由Files.copy接管。改动不算大,但线上乱码、超时、句柄报错都陆续消失了。期间踩过的那些坑,大多不是 API 不会用,而是没想清楚流背后的数据方向、缓冲时机和资源生命周期。把这些基础想明白,Java 里的流相关知识,基本就成了一张清清楚楚的地图。