文件 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 字节就发起一次系统调用」的开销。
// 低效:每读一个字符都可能触发一次系统调用
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 批量读写:
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:
// 推荐写法:字节流 + 指定编码的桥接
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 仍常用)代表文件/目录的抽象路径,可判断存在、创建、删除、列目录,但不读写内容:
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")); // 过滤 .txtNIO.2 更现代
java.nio.file.Files 提供了 readAllLines、write、walk 等更简洁、带异常分类的 API,新项目可优先用。但理解经典 IO 流的「分层/装饰/缓冲」思想,对读懂任何 Java IO 代码都必不可少。
五、序列化:对象 → 字节流
序列化(Serialization) 把对象的状态写成字节流,便于网络传输或落盘;反序列化再还原成对象。要求类实现 java.io.Serializable 标记接口(它是「空接口/标记接口」,仅告诉 JVM「这个类允许序列化」):
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_」标记:
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。
七、最佳实践
- 所有流用 try-with-resources,自动关闭、避免泄漏(见异常一节原理)。
- 文本明确指定 UTF-8(
StandardCharsets.UTF_8),杜绝平台默认编码差异。 - 读写大文件用缓冲流 + 字节数组批量处理,效率远高于逐字节。
- 需要对象持久化才用序列化,声明
serialVersionUID、敏感字段transient;长期/跨系统存储优先 JSON/Protobuf。 - 列目录、判断存在用
File/Files;真正读写内容才用流。
📚 官方文档与参考资料
- Java SE 21 文档:https://docs.oracle.com/en/java/javase/21/
- Java 教程 · 基础 IO:https://docs.oracle.com/javase/tutorial/essential/io/
- Java 教程 · 序列化:https://docs.oracle.com/javase/tutorial/jndi/objects/serial.html
- OpenJDK 项目:https://openjdk.org/
✏️ 练习与自测
- 用字符流(显式 UTF-8)把一个字符串写入
hello.txt,再读出来打印,验证中文不乱码。 - 用缓冲字节流复制一张图片,比较「逐字节复制」与「8KB 缓冲复制」的耗时差异(大文件更明显)。
- 写代码读取某目录下所有
.txt文件(File.listFiles或Files.walk过滤),逐个打印行数。 - 让一个类实现
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 关闭。