1. 为什么 Java IO 要分成字节流和字符流
Java 的 IO 体系里,最容易被初学者混淆的一对概念就是字节流 和字符流。它们并不是两套互相替代的 API,而是针对不同数据形态设计的两条主线:字节流面向原始二进制数据,字符流面向文本数据。
理解这一点,后面的选择就变得很自然:
- 字节流 :以
byte(8 位)为最小单位,适合图片、音频、视频、压缩包、序列化对象等任何二进制内容。 - 字符流 :以
char(16 位)为最小单位,内部会做编码解码,适合文本文件、配置文件、日志、CSV、JSON 等字符内容。
一句话概括:字节流处理"数据",字符流处理"文本"。字符流本质上是在字节流之上加了一层编码转换。
2. 字节流体系:InputStream 与 OutputStream
字节流的顶层抽象类是 InputStream 和 OutputStream,所有具体实现都继承自它们。常见实现包括:
| 类型 | 常用类 | 典型用途 |
|---|---|---|
| 文件 | FileInputStream / FileOutputStream |
读写任意文件 |
| 缓冲 | BufferedInputStream / BufferedOutputStream |
减少系统调用,提升吞吐 |
| 内存 | ByteArrayInputStream / ByteArrayOutputStream |
内存中的字节数据转换 |
| 对象 | ObjectInputStream / ObjectOutputStream |
对象序列化与反序列化 |
下面是一个标准的字节流文件复制示例,注意使用缓冲区并配合 try-with-resources 自动关闭资源:
java
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class ByteStreamCopy {
public static void copy(String src, String dest) throws IOException {
try (BufferedInputStream in = new BufferedInputStream(new FileInputStream(src));
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(dest))) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
out.flush();
}
}
public static void main(String[] args) throws IOException {
copy("input.zip", "output.zip");
}
}
这里有几个关键点:
read(buffer)返回实际读取的字节数,返回-1表示读到末尾,不能只判断是否等于 0。- 写入时要用
write(buffer, 0, len),否则最后一次可能把缓冲区里的脏数据也写进去。 - 缓冲区大小一般取 4KB 到 64KB,8KB 是比较通用的选择。
- 字节流不需要关心编码,复制出来的文件与源文件逐字节一致。
3. 字符流体系:Reader 与 Writer
字符流的顶层抽象类是 Reader 和 Writer。它们内部会按照指定字符集把字节解码成字符,因此必须明确编码,否则会使用平台默认编码,跨环境时极易出现乱码。
| 类型 | 常用类 | 典型用途 |
|---|---|---|
| 文件 | FileReader / FileWriter |
简单文本读写(不推荐,编码不可控) |
| 转换 | InputStreamReader / OutputStreamWriter |
字节流与字符流之间的桥梁 |
| 缓冲 | BufferedReader / BufferedWriter |
按行读取、批量写入 |
| 内存 | StringReader / StringWriter |
字符串与字符流互转 |
推荐写法是显式指定 UTF-8,通过转换流包装字节流:
java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
public class CharStreamDemo {
public static void readLines(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
public static void writeLines(String path) throws IOException {
try (BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(path), StandardCharsets.UTF_8))) {
writer.write("第一行");
writer.newLine();
writer.write("第二行");
writer.flush();
}
}
}
字符流的优势在于:
- 按行读取 :
readLine()直接处理换行符,适合日志、CSV 等文本格式。 - 编码可控 :通过
InputStreamReader指定字符集,避免乱码。 - 字符级操作:可以直接写入字符串,不必手动转成字节数组。
4. 字节流与字符流的转换关系
字符流并不是独立存在的,它的底层仍然是字节流。转换流 InputStreamReader 和 OutputStreamWriter 就是两者之间的桥梁:
理解这条链路后,就能明白为什么 FileReader 不推荐使用:它内部固定使用平台默认编码,在 Windows 和 Linux 之间迁移时容易出现乱码。显式使用 InputStreamReader 加 StandardCharsets.UTF_8 才是稳妥做法。
5. 如何选择:一张判断表
| 场景 | 推荐选择 | 理由 |
|---|---|---|
| 复制图片、音频、视频 | 字节流 | 二进制数据不能做字符解码 |
| 读取日志、CSV、配置文件 | 字符流 | 需要按行处理,且要控制编码 |
| 网络传输原始报文 | 字节流 | 协议层数据以字节为准 |
| HTTP 响应体是文本 | 字节流读入,再按 charset 解码 | 编码信息来自响应头,需动态决定 |
| 对象序列化 | 字节流 | 序列化结果是二进制 |
| 大文本文件逐行处理 | 字符流 + 缓冲 | 避免一次性加载,减少内存占用 |
可以记住三条经验法则:
- 不确定就用字节流,字节流是万能的,字符流只是它的上层封装。
- 涉及文本就必须显式指定编码,不要依赖平台默认值。
- 无论哪种流,都建议加缓冲,减少底层 IO 次数。
6. 常见坑与最佳实践
6.1 忘记 flush 导致数据丢失
字符流和缓冲流内部都有缓冲区,写完不调用 flush() 或没有正确关闭,数据可能停留在内存中。使用 try-with-resources 可以保证 close() 被调用,而 close() 内部会先 flush。
6.2 用字符流读取二进制文件
用 FileReader 读图片会破坏字节内容,因为解码过程会替换无法映射的字节。二进制文件一律使用字节流。
6.3 编码不一致导致乱码
写入用 UTF-8、读取用 GBK,必然乱码。团队内应统一约定编码,并在代码中显式写出 StandardCharsets.UTF_8,让问题在代码评审阶段就暴露。
6.4 缓冲区大小设置不合理
缓冲区过小会导致频繁系统调用,过大则浪费内存。一般 8KB 起步,处理大文件时可以调到 32KB 或 64KB,并结合实际压测结果调整。
6.5 忘记处理异常与资源关闭
IO 操作必须处理 IOException,并确保资源释放。try-with-resources 是首选写法,避免手写 finally 块时遗漏关闭逻辑。
7. 小结
字节流和字符流的分工非常清晰:字节流负责原始数据的搬运,字符流负责文本的编码解码与按行处理。实际开发中,字符流几乎总是建立在字节流之上,通过转换流指定编码完成桥接。
选择时先问自己一个问题:我要处理的是二进制数据还是文本? 二进制走字节流,文本走字符流并显式指定 UTF-8;无论哪种,都加上缓冲、用 try-with-resources 管理资源,就能避开绝大多数 IO 问题。