Skip to content

文件 IO 基础 ​

Java 初级 · 进阶基础

IO(输入/输出)是程序与外部世界(文件、网络、控制台)交换数据的方式。Java 把 IO 抽象成「流(stream)」:字节流处理所有二进制,字符流专门处理文本,缓冲流则大幅提升性能。本节从流的分层体系、装饰器模式、缓冲原理、序列化字节结构讲透,让你不仅会用,还懂「为什么裸流慢、为什么序列化有兼容性坑」。

一、IO 体系的两条主线 ​

Java IO 按「处理单位」分两大族,每族又分「节点流(直接连数据源)」与「处理流(套在别的流上增强功能)」:

  • 字节流 InputStream/OutputStream:以 byte 为单位,能处理一切(图片、压缩包、任意文件)。不含编码概念。
  • 字符流 Reader/Writer:以 char(字符)为单位,内部桥接了编码转换(字节↔字符),专门伺候文本,避免中文乱码。
  • 字节↔字符的桥梁是 InputStreamReader/OutputStreamWriter(可指定 Charset)。

二、装饰器模式:为什么流能「套娃」 ​

你会发现 new BufferedReader(new FileReader("x")) 这种「一层套一层」的写法。这背后是装饰器模式(Decorator):每个处理流「包裹」一个底层流,在它之上增加能力(缓冲、行读取、加密……),而接口不变。

好处:能力可任意组合、互不侵入。想要「带缓冲的文件字符流」就 BufferedReader(FileReader(FileInputStream));想要「带缓冲的加密网络流」就再套一层。每个类只关心「在已有流上加点料」,符合单一职责。

三、缓冲流:为什么快,快在哪 ​

BufferedReader/BufferedInputStream 内部维护一个字节/字符数组(默认 8KB)。读数据时一次从磁盘批量读一大块进缓冲区,后续 read() 直接从内存缓冲区取,极大减少「每次读 1 字节就发起一次系统调用」的开销。

java
// 低效:每读一个字符都可能触发一次系统调用
try (FileReader fr = new FileReader("data.txt")) {
    int c;
    while ((c = fr.read()) != -1) { ... }
}

// 高效:BufferedReader 内部 8KB 缓冲,系统调用次数骤减
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    String line;
    while ((line = br.readLine()) != null) {   // 按行读,还能直接拿整行
        System.out.println(line);
    }
}

字节流复制文件同理,用 byte[] buf 批量读写:

java
try (InputStream in = new FileInputStream("a.png");
     OutputStream out = new FileOutputStream("b.png")) {
    byte[] buf = new byte[8192];       // 8KB 缓冲区
    int len;
    while ((len = in.read(buf)) != -1) {
        out.write(buf, 0, len);       // 只写本次实际读到的长度
    }
}

必须指定编码,否则乱码

FileReader/FileWriter 用平台默认编码(Windows 常是 GBK,Linux/macOS 常是 UTF-8),跨平台极易乱码。文本一律显式指定 UTF-8:

java
// 推荐写法:字节流 + 指定编码的桥接
try (BufferedReader br = new BufferedReader(
        new InputStreamReader(new FileInputStream("data.txt"), StandardCharsets.UTF_8))) {
    ...
}

StandardCharsets.UTF_8 是常量,比写字符串 "UTF-8" 更安全(后者拼写错只会在运行时抛异常)。

四、File:文件的抽象路径 ​

java.io.File(注意:Java 7+ 有更现代的 java.nio.file.Path/Files,但 File 仍常用)代表文件/目录的抽象路径,可判断存在、创建、删除、列目录,但不读写内容:

java
File f = new File("data.txt");
if (!f.exists()) f.createNewFile();   // 不存在则创建
System.out.println(f.length());      // 文件大小(字节)
File dir = new File("logs");
File[] txts = dir.listFiles((d, name) -> name.endsWith(".txt"));  // 过滤 .txt

NIO.2 更现代

java.nio.file.Files 提供了 readAllLines、write、walk 等更简洁、带异常分类的 API,新项目可优先用。但理解经典 IO 流的「分层/装饰/缓冲」思想,对读懂任何 Java IO 代码都必不可少。

五、序列化:对象 → 字节流 ​

序列化(Serialization) 把对象的状态写成字节流,便于网络传输或落盘;反序列化再还原成对象。要求类实现 java.io.Serializable 标记接口(它是「空接口/标记接口」,仅告诉 JVM「这个类允许序列化」):

java
import java.io.*;

class User implements Serializable {        // 标记接口,无方法
    private static final long serialVersionUID = 1L;  // 版本号,见下方陷阱
    private String name;
    private transient String tempToken;      // transient: 不参与序列化
    // ...
}

// 序列化
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("u.dat"))) {
    oos.writeObject(new User("Tom"));
}
// 反序列化
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("u.dat"))) {
    User u = (User) ois.readObject();
}

序列化的字节结构(略览) ​

ObjectOutputStream 写出的不是裸字段,而是一段有格式的流,开头有魔数 AC ED + 版本 00 05,后跟各类「TC_」标记:

text
aced 0005            ← STREAM_MAGIC(0xaced) + STREAM_VERSION(5)
73                  ← TC_OBJECT 对象开始
72                  ← TC_CLASSDESC 类描述符开始
... 类名长度/类名 UTF / serialVersionUID / 字段数 / 各字段类型签名 ...

两个关键陷阱 ​

  • serialVersionUID:类编译后会有一个版本号。若序列化后你改了类(增删字段)却没显式声明固定 serialVersionUID,JVM 按类结构算出版号变化,反序列化会抛 InvalidClassException。显式声明 private static final long serialVersionUID = 1L; 可兼容旧数据。
  • transient:标记「不序列化」的字段(如密码、临时缓存),反序列化后该字段为默认值(如 null)。

序列化不是持久化的好选择

序列化格式是 Java 私有、体积大、版本脆弱、且可被反序列化攻击(构造恶意字节流触发危险 readObject)。跨系统/长期存储请用 JSON(如 Jackson)、Protobuf 等,而非 Java 原生序列化。原生序列化只适合临时内存对象传递或简单 demo。

六、常见误区与注意点 ​

WARNING

  • 忘记关闭流:不关流占用系统资源、缓冲数据可能没真正刷盘。务必 try-with-resources。
  • 文本用字节流裸读:默认编码可能不对,中文乱码;文本用 Reader/Writer 并指定 UTF-8。
  • FileReader 编码不可指定:用平台默认编码,跨平台乱码;改 InputStreamReader(..., UTF_8)。
  • 序列化对象未实现 Serializable:直接 NotSerializableException。
  • 改了类后反序列化旧数据:没固定 serialVersionUID 会 InvalidClassException。

七、最佳实践 ​

  1. 所有流用 try-with-resources,自动关闭、避免泄漏(见异常一节原理)。
  2. 文本明确指定 UTF-8(StandardCharsets.UTF_8),杜绝平台默认编码差异。
  3. 读写大文件用缓冲流 + 字节数组批量处理,效率远高于逐字节。
  4. 需要对象持久化才用序列化,声明 serialVersionUID、敏感字段 transient;长期/跨系统存储优先 JSON/Protobuf。
  5. 列目录、判断存在用 File/Files;真正读写内容才用流。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 用字符流(显式 UTF-8)把一个字符串写入 hello.txt,再读出来打印,验证中文不乱码。
  2. 用缓冲字节流复制一张图片,比较「逐字节复制」与「8KB 缓冲复制」的耗时差异(大文件更明显)。
  3. 写代码读取某目录下所有 .txt 文件(File.listFiles 或 Files.walk 过滤),逐个打印行数。
  4. 让一个类实现 Serializable,固定 serialVersionUID,序列化一个对象到文件;然后故意修改类增删一个字段但不改 serialVersionUID,反序列化观察能否兼容(再试改成不声明 serialVersionUID 看是否报错)。

本节小结

Java IO 以「流」为核心:字节流 InputStream/OutputStream 通吃二进制,字符流 Reader/Writer 按字符并桥接编码处理文本。流采用装饰器模式层层包裹(如 BufferedReader(FileReader(FileInputStream))),接口不变、能力叠加。缓冲流内部维护 8KB 缓冲区,批量系统调用替代逐字节,性能质变——故文本必显式 UTF-8 并配缓冲流。File 管路径元数据不读写内容。Serializable + ObjectOutputStream 做序列化,流内含魔数 AC ED、类描述与字段值;务必声明 serialVersionUID 兼容版本、transient 排除敏感字段,但长期存储优先 JSON/Protobuf 而非原生序列化。所有流用 try-with-resources 关闭。