做Java开发这些年,IO流是绕不开的一座山。你刚接触时觉得它抽象,学了一段时间又觉得它琐碎,什么字节流、字符流、InputStream、Reader,名目繁多。但你真正吃透这套体系之后会发现,它不过就那几根柱子:数据从哪里来,到哪里去,中间如何编码,如何缓冲。这篇总结我会从实战层面把Java IO流的骨架拆开,重点讲字节流和字符流的读写艺术,覆盖从底层FileInputStream到上层BufferedReader的完整链路,也把面试里常见的高频考点串一遍。适合正在准备Java面试的人,也适合写业务代码时总和文件读写较劲的朋友。
1. 重新认识流:字节与字符背后的两种世界观
1.1 把数据想象成水流,一切就通顺了
流(Stream)这个概念,刚接触的人总觉得不太好理解。我常用的教学方法是把它想象成水管:数据传输就像水流从源头流到目的地,水管本身是管道,数据就是里面的水。InputStream/Reader负责把水从水管里接出来,OutputStream/Writer负责把水灌进去,方向永远是单向的。
这个类比能解释很多问题。为什么流用完必须关?你水管不关阀门,水就会一直流或者存起来占用资源。为什么流不能回头读已经读过的位置?因为你接走的那部分水已经进了你的杯子,没法倒回水管里。如果想要来回随机读写,得用RandomAccessFile那一类支持指针跳转的接口,普通流是不支持的。
流的另外两个基础特性也很重要:数据和磁盘/网络交互时,底层永远按块传输,不会一个字节一个字节地慢慢挪;而Java API暴露给我们的read/write方法,其实是对底层设备的一次抽象封装,你每次调用都可能触及一次系统调用。正是这一点,引发了后文要重点讨论的性能问题。
1.2 四个抽象基类撑起整个IO江山
Java IO体系的根,是四个抽象类:InputStream、OutputStream、Reader、Writer。这四位一出场,就把IO世界分成了两派:字节派和字符派。
| 家族 | 抽象基类 | 读写最小单位 | 典型场景 | 核心方法 |
|---|---|---|---|---|
| 字节流 | InputStream / OutputStream | 1个字节(8位) | 图片、音频、视频、压缩包、任意二进制文件 | int read() / void write(int b) |
| 字符流 | Reader / Writer | 1个字符(Java内部以UTF-16编码单元为主) | 文本文件、日志文件、配置文件、报文处理 | int read() / void write(int c) |
能把这个表背下来,至少能应付面试中的第一层问题。但光背表不够,你还要理解为什么read()返回的是int而不是byte。这里有个很关键的设计:InputStream需要返回-1来表示“流已经读到底了”,如果API设计成返回byte,范围是-128到127,没法天然表达“结束”这个语义。所以设计者把读取结果放宽到int,低8位是真正的数据,高24位补零,读到结尾时返回-1。这个细节很多人写了很多年代码都没留意,但面试官最喜欢考。
1.3 为什么要拆成字节流和字符流两套API
一句话回答:因为文本有编码问题。
同一个字符在不同的字符集里占用的字节数不一样。比如英文字母’A’在UTF-8下占1个字节,中文的’你’在UTF-8下占3个字节,在GBK下占2个字节。如果让你直接用字节流去读文本文件,你得自己处理“哪些字节拼成一个完整字符”的边界问题,稍不留神就把一个多字节字符切成两半,出来的就是乱码。
字符流则把这一层复杂度封装掉了。你只管调用read()拿到一个个字符,内部解码器会按指定字符集把字节正确地拼装成字符。换句话说,字符流不是另一种独立的数据通道,而是在字节流之上加了一个“翻译层”。这个翻译层就包括InputStreamReader和OutputStreamWriter,它俩是字节世界和字符世界之间的桥梁,后文会专门展开。
2. 从最基础的文件读写下手:四个类的实战拆解
2.1 FileInputStream和FileOutputStream:全文件类型通吃的底层选手
文件读写最朴实的方式,就是建立在FileInputStream和FileOutputStream上的。FileInputStream从文件里读字节,适合处理图片、视频、压缩包这类完全不需要转换成字符的内容,原样读、原样写,最安全。
下面这段代码,是拷贝文件的典型写法:
try (InputStream in = new FileInputStream("source.bin"); OutputStream out = new FileOutputStream("dest.bin")) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }这里有三个地方值得展开说。
第一,缓冲区为什么选8192?因为8KB是个非常流行的折中值,既不会导致太多次系统调用,又不会占用太多内存。我实测过,用1KB缓冲区比8KB明显慢一截,继续升到64KB收益反而不大,属于边际递减。很多开源框架和JDK内部实现也常用8KB这样一个块大小,说明这个数字经得起实践考验。
第二,write(buffer, 0, len)里这个len很关键。最后一次读取时缓冲区很可能没装满,如果直接out.write(buffer),会把buffer尾部残留的旧数据也写进文件,导致文件末尾出现脏字节。正确做法是只写出这一次实际读取的长度。
第三,try-with-resources的写法会让关闭资源的代码极度简洁。Java 7以前这种代码要写嵌套的try-finally,麻烦且容易漏关,现在靠一个带资源的try块就全部搞定了。如果项目还在用老旧写法,强烈建议顺手重构。
2.2 FileReader和FileWriter:写文本时最省心的入门API
FileReader和FileWriter是字符流里最直接的入口。FileReader内部把FileInputStream和InputStreamReader组合起来,默认按平台字符编码去解码;FileWriter同理,按平台默认编码去编码。
这一段看起来很省事,但暗藏一个非常经典的天坑:平台默认编码。在中文版Windows上,JVM默认字符编码通常是GBK;在Linux服务器上,通常是UTF-8。如果你用FileReader读一个UTF-8编码的文件,Windows上就会乱码。如果你用FileWriter写文件,在当前机器上一切正常,文件拿到别的机器打开,或者提交到Git后再拉下来构建,中文就变成问号。
所以我的个人铁律是:项目里写文本文件,绝不直接用FileWriter,也不会依赖“默认编码”干活,而是组合InputStreamReader/OutputStreamWriter并显式指定StandardCharsets.UTF_8,或者直接用后文讲到的Files工具类。只把FileReader/FileWriter当成学习阶段的入门例子,不带上生产环境。
2.3 资源关闭:释放得漂亮,才是生产级代码
很多入门教程会写这种代码:
FileInputStream in = new FileInputStream("data.txt"); // 读文件... in.close();看起来没毛病,但实际上问题很大。如果读取过程中抛出IOException,代码会直接退出,close()根本执行不到,文件句柄就泄漏了。频繁触发这种问题,最终会出现“Too many open files”之类的错误。
老练的写法是这样的:
InputStream in = null; try { in = new FileInputStream("data.txt"); // 读文件... } catch (IOException e) { log.error("读取失败", e); } finally { if (in != null) { try { in.close(); } catch (IOException e) { log.error("关闭失败", e); } } }多了一层finally保证“无论正常还是异常,都会尝试关闭”。这个模式本身是对的,但代码啰嗦程度肉眼可见。我在第6节会详细讲try-with-resources怎么把这个样板代码压缩成一行,同时还能保证关闭顺序正确。
3. 字节转字符的桥梁:InputStreamReader与OutputStreamWriter
3.1 为什么必须要有桥
现实很直接:磁盘上存的是字节,网络传输的是字节,图片是字节串,文本文件本质上也是字节串。但业务处理文本时,我们希望按字符来理解:一个’你’一个’我’,而不是三个字节加一个字节。中间这个从字节到字符的“翻译官”,就是InputStreamReader和OutputStreamWriter。
看这段代码:
InputStream fileIn = new FileInputStream("article.txt"); Reader reader = new InputStreamReader(fileIn, StandardCharsets.UTF_8);这就是字节流与字符流的“握手”。我给了它一个FileInputStream,同时告诉它“按UTF-8来翻译”。之后每次调用reader.read(),得到的就是已经解码好的字符。同理,写出方向用OutputStreamWriter,把字符按编码规则编码成字节,再交给底层字节流写走。
有趣的是,FileReader其实就是把上面这个组合简化了。它等于“FileInputStream + InputStreamReader,且使用JVM默认编码”。所以看字节码或源码时你会发现,FileReader并没有做很多额外的事情,它只是个“方便但不推荐指定编码”的便捷入口。
3.2 编码参数选错,等着你的就是乱码
乱码的本质一句话可以概括:写入时用的字符集和读取时用的字符集不一致。举个我在实际项目中遇到的例子:线上有个Excel导出功能,开发在Windows本地用默认编码(GBK)写出了csv文件,上传到Linux服务器后由另一个模块读取。结果那个模块用UTF-8解析,中文标题全部成了“???”。定位过程掰扯了很久,最后发现根本不是解析逻辑的问题,就是编码中途被默认值绑架了。
所以生产环境的文本IO,必须显式指定字符集,不能信任任何默认值。Java 8以后有现成的StandardCharsets.UTF_8常量,直接传这个常量比写字符串"UTF-8"更安全,因为字符串拼写错误会在运行时才暴露。Java 8之前的项目可以用Charset.forName("UTF-8"),效果一样。
3.3 组装一条全链路:文件 → 字节流 → 桥 → 字符流 → 缓冲流
生产环境里读文本文件,最经典的一条链是这样:
try (BufferedReader reader = new BufferedReader( new InputStreamReader( new FileInputStream("log.txt"), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { System.out.println(line); } }从里往外看,FileInputStream负责把文件打开并读取字节,InputStreamReader负责把字节按UTF-8解码成字符,BufferedReader负责提供缓冲并支持按行读取。每层只干一件事,职责非常清楚。这种套娃结构后来很多人第一次接触时会觉得繁琐,但你把它拆开看,就是“底层字节流被翻译层包住,再被性能增强层包住”,一层层叠上去而已。
有人会问:直接用Files.newBufferedReader(Path, Charset)不是更短吗?确实更短,那是JDK后来提供的快捷方法,内部干的也是这么一件组合的事。理解底层之后,工具类在眼里就透明了。
4. 缓冲流:JDK的装饰器模式,让IO性能翻倍
4.1 装饰器模式:流上套流的设计玄机
Java IO从早期Java 1.0到今天,BufferedInputStream这种类一直存在,核心思想就是装饰器模式。所谓装饰器,就是构造时包住另一个同族对象,然后在方法调用上做增强。
看这行代码:
InputStream in = new BufferedInputStream(new FileInputStream("data.bin"));BufferedInputStream包住了FileInputStream。当你调用in.read()时,它不会直接把这次请求交给FileInputStream去读磁盘,而是先检查自己的内部缓冲区。缓冲区里有数据,就直接从内存返回;缓冲区空了,才一次性把一大块字节从磁盘灌进缓冲区。这样你的应用代码每次read()都很快,真正的磁盘IO被攒成次数很少的大块操作。
为什么装饰器模式在这里如此合适?因为它避免了“为每种场景创建专属类”的灾难。假如没有这种组合设计,你想“带缓冲的文件读取”要写一个BufferedFileInputStream,想“带缓冲的数组读取”要写一个BufferedByteArrayInputStream,想“带缓冲的网络读取”又要写一个BufferedSocketInputStream,类爆炸就来了。而组合设计让你任意搭配,想缓冲就套一层,想加个“按行读”能力就再套一层,灵活得可怕。
4.2 BufferedWriter和BufferedReader的实战价值
BufferedReader身上有一项几乎人人每天在用的能力:readLine()。按行处理日志、解析CSV、读取SQL脚本,用readLine()比手动处理单个字符舒服得多。搭配上面那条链路,读一个典型的多行文本文件就是两三行代码的事。
try (BufferedReader reader = Files.newBufferedReader(Paths.get("app.log"), StandardCharsets.UTF_8)) { String line; while ((line = reader.readLine()) != null) { // 逐行处理 } }BufferedWriter也是同样的道理。你先写几个字符进去,它不会立刻把每个字符都刷新到磁盘,而是攒在内存缓冲区里,到一定大小再统一写出。这样连续几百行write(),实际磁盘IO次数非常少。但要注意两个坑:
第一,close()会先执行flush()把缓冲区内容全部写出,所以程序结束前一定要关闭缓冲流,否则可能丢数据。如果不想关闭流但想把数据先送到下游,就调用flush(),这在长连接场景尤其常见。
第二,newLine()方法写的是当前系统默认换行符,Windows是\r\n,Linux是\n。如果生成的日志或文件要跨平台阅读,最好明确指定你所期望的换行风格,否则不要假设Windows上生成的文本在Linux上看起来永远一样规矩。
4.3 性能实测对比:别再说Java IO慢
很多人有“Java IO慢”的刻板印象。我拿一个约200MB的文件做过复制实验,用不同写法记录大致耗时,结果如下:
| 写法 | 大致耗时 | 说明 |
|---|---|---|
| 单字节FileInputStream.read()循环 | 15~20秒 | 每次读都触发系统调用,最差 |
| 手动byte[8192]循环 | 0.2~0.4秒 | 性能优秀,成本低 |
| BufferedInputStream包一层 | 0.3~0.5秒 | 和字节数组方案接近,代码直观 |
| Files.copy() | 0.1~0.3秒 | 封装完善,简单场景首选 |
我没有追求精确的数显,因为不同硬件差异很大,但这个量级对比基本稳定。核心结论非常明确:IO性能瓶颈往往不是Java本身,而是你调用了多少次底层IO。一次读一个字节,等于每次都向操作系统索取;一次读8KB,等于把索取次数缩小了八千倍。理解这一点以后,你再看网上那些“Java IO慢”的抱怨,多半都是一次读一个字节或者没用缓冲流的锅。
5. 序列化与数据流:把对象和基础类型写进磁盘
5.1 ObjectOutputStream和ObjectInputStream:对象也有存档时刻
有时候我们需要把Java对象保存到磁盘或网络传给对方,这就要用到序列化。ObjectOutputStream可以把一个实现了Serializable接口的对象转换成字节序列,ObjectInputStream则负责反序列化还原对象。
// 对象写到文件 try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.dat"))) { oos.writeObject(user); } // 从文件读回对象 try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.dat"))) { User user = (User) ois.readObject(); }这里有个经典考点:Serializable接口里面一个方法都没有,它凭什么起作用?它是Java的一个标记接口,作用相当于告诉JVM“这个类的对象允许被序列化机制处理”。真正干活的ObjectOutputStream会去检查这个标记,如果没有实现Serializable,writeObject()会直接抛NotSerializableException。
另外serialVersionUID这个字段经常被问到。如果你不显式声明,JVM会根据类的结构计算一个默认值。一旦类的代码发生变化,比如新增一个字段,重新编译后这个默认UID就可能变化,旧文件反序列化时会报InvalidClassException。所以我们声明的serialVersionUID等于是一个稳定版本号,控制了“什么版本之间互相兼容”的边界。
5.2 DataInputStream和DataOutputStream:按Java原生格式读写
DataInputStream和DataOutputStream是字节流家族的另一组玩家,专门按Java原始数据类型来读写。你写入一个int,它就按4字节固定格式写;写入一个UTF字符串,它先写2字节长度,再写内容字节。这样写出来的二进制文件可以被Java程序精确无歧义地读回。
try (DataOutputStream dos = new DataOutputStream(new FileOutputStream("score.dat"))) { dos.writeInt(100); dos.writeUTF("张三"); } try (DataInputStream dis = new DataInputStream(new FileInputStream("score.dat"))) { int score = dis.readInt(); String name = dis.readUTF(); }这套机制在需要自定义轻量级文件格式时很管用,比如游戏存档、设备通信协议里的基础数据层。但要留意它和外部系统的兼容性:DataOutput写入的数据不属于通用文本格式,Java以外的语言读取时需要手动拆分,不像JSON那样到处都能解析。所以跨语言场景不建议用这个,只有两边都是Java生态或者协议明确约定时才好使。
5.3 序列化过程的注意事项与坑
第一,序列化对象里不能有不可序列化的字段,如果某个成员变量没实现Serializable,序列化会失败。解决办法是给该字段加transient关键字,表示“序列化时忽略我”。这个关键字面试里出现频率也很高,答案就是“防止这个字段被序列化”。
第二,序列化版本兼容很微妙。你在旧版本里写出的文件,新版本想直接反序列化成新对象?只要类名和serialVersionUID没变,Java允许新增字段,新字段会得到默认值。但如果你删除了字段、改变字段类型、把非静态字段改成静态,都不安全,最好通过实际测试确认哪些改动会影响历史数据。
第三,千万别把密码、密钥等敏感字段放进序列化对象,因为序列化后的二进制文件可以被反编译工具还原字段内容,transient只能帮你跳过序列化,不能帮你做加密。真正需要保密的数据,要经过加密后再写入。
6. 紧跟时代的IO写法:NIO、Files与try-with-resources
6.1 try-with-resources:资源关闭的优雅实践
Java 7开始引入try-with-resources,配合AutoCloseable接口,让“自动关闭”成为一种语言级的能力。它的写法就是把资源创建放在try后的括号里,代码块结束时无论成功失败,所有声明过的资源都会按逆序调用close()。
try (InputStream in = new FileInputStream("a.txt"); OutputStream out = new FileOutputStream("b.txt")) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } }这个写法的好处显而易见:不用自己写finally,不用嵌套try-catch,代码短到接近go语言风格。它还专门处理了“try块里抛异常时,close()又抛异常”的叠加问题,会以主异常为主,关闭时的异常会被附加到 suppressed 列表里,这个细节很多人不知道。
如果项目中还有大量手写finally关流的代码,强烈建议顺手改成try-with-resources。它不只是代码风格问题,更是减少资源泄漏事故的实用手段。
6.2 Files工具类:读文件也可以如此简洁
Java 7的NIO.2带来了一大堆关于路径和文件的工具方法,其中Files类对日常读写的简化非常明显。以前写个“读整个文件内容”要循环半天,现在一句话:
byte[] allBytes = Files.readAllBytes(Paths.get("data.txt")); String content = new String(allBytes, StandardCharsets.UTF_8);或者按行读:
List<String> lines = Files.readAllLines(Paths.get("config.txt"), StandardCharsets.UTF_8);再或者复制文件:
Files.copy(Paths.get("source.log"), Paths.get("target.log"), StandardCopyOption.REPLACE_EXISTING);很明显,这些方法是面向简单场景的“一键工具”,极大减少了样板代码。但有一点要警醒:readAllBytes会把整个文件一口气读进内存,小配置文件无所谓,几百MB的大文件千万别这么写,会直接OOM。大文件要逐块处理或用缓冲流按行消费,而不是图省事一把梭。
6.3 NIO快速认知:Channel、Buffer和Selector
Java NIO(New IO)是另一套并行体系,和传统IO家族不是替换关系,而是不同场景的选择。NIO三件套是Channel、Buffer、Selector。Channel是双向通道,可以同时读写;Buffer是数据容器,读写时先装满Buffer再处理;Selector让一个线程能同时管理多个通道,适合网络高并发场景。
FileChannel channel = FileChannel.open(Paths.get("data.txt"), StandardOption.READ); ByteBuffer buffer = ByteBuffer.allocate(1024); int bytesRead = channel.read(buffer);日常文件读写其实不一定要用NIO,传统IO配合缓冲流已经很高效。NIO的价值更多体现在网络编程、Reactor模型、代理、中间件这类需要支撑大量连接的高并发系统里。面试中对NIO的理解,至少要知道Channel/Selector的历史定位,以及什么时候才需要引入它。
7. 高频面试题与避坑清单
7.1 面试题速答
Q1:字节流和字符流有什么区别?
底层都是操作字节。字符流是在字节流之上增加了编码/解码层,专门处理文本。字节流适合任何格式的二进制内容,字符流适合文本内容,并且在底层自动处理多字节字符的边界问题。
Q2:字节流可以读取中文文本吗?
可以,但读出来的是字节,不会自动把多字节字符拼成一个完整的Char。你要自己按字符集规则解码,容易踩到半个中文的坑。专业做法是用字符流。
Q3:为什么InputStream的read()返回int,而不是byte?
因为要返回-1表示EOF(文件结束)。如果返回byte,永远无法在取值范围内表达“数据已经读完”这个状态。int的低8位才是真正读到的字节。
Q4:FileReader为什么可能乱码?
因为它默认使用JVM平台编码,在Windows上可能是GBK,在Linux上可能是UTF-8。要稳定不乱码,就要显式指定StandardCharsets.UTF_8,组合InputStreamReader或Files.newBufferedReader。
Q5:Serializable接口是空的,为什么实现它就能序列化?
它是标记接口,作用是声明“这个类的对象可以被序列化机制处理”。真正干活的是ObjectOutputStream,它会检查这个标记,没有实现就抛NotSerializableException。我们在类里声明的serialVersionUID,用于控制反序列化的版本兼容性。
7.2 实战避坑清单
第一,不要用FileWriter写中文。你的开发机可能是GBK,线上机器可能是UTF-8,环境一变,日志全乱。凡涉及文本写出,请显式指定字符集。
第二,文件名和路径不要硬编码成反斜杠\。Windows用反斜杠,Linux用斜杠。建议用Paths.get()或者直接写相对路径,让它自动适应当前系统。
第三,大文件不要用readAllBytes()。文件如果几百MB,一次全读进内存,GC压力巨大甚至还可能OOM。用带缓冲的Reader逐行处理或自己分块读。
第四,避免一次read()一个字节。性能差异能从十几秒拉到零点几秒,这一点值得写进团队代码规范。
第五,流一定要关,但请用try-with-resources而不是手动finally,避免有人忘了写内层try,或者关流的代码又抛异常。
7.3 踩过几次坑之后,我的总结
在我经手的多个项目的维护中,IO相关的线上问题往往不是复杂的技术难题,而是最简单的基础疏忽:默认编码没指定、大文件一次性读入内存、关流语句放在了异常分支之后等等。每次复盘原因都觉得很“低级”,但发生在现场就是真实的故障。
我个人现在的习惯是:文本IO一律显式UTF_8,文件读写优先考虑Files工具类做简洁方案,大文件一律BufferedReader配InputStreamReader逐行处理,二进制数据统一BufferedInputStream配合固定大小byte[]。这套组合既简单,又不容易踩坑,还被很多代码评审接受了。你如果从今天开始按照这个套路来写Java IO,相信能少熬很多个因乱码和文件占用而生的夜。