Java 流式 I/O
Java 流式 I/O 用字节流读写原始数据,用字符流读写文本。文件、Socket 和压缩数据在底层都以字节传输。本文先区分字节与字符,再说明部分读取、编码转换和缓冲,最后解释刷新与跨块解码的边界。
1. 面试核心
先记住这几条,再读正文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 时,先确认数据是文本还是二进制,再判断读写方式。可以按以下五类需求选择:
- 二进制数据:使用
InputStream、OutputStream或字节Channel。 - 文本数据:使用
Reader、Writer或Files的带字符集方法。 - 大量小读写:使用缓冲或显式块读取。
- 一次性文件复制:优先查看
Files.copy、InputStream.transferTo或FileChannel.transferTo。 - 随机访问、内存映射或多路复用:进入 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?
InputStream.read() 返回 int,是为了同时表示 0 到 255 的字节值和结束标记 -1。如果返回 byte,合法字节 0xFF 和结束标记就会使用同一个值。
9.3 readLine() 会保留换行符吗?
readLine() 返回行内容时会移除行终止符。原文件可以使用或混用不同的换行形式,逐行读取后再统一补换行,会改变这些字节。因此,需要原样保留文件时,应采用字节复制。
9.4 什么时候可以使用默认字符集?
输入格式明确规定跟随运行环境时,可以使用默认字符集。跨机器文件、网络协议和持久化文本应使用约定的字符集,例如 UTF-8。这样,部署环境改变时,读写双方仍按同一规则解释字节。
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。