Skip to content

第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/fcloseStream 体系
错误处理返回值/errno异常
资源管理手动 fclosetry-with-resources 自动
缓冲手动 setvbufBufferedStream 装饰器
二进制/文本一套 FILE*字节流/字符流分离

对 C 程序员:Java 把 C 的 FILE* 拆成字节流/字符流,用装饰器组合功能,用异常和 try-with-resources 替代手动错误检查和 fclose。思想相通,只是更面向对象、更安全。


💡 八、最佳实践

  1. 一律 try-with-resources 关闭流,杜绝泄漏。
  2. 用 Buffered 包装 节点流,性能差几十倍。
  3. 二进制用字节流,文本用字符流(下节课)。
  4. 显式指定编码getBytes(StandardCharsets.UTF_8),不依赖平台默认。
  5. 批量读写:用 byte[] 缓冲或 Buffered,避免单字节操作。
  6. 大文件用 NIO(下下节课)或分块处理,避免一次性读入内存。

📝 练习预告

完成 练习/Ex19_ByteStream.java 中的 6 道题:

  1. FileOutputStream 写文件
  2. FileInputStream 读文件
  3. 文件复制(byte[] 缓冲)
  4. Buffered 流读写
  5. try-with-resources
  6. 综合:大文件分割与合并

完成后对比 答案/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