跳到主要内容
约 9 分钟3 张卡

Java 流式 I/O

Java 流式 I/O 用字节流读写原始数据,用字符流读写文本。文件、Socket 和压缩数据在底层都以字节传输。本文先区分字节与字符,再说明部分读取、编码转换和缓冲,最后解释刷新与跨块解码的边界。

1. 面试核心​

先记住这几条,再读正文
  • 字节与字符。 字节流读写原始字节,适合二进制数据,字符流读写字符序列,适合文本。卡 · 字节流
  • 编码转换。 InputStreamReader 按指定字符集将字节解码为字符,OutputStreamWriter 则将字符编码为字节。
  • 有效读取长度。 read(byte[]) 返回实际读取的字节数,-1 表示流结束,写出时要使用本次返回的数量。卡 · 部分读取
  • 缓冲作用。 缓冲流把多次小读写合并成大块操作,从而减少底层 I/O 调用。
  • 刷新与持久化。 flush() 将 Java 缓冲中的数据交给底层流,文件持久化还需要 FileChannel.force 等机制。

2. 字节流​

Java 的阻塞式字节流以 InputStream 和 OutputStream 为基础,分别提供字节读取与写入操作。图片、压缩包和加密数据需要保留原始字节,未知格式文件也应按字节处理。比如,复制图片时可以直接把输入流传给输出流:

Path source = Path.of("avatar.png");
Path target = Path.of("avatar-copy.png");

try (
InputStream input = Files.newInputStream(source);
OutputStream output = Files.newOutputStream(target)
) {
input.transferTo(output);
}

这段代码复制图片的原始字节,内容由文件格式解释。比如,0x41 在文本中可以表示字符 A,在图片或压缩包中则有其他含义。

流由创建或明确接管它的一方关闭,优先使用 try-with-resources。关闭顺序与异常处理见 Java 异常。

2.1 部分读取​

读取一块数据时,要用 read(byte[]) 的返回值判断本次读到了多少字节。即使数组还有空位,一次读取也可以提前返回。返回 -1 才表示到达流末尾:

byte[] buffer = new byte[8192];
int count;

while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
}

示例中的数组有 8192 个字节,但最后一块数据可以少于这个容量。因此,写出时使用实际的 count,只写入本次读取覆盖的区域。若每次都写完整数组,上次残留的数据也会进入输出,造成重复或损坏。

读取协议中固定长度的字段时,可以使用 DataInputStream.readFully。它会持续读取到指定长度,提前遇到流末尾时抛出 EOFException。

available() 返回当前无需阻塞即可读取的估计字节数。这个估计值不能用来判断流的总长度或网络消息是否完整。网络消息的结束位置应由协议中的长度或结束标记确定。

3. 字符流​

Reader 和 Writer 分别读取与写入字符序列。处理文本文件时,需要用字符集约定字节和字符之间的对应关系。读取时把字节转换为字符,称为解码;写入时把字符转换为字节,称为编码。

下面将读取和写入放在一起,两端都明确使用 UTF-8:

Path path = Path.of("article.md");

try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String firstLine = reader.readLine();
System.out.println(firstLine);
}

try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
writer.write("欢迎来到 Coding 101");
writer.newLine();
}

第一个操作读取并打印首行,第二个操作写入文本和换行符。如果读写两端使用不同字符集,同一组字节就会被解释成错误字符。因此,网络协议、文件格式和数据库连接都应明确约定字符集。

3.1 InputStreamReader 与 OutputStreamWriter​

已有文件或 Socket 字节流时,可以用转换流连接文本处理代码。InputStreamReader 负责解码,OutputStreamWriter 负责反向编码:

Reader reader = new InputStreamReader(
socket.getInputStream(),
StandardCharsets.UTF_8
);

Writer writer = new OutputStreamWriter(
socket.getOutputStream(),
StandardCharsets.UTF_8
);

示例中的 reader 将 Socket 收到的 UTF-8 字节解码为字符。writer 则把写入的字符编码为 UTF-8 字节,再交给 Socket。已经解码的字符若要重新输出为字节,仍需选定目标字符集。Reader 本身不会保留一份可直接取回的原始字节。

3.2 char 与用户可见字符​

字符流中的一个 char 是一个 UTF-16 代码单元,也就是一份 16 位的编码值。它与用户看到的一个字符并非一一对应,因此按字符计数或截断文本时要区分这两者。

码点、代理对和组合字符的区别见 String。

4. 缓冲流​

大量小读写会反复调用底层流,直接访问设备时还会增加系统调用。缓冲流先读取一大块数据放入内存,再从中满足后续的小读取。写入时则先积累数据,再成块交给底层流:

try (
InputStream input = new BufferedInputStream(Files.newInputStream(source));
OutputStream output = new BufferedOutputStream(Files.newOutputStream(target))
) {
input.transferTo(output);
}

示例通过包装增加输入与输出缓冲。字符缓冲采用同样的思路,还提供 readLine() 和 newLine() 等文本操作。

缓冲的收益来自合并小调用,减少应用与操作系统之间的往返。Files.copy、transferTo 和部分底层流已经按块处理数据。这时,额外包装是否还能减少调用,需要按实际读取和写入方式验证。

4.1 缓冲区大小​

缓冲区大小决定每次能积累多少数据,也决定每个连接长期占用多少内存。扩大缓冲可以减少底层调用,但会增加内存占用和 CPU 缓存压力。服务端维护数万连接时,单连接多占的一点空间会累积成很大的总量。

先使用 API 的默认容量。性能分析确认小粒度 I/O 是瓶颈后,再结合数据量、并发连接数和延迟要求调整。

5. 装饰器组合​

读取压缩文本时,程序既要解压,也要解码和按行读取。Java I/O 可以把这些能力逐层包装到同一个流上,这种组合方式称为装饰器:

try (
InputStream file = Files.newInputStream(Path.of("data.gz"));
InputStream buffered = new BufferedInputStream(file);
InputStream decompressed = new GZIPInputStream(buffered);
Reader decoded = new InputStreamReader(decompressed, StandardCharsets.UTF_8);
BufferedReader lines = new BufferedReader(decoded)
) {
lines.lines().forEach(System.out::println);
}

示例先读取并缓冲文件字节,再解压出文本的 UTF-8 字节,最后解码为字符并逐行打印。每一层只处理自己负责的转换,所以可以组合使用。

包装顺序要与文件格式对应。写入时若先编码再压缩,读取时就要先解压再解码。本例关闭最外层流会沿包装链关闭底层流。换用其他包装类时,要查看它的关闭约定,确认由谁释放底层资源。

6. flush 与持久化​

使用输出缓冲后,写入方法返回时,数据仍可能留在 Java 缓冲区中。需要及时发出这一批数据时,可以调用 flush() 将它交给底层流:

writer.write("event\n");
writer.flush();

这段代码在写入一行后刷新,适合需要及时发送消息的长连接。连接继续保持打开,下一条消息还能接着写入。BufferedWriter.close() 则会先刷新,再关闭底层流。

刷新完成后,文件系统和存储设备仍可缓存数据。需要系统崩溃后的持久化保证时,应使用 FileChannel.force 等机制,并确认文件系统与硬件的保证。

映射文件的写回范围见 零拷贝。

每次刷新都会提前结束当前批次,所以频繁刷新会削弱缓冲效果。刷新时机应由消息边界、交互延迟或持久化协议确定。

7. API 选择​

选择 API 时,先确认数据是文本还是二进制,再判断读写方式。可以按以下五类需求选择:

  1. 二进制数据:使用 InputStream、OutputStream 或字节 Channel。
  2. 文本数据:使用 Reader、Writer 或 Files 的带字符集方法。
  3. 大量小读写:使用缓冲或显式块读取。
  4. 一次性文件复制:优先查看 Files.copy、InputStream.transferTo 或 FileChannel.transferTo。
  5. 随机访问、内存映射或多路复用:进入 NIO 的 Channel、Buffer 和 Selector 模型。

复制图片或压缩包时,应直接保留字节。二进制内容经过错误解码再编码,会改变原始字节序列,导致文件损坏。

8. 跨块解码与错误处理​

分块读取文本时,字节块的结束位置可以落在一个字符中间。UTF-8 用多个字节表示部分字符,逐块调用 new String(bytes, UTF_8) 会把残缺序列当作错误输入。InputStreamReader 会保留尚未解码的字节,等下一块到达后继续拼接和解码。

如果输入本身已经损坏,还需要决定如何处理错误。下面把格式损坏和无法映射的输入都配置为 REPORT,让读取以异常结束:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try (Reader reader = new InputStreamReader(input, decoder)) {
char[] chars = new char[1024];
int count;
while ((count = reader.read(chars)) != -1) {
consume(chars, 0, count); // 只处理本次字符;格式损坏时读取抛异常。
}
}

示例只把本次读到的字符交给 consume。遇到损坏文本时,解码器会报告错误,调用方就能拒绝这份输入。

Java 17 的默认字符集取决于运行环境。Java 21 默认使用 UTF-8,也允许兼容配置。因此,即使升级了 JDK,协议和文件格式仍应显式指定字符集。

9. 常见问题​

选择流之后,还需要区分性能、返回值和文本格式的边界。

9.1 字符流一定比字节流快吗?​

字符流没有固定的速度优势,因为它还要完成编解码。实际性能取决于字符集、缓冲方式、数据量和底层设备。选择时先看是否需要处理文本,再测量对应读写方式的成本。

9.2 为什么 InputStream.read() 返回 int?​

9.3 readLine() 会保留换行符吗?​

9.4 什么时候可以使用默认字符集?​

10 · 面试题卡片​

10.1 字节流和字符流有什么区别,怎样选择​

出现公司:饿了么、网易、美团、金山云

考察重点

  • 说明字节和字符的区别,并判断数据是否需要编解码。
  • 解释 InputStreamReader 和 OutputStreamWriter 各自转换的方向。
  • 说明缓冲怎样合并小读写,以及它为什么能减少底层调用。

相关内容:字节流、字符流、缓冲流。

参考回答

字节流处理原始字节,适合二进制数据,字符流处理字符序列,适合文本。

图片和压缩包要保留原字节,文本则需要按约定字符集解码或编码。InputStreamReader 将字节解码为字符,OutputStreamWriter 将字符编码为字节。因此,选择文本 API 时要明确字符集。

字符流增加了编解码工作,速度还受缓冲方式和底层设备影响。大量小读写可以通过缓冲合并成块操作,减少底层调用。先按数据含义选流,再按调用粒度判断是否需要缓冲。

10.2 读取流时为什么必须使用 read 的返回值​

出现公司:美团

考察重点

  • 解释一次读取为什么可以只填充数组的一部分。
  • 区分 read(byte[]) 返回的数量与 read() 返回的单字节值。
  • 说明如何识别流末尾,并避免把上次残留的数据写出去。

相关内容:部分读取。

参考回答

read(byte[]) 的返回值决定本次有效数据的范围,因此写出时必须使用它。

一次读取可以只填充数组的一部分,返回 -1 才表示流结束。比如,最后一块少于数组容量时,写入应使用 write(buffer, 0, count)。如果总是写完整数组,本次未覆盖的区域就会带出旧数据,造成输出重复或损坏。

10.3 为什么不能把每次 read 的字节块分别转成 UTF-8 字符串?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释字节块为何会截断字符,以及解码器怎样保留剩余字节和处理损坏输入。

相关内容:跨块解码与错误处理。

参考回答

一次读取可以截断一个 UTF-8 多字节字符,所以每块独立解码会把残缺字节当作错误输入。

转换流会保留尚未完成的字节序列,等下一块到达后继续解码。这样,读取边界就可以与字符边界不同。需要拒绝真正损坏的输入时,用 CharsetDecoder 将错误策略设为 CodingErrorAction.REPORT。

11. 参考资料​