第 12 章 · 输入输出(IO)

我大二的第一个「正式」bug:程序读完文件不报错但内容是乱码,查了一小时,发现是文件是 GBK 编的、读的代码按 UTF-8 解。这一章从这类事故出发,把 Java IO 的两套体系(字节流/字符流)讲清,再把本章真正的重头戏讲透:try-with-resources 和缓冲的意义。前者是 Java 对「资源必须关闭」这个千古难题的官方答案,后者是操作系统课第 8 章(页缓存)在应用层的回响。

12.1 流的抽象:字节流与字符流

Java 把一切输入输出抽象成「流」:数据像水管里的水,顺序进出。两套体系按数据单位分:

  • 字节流 InputStream/OutputStream:8 位字节,万物皆可(图片、音频、任意二进制)。
  • 字符流 Reader/Writer:处理文本,自动做「字节 ↔ 字符」的编码转换(12.6 的乱码就出在这层)。

分类记住一个原则:文本用字符流,二进制用字节流。拿字符流读图片(经过编码转换)必坏,拿字节流读中文(不解释编码)必乱码。

12.2 try-with-resources:资源管理的官方答案

先看这个模式,它值得整段背下来:

try (BufferedReader br = new BufferedReader(new FileReader("a.txt"))) {
    String line;
    while ((line = br.readLine()) != null)    // readLine 返回 null = 读完了
        System.out.println(line);
} catch (IOException e) {
    e.printStackTrace();
}   // ← 走到这里,br 自动关闭(无论正常结束还是异常)

括号里声明的资源(AutoCloseable 的对象),在 try 块结束时保证关闭,异常路径也不例外。这是 Java 7 的语法糖(编译器自动生成 finally + close + 异常抑制),它治的是 C 时代的老病:open 了忘 close(文件句柄泄漏,长期服务耗尽句柄号,操作系统课第 9 章的 fd 表)。C 的 FILE* 要手动 fclose、Python 的 with 是同款思想(本站 Python 课会再讲),Java 的版本还能一次管多个资源(括号里分号隔开)。

对比一下反面教材(Java 6 以前的老写法):try 里 open,finally 里判空再 close,close 自己还可能抛异常要再包一层 try,三行变八行。try-with-resources 之后,「忘关资源」这类 bug 在新代码里几乎绝迹,这是语言设计直接影响代码质量的实例。

12.3 缓冲:为什么要有 Buffered

裸的 FileInputStream 每次 read 都可能触发一次系统调用(第 1 章 strace 见过的 read),一次进一次出,内核态切换的开销(第 1 章算过,微秒级)在百万次小读取下变成灾难。缓冲流是解药:

new BufferedReader(new FileReader("a.txt"))   // 缓冲包在文件流外面
new BufferedInputStream(new FileInputStream("a.bin"))

原理:缓冲流一次向内核要 8KB,攒在内存里,应用每次 read 只从内存取,系统调用次数从「每字节一次」降到「每 8KB 一次」。实测体验:复制一个大文件,包不包缓冲差一个数量级。这层设计还顺带示范了装饰器模式:BufferedInputStream 和 FileInputStream 实现同一个接口(都是 InputStream),缓冲流只是「包了一层、功能增强」,用法完全不变。想再加一层(如数据流 readInt)继续包,套娃即可。IO 类库的几十个类看着吓人,记住「都是同一接口的装饰器堆叠」就理顺了。

12.4 字节流复制文件的完整模板

try (InputStream in = new FileInputStream("src.jpg");
     OutputStream out = new FileOutputStream("dst.jpg")) {
    byte[] buf = new byte[8192];
    int len;
    while ((len = in.read(buf)) != -1) {      // read 返回实际读到的字节数,-1 是 EOF
        out.write(buf, 0, len);               // 只写 len,不写整个 buf!
    }
}

两个细节:read 返回 -1 表示流结束;write 必须写 buf, 0, len(最后一次读可能不满 8KB,写整个 buf 会把垃圾也拷进去)。这个模板是字节流操作的母题,音视频处理、网络传输全是它的变体。

12.5 编码:乱码案的完整卷宗

字符流在「字节 ↔ 字符」之间做翻译,翻译依据是字符集:ASCII(1 字节,英文)、GBK(中文,12 字节)、UTF-8(变长 14 字节,兼容 ASCII,互联网事实标准)、UTF-16(Java 内存里 String 的存储方式)。

乱码的全部根源:写时编码与读时编码不一致。GBK 写的文件按 UTF-8 读,中文变「锟斤拷」;UTF-8 写的按 GBK 读,变问号或方块。Java 默认编码随平台变(Windows 中文环境曾是 GBK,Linux 几乎都是 UTF-8),所以跨平台代码必须显式指定:

new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8);
new OutputStreamWriter(new FileOutputStream("b.txt"), StandardCharsets.UTF_8);
// Java 18+ 默认 UTF-8(JEP 400),老 JDK 必须手动

顺带把字与字节的账算清:一个 char 是 2 字节(UTF-16 码元),一个汉字在 UTF-8 文件里占 3 字节,「中文长度」的问题(char 数、字节数、显示宽度)从此有了三个不同答案,处理文本时先问自己要哪个。

12.6 File 类与更多流

File 表示路径(不是内容):exists、createNewFile、mkdir、delete、listFiles(目录树遍历用递归,第 6 章的递归练手题)。RandomAccessFile 能 seek 到任意位置读写(数据库课第 8 章的页管理、断点续传的底层)。对象序列化(ObjectOutputStream + implements Serializable)能把整个对象写进文件,但跨语言/跨版本脆弱,真实项目多用 JSON(第三方库),了解即可。

常见错误

  • 读文本不指定编码。12.5 的乱码主案;新 JDK 默认 UTF-8 也没惯着它的理由,显式写。
  • write 写整个 buf 不看 len。12.4 的细节,文件末尾多出垃圾字节。
  • 字符流处理二进制。图片过一遍编码转换,数据必坏;二进制永远字节流。
  • 在循环里 new 流不关。每轮泄漏一个句柄;资源一律进 try-with-resources 括号。

练习

  1. 动手:实测缓冲的威力:同一文件复制三次(无缓冲逐字节、缓冲 8KB、Files.copy 一行),计时对比,记录三个数字。
  2. 实现:统计一个文本文件的行数、单词数、字符数(wc 的 Java 版),用 BufferedReader.readLine;再处理「最后一行没有换行符」的边界。
  3. 动手:制造乱码:用 GBK 写一个文件(OutputStreamWriter 指定 GBK),再用默认编码读,观察乱码;再指定 UTF-8 读,再指定 GBK 读,三种结果截图记录,写一句「乱码 = 编码不一致」的结论。
  4. 思考:装饰器模式让 IO 类「几十个类但一个接口」,对比 C 的 fread 参数(一个函数搞定但没法套娃)。结合你写过的代码,什么时候你会感谢「可堆叠」,什么时候嫌它啰嗦?这个权衡对 API 设计有什么启发?