Appearance
第19课:IO流 - 字节流
🎯 学习目标
- 理解 Java IO 流的体系(字节流 vs 字符流、节点流 vs 处理流)
- 掌握 FileInputStream/FileOutputStream 的读写
- 理解 BufferedInputStream/BufferedOutputStream 的装饰器原理与性能
- 掌握 try-with-resources 自动资源管理
- 识别陷阱(不关流、不用缓冲、字节流读中文乱码)
📖 一、概念讲解:IO 流体系
1. 流的分类
Java IO 用"流"抽象数据的读写,体系庞大但规律清晰:
| 维度 | 分类 |
|---|---|
| 数据单位 | 字节流(InputStream/OutputStream,8位)vs 字符流(Reader/Writer,16位,下节课) |
| 方向 | 输入(读)/ 输出(写) |
| 角色 | 节点流(直接连数据源,如 FileInputStream)vs 处理流(包装节点流,如 BufferedInputStream) |
字节流:读写字节(byte),适合二进制数据(图片、视频、任意文件)。字符流留给文本(下节课)。
2. 装饰器模式(关键认知)
IO 流用装饰器模式组合功能:
InputStream(抽象组件)
└─ FileInputStream(节点流,连接文件)
└─ BufferedInputStream(处理流,加缓冲,包装其他流)
└─ DataInputStream(处理流,读基本类型)java
// 一层包一层,按需组合功能
InputStream in = new BufferedInputStream(new FileInputStream("a.txt"));BufferedInputStream 包装 FileInputStream,给它加"缓冲"能力,但对外仍是 InputStream。这是装饰器:不改接口,增强功能。
📖 二、写文件:FileOutputStream
java
// 写字节到文件(覆盖模式)
try (FileOutputStream fos = new FileOutputStream("output.txt")) {
fos.write("Hello".getBytes()); // String → byte[]
fos.write(65); // 写单个字节 'A'
} // 自动关闭
// 追加模式(第二参数 true)
try (FileOutputStream fos = new FileOutputStream("output.txt", true)) {
fos.write(" World".getBytes());
}注意:write(int) 写低 8 位;write(byte[]) 写整个数组。getBytes() 用平台默认编码(建议显式 getBytes(StandardCharsets.UTF_8))。
📖 三、读文件:FileInputStream
java
// 单字节读(慢,仅演示)
try (FileInputStream fis = new FileInputStream("output.txt")) {
int b;
while ((b = fis.read()) != -1) { // read 返回 0-255,到末尾返回 -1
System.out.print((char) b);
}
}
// 用缓冲数组读(快)
try (FileInputStream fis = new FileInputStream("output.txt")) {
byte[] buf = new byte[1024];
int len;
while ((len = fis.read(buf)) != -1) { // 读入 buf,返回实际字节数
System.out.println(new String(buf, 0, len));
}
}关键:read() 单字节读每次都调系统调用,慢;用 byte[] 批量读大幅减少系统调用。但最佳实践是用 BufferedInputStream(见下)。
📖 四、缓冲流:BufferedInputStream/Output
java
// 写:BufferedOutputStream 包 FileOutputStream
try (BufferedOutputStream bos = new BufferedOutputStream(
new FileOutputStream("output.txt"))) {
bos.write("Hello Buffered".getBytes(StandardCharsets.UTF_8));
} // close 时 flush 缓冲区到磁盘
// 读:BufferedInputStream 包 FileInputStream
try (BufferedInputStream bis = new BufferedInputStream(
new FileInputStream("output.txt"))) {
int b;
while ((b = bis.read()) != -1) {
System.out.print((char) b);
}
}为什么快:内部维护一个缓冲数组(默认 8KB)。读时一次性从磁盘读 8KB 到内存,后续 read 直接从内存取,避免每次系统调用。写时先写缓冲,满或 close 时一次刷盘。
对比:无缓冲单字节读 1MB 文件 = 100 万次系统调用;缓冲后 ≈ 100 次(每 8KB 一次)。性能差几十倍。
📖 五、try-with-resources(自动资源管理)
java
// JDK 7+,资源自动关闭,无需 finally
try (FileInputStream fis = new FileInputStream("a.txt");
FileOutputStream fos = new FileOutputStream("b.txt")) {
// 用 fis, fos
} // 自动按逆序 close,即使有异常也关原理:资源须实现 AutoCloseable。编译器生成 try-finally,自动调 close()。多个资源逆序关闭(后开的先关,符合"栈"语义)。
优势:比手写 finally 简洁、不会漏关、不会掩盖异常(try 异常为主,close 异常用 addSuppressed 附加,不丢失)。
⚠️ 六、常见陷阱
陷阱1:忘记关闭流 → 资源泄漏
java
FileInputStream fis = new FileInputStream("a.txt"); // 用完不关
// ❌ 文件句柄泄漏,可能耗尽系统资源规避:一律用 try-with-resources。
陷阱2:不用缓冲 → 性能差
单字节 read() 每次系统调用。大文件必须用 Buffered 或批量 byte[] 读。
陷阱3:字节流读中文乱码
java
// ❌ 中文可能乱码(字节流按字节读,多字节字符被拆开)
FileInputStream fis = new FileInputStream("中文.txt");
int b; while ((b = fis.read()) != -1) System.out.print((char) b);原因:中文 UTF-8 是 3 字节/字符,字节流逐字节读再 (char) 强转会拆碎字符。文本用字符流(下节课 Reader/Writer + 指定编码)。
陷阱4:BufferedOutputStream 不 flush/close 数据丢失
缓冲流 write 只是写内存缓冲,不 flush/close 数据不会落盘。try-with-resources 的 close 会自动 flush。
陷阱5:write(int) 只写低8位
fos.write(256) 只写 0(256 & 0xFF = 0)。write(int) 按字节写,非整数写。
🆚 七、Java vs C 对比
| 特性 | C 语言 | Java |
|---|---|---|
| 文件操作 | fopen/fread/fwrite/fclose | Stream 体系 |
| 错误处理 | 返回值/errno | 异常 |
| 资源管理 | 手动 fclose | try-with-resources 自动 |
| 缓冲 | 手动 setvbuf | BufferedStream 装饰器 |
| 二进制/文本 | 一套 FILE* | 字节流/字符流分离 |
对 C 程序员:Java 把 C 的 FILE* 拆成字节流/字符流,用装饰器组合功能,用异常和 try-with-resources 替代手动错误检查和 fclose。思想相通,只是更面向对象、更安全。
💡 八、最佳实践
- 一律 try-with-resources 关闭流,杜绝泄漏。
- 用 Buffered 包装 节点流,性能差几十倍。
- 二进制用字节流,文本用字符流(下节课)。
- 显式指定编码:
getBytes(StandardCharsets.UTF_8),不依赖平台默认。 - 批量读写:用 byte[] 缓冲或 Buffered,避免单字节操作。
- 大文件用 NIO(下下节课)或分块处理,避免一次性读入内存。
📝 练习预告
完成 练习/Ex19_ByteStream.java 中的 6 道题:
- FileOutputStream 写文件
- FileInputStream 读文件
- 文件复制(byte[] 缓冲)
- Buffered 流读写
- try-with-resources
- 综合:大文件分割与合并
完成后对比 答案/Sol19.java,查看逐行讲解与多解法。
📖 九、文件复制的标准写法
字节流最典型的应用是复制任意文件。
java
public static void copy(Path source, Path target) throws IOException {
try (InputStream in = new BufferedInputStream(new FileInputStream(source.toFile()));
OutputStream out = new BufferedOutputStream(new FileOutputStream(target.toFile()))) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}关键点:
text
使用 byte[] 批量读写。
写出时必须使用 write(buffer, 0, len)。
不能直接 write(buffer),否则最后一块可能写入脏数据。
使用 Buffered 减少系统调用。
使用 try-with-resources 保证关闭。最后一块数据通常不足 8192 字节,所以必须尊重 len。
📖 十、flush 与 close
输出流涉及缓冲时,write 不一定立刻写到目标设备。
text
write:写入流或缓冲区。
flush:把缓冲区内容刷出。
close:关闭资源,通常会先 flush。什么时候需要手动 flush?
text
长连接网络通信,需要立即发送一条消息。
交互式命令行,需要马上显示输出。
写日志或协议帧,希望对方立刻收到。文件写入通常依赖 try-with-resources 的 close 即可。
注意:
text
flush 不等于 fsync。
flush 只是从 Java 缓冲写到操作系统。
数据是否真正落盘还受操作系统缓存影响。如果需要强制落盘,通常使用 NIO 的 FileChannel.force(true)。
🧪 十一、实战案例:计算文件哈希
读取二进制文件并计算 SHA-256:
java
public static String sha256(Path file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = new BufferedInputStream(new FileInputStream(file.toFile()))) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
digest.update(buffer, 0, len);
}
}
byte[] hash = digest.digest();
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}这个案例体现了字节流的适用场景:
text
不关心文本编码。
按原始字节处理。
适合图片、压缩包、视频、加密、哈希、网络协议。📖 十二、Files 工具类的关系
现代 Java 中,小文件操作常用 java.nio.file.Files。
java
byte[] bytes = Files.readAllBytes(Path.of("a.bin"));
Files.write(Path.of("b.bin"), bytes);
Files.copy(Path.of("a.bin"), Path.of("b.bin"), StandardCopyOption.REPLACE_EXISTING);适用场景:
text
小文件一次性读取。
简单复制、移动、删除。
代码需要简洁。不适合:
text
超大文件一次性 readAllBytes。
需要边读边处理。
需要精确控制缓冲、进度、限速。文件很大时,应使用流式处理或 NIO Channel,避免一次性占用大量内存。
📖 十三、大文件处理原则
大文件处理不要把整个文件读入内存。
错误示例:
java
byte[] all = Files.readAllBytes(path);如果文件有 5GB,这会直接导致内存压力甚至 OOM。
正确思路:
text
分块读取。
边读边处理。
只保留必要状态。
需要随机访问时用 RandomAccessFile 或 FileChannel。
需要高性能传输时使用 transferTo/transferFrom。示例场景:
text
大文件上传。
日志归档。
视频处理。
备份复制。
哈希校验。
压缩解压。🛠 十四、字节流排查清单
常见问题:
text
文件路径不存在。
没有读写权限。
流未关闭导致文件占用。
复制文件最后一块写错。
写完未 flush/close。
把文本当字节逐个转 char 导致乱码。
一次性读大文件导致 OOM。
异常时吞掉 IOException。排查建议:
text
打印绝对路径确认文件位置。
检查文件大小和权限。
确认 read 返回值是否被正确使用。
确认 try-with-resources 是否覆盖所有流。
二进制文件不要用 Reader/Writer。
文本文件不要忽略编码。✅ 十五、掌握标准
学完本课后,应能做到:
text
能区分字节流和字符流的使用场景。
能用 FileInputStream/FileOutputStream 读写文件。
能用 BufferedInputStream/BufferedOutputStream 提升性能。
能写出正确的文件复制循环。
能解释 read 返回 -1 和 len 的意义。
能说明 flush、close、try-with-resources 的关系。
能避免大文件一次性读入内存。
能知道什么时候使用 Files 工具类更合适。字节流处理的是原始 bytes。只要涉及二进制、哈希、压缩、网络协议或不确定编码的数据,就应优先从字节流思考。
🎓 下一步
- 第20课:IO流 - 字符流 — Reader/Writer、编码问题、BufferedReader