对象也能写进文件、跨网络传输——靠的就是序列化。本篇讲透序列化的规则与版本兼容陷阱,再补齐打印流、随机访问流和 Properties 这几块 IO 拼图。

本篇目标

  • 理解序列化/反序列化的概念,掌握 Serializable、serialVersionUID、transient 三大规则
  • 学会序列化流读写多个对象的两种技巧
  • 会用打印流 PrintWriter、数据流和随机访问流 RandomAccessFile
  • 用 Properties 读写配置文件

序列化是什么

序列化(Serialize)就是把内存中的对象转成字节序列写出去,持久化到文件或发往网络;反序列化则反过来,把字节序列还原成对象。JDK 提供的工具是一对”对象流”:ObjectOutputStream 用 writeObject() 把对象写出,ObjectInputStream 用 readObject() 把对象读回。

// 写出(序列化)
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("demo/person.dat"));
oos.writeObject(new Person("张三", 23));
oos.close();

// 读回(反序列化)
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("demo/person.dat"));
Person p = (Person) ois.readObject(); // 返回 Object,要强转
ois.close();

反序列化还有一个特点:它不走构造方法,对象的字段值全部来自字节流,这一点在某些框架的底层源码里会遇到。

三大规则

1. Serializable 是个空接口

直接运行上面的代码会抛 NotSerializableException。要被序列化的类必须实现 Serializable 接口——打开源码你会发现它里面什么都没有,一个方法都没有。这类接口叫标记接口,作用只是给 JVM 打一个”允许序列化”的标记,序列化机制运行时靠检查这个标记来决定放行还是拒绝。类似的标记接口还有 Cloneable。

2. serialVersionUID:类的版本指纹

public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
}

serialVersionUID 是类的”版本指纹”。序列化时它会被一起写入文件;反序列化时 JVM 拿文件里的版本号和当前类的版本号对比,不一致就抛 InvalidClassException。

如果不显式声明,编译器会根据类的字段、方法等结构自动算一个。问题在于:只要类结构有任何改动(哪怕只是加个字段),自动算出的版本号就变了,旧文件全部读不回来。所以实际开发中必须显式写死 serialVersionUID,这样给类增加字段后旧数据仍能兼容读取,新字段用默认值补齐——这就是序列化的版本兼容问题及其标准解法,也是面试高频题。

3. transient:不想被序列化的字段

密码这类敏感字段不该落盘,用 transient 修饰即可让序列化机制跳过它:

private transient String password;      // 反序列化后为 null

另外注意:static 字段属于类而不属于对象,天然不参与序列化,所以也别指望靠它保存对象状态。

最后再提醒两点使用上的细节:一是序列化文件是人看不懂的二进制,别用文本编辑器去改;二是反序列化要求类路径上能找到对应的类定义,类被删了或包名改了都会抛 ClassNotFoundException,这也是实际项目中序列化文件”娇气”的原因——它存的不只是数据,还有对类结构的依赖。

多对象读写技巧

一个流连续 writeObject 多个对象没问题,但读取时有个麻烦:readObject() 读到流末尾不是返回 null,而是抛 EOFException。两种应对:

技巧一:用一个集合装所有对象(推荐)

List<Person> list = new ArrayList<>();
list.add(new Person("张三", 23));
list.add(new Person("李四", 24));

try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("demo/list.dat"))) {
oos.writeObject(list); // 只写一个对象
}

try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("demo/list.dat"))) {
@SuppressWarnings("unchecked")
List<Person> persons = (List<Person>) ois.readObject();
for (Person p : persons) {
System.out.println(p);
}
}

读一次拿到集合,循环遍历即可,不用关心对象个数。强转时的泛型警告用 @SuppressWarnings(“unchecked”) 压制,因为泛型在运行期会被擦除,JVM 看到的只是 ArrayList。

技巧二:捕获 EOFException,读到异常就认为读完了。能用,但拿异常当流程控制不优雅,知道即可。

打印流 PrintWriter

System.out 就是一个打印流(PrintStream)。打印流的特点:

  • 可以 print/println 任意类型,底层自动调用对象的 toString() 转成字符串,传 null 就打印 “null”
  • 只负责输出,永远不会抛 IOException(内部吞掉了),所以代码格外干净
  • 构造时开启自动刷新(autoFlush = true)后,println 会自动 flush
PrintWriter pw = new PrintWriter(new FileWriter("demo/g.txt"), true);
pw.println(97); // 打印的是字符串 "97"
pw.println("你好"); // 自动换行 + 自动刷出
pw.close();

写日志、写简单文本报告时,打印流比 BufferedWriter 顺手。

标准输入输出流

System.in 是 InputStream 类型的标准输入流,默认从键盘读字节;System.out 是 PrintStream 类型的标准输出流,默认打向控制台。这两个”标准设备”可以用 System.setIn() / System.setOut() 重定向到文件:

System.setIn(new FileInputStream("demo/a.txt"));
System.setOut(new PrintStream("demo/b.txt"));

重定向之后,程序里所有 System.out.println 的输出都会写进 b.txt,做日志收集或批量测试输入时偶尔能派上用场。这个用法了解即可,重点是理解”控制台其实也是一种 IO 设备”。

数据流与随机访问流

DataInputStream / DataOutputStream 能按基本数据类型整块读写:writeInt() 固定写 4 个字节,readInt() 固定读 4 个字节,写读顺序必须严格对应,否则读出来全是错位的数据。它适合读写结构固定的二进制数据。

RandomAccessFile 不属于流体系,直接继承 Object,但它同时具备了读和写的能力。最大特点是内部维护一个文件指针,可以 seek() 到任意位置读写,不必从头开始:

RandomAccessFile raf = new RandomAccessFile("demo/h.txt", "rw");
raf.writeUTF("hello");
raf.seek(0); // 把指针拨回文件开头
System.out.println(raf.readUTF());
raf.close();

典型应用是断点续传和多线程分段下载:每个线程 seek 到自己负责的区间写数据,互不干扰。模式参数 “r” 表示只读,”rw” 表示读写。

序列流与内存流(了解)

  • SequenceInputStream:把多个字节输入流首尾相接拼成一个流,按顺序依次读。两个流直接传构造方法;更多流要装进 Vector,再用 elements() 得到 Enumeration 传入。这个 API 很老,了解原理即可。
  • ByteArrayOutputStream:往内存里写的输出流,数据攒在内部数组里,最后 toByteArray() 或 toString() 一次性取出,适合”先把数据凑齐再统一处理”的场景,也能用来绕过 read(byte[]) 的分批拼接问题。

Properties:配置文件读写

Properties 是一个特殊的 Map:键和值都是字符串,并且可以直接和文件互转。作为集合用 setProperty/getProperty/stringPropertyNames;真正值钱的是 load 和 store 这对方法:

Properties prop = new Properties();

// 读:从文件加载
try (FileReader fr = new FileReader("demo/config.properties")) {
prop.load(fr);
}
System.out.println(prop.getProperty("username"));

// 改并写回
prop.setProperty("username", "admin");
prop.setProperty("count", "10");
try (FileWriter fw = new FileWriter("demo/config.properties")) {
prop.store(fw, "config"); // 第二个参数是写入文件头部的注释
}

.properties 文件每行一条 key=value,# 开头是注释。它一直是 Java 世界最经典的配置载体,后来 Spring 的配置文件、Maven 的 properties 都是它的徒子徒孙。相比自己用字符流逐行解析配置文件,Properties 帮你处理了转义、注释和键值切分,代码量从几十行缩到几行。

经典练习

把上一篇的”试用版软件”升级为 Properties 版本:剩余次数存在 config.properties 里,每次运行 load 出来减一再 store 回去,归零时提示购买正版。体会 Properties 相比手写文本解析的便利。

小结

  • 序列化三规则:实现 Serializable 标记接口、显式声明 serialVersionUID、敏感字段加 transient
  • 多对象读写最稳的办法是”装进集合一次读写”
  • PrintWriter 打印方便且可自动刷新;Data 流按类型整块读写;RandomAccessFile 靠 seek() 实现随机读写,是断点续传的基础
  • Properties = 字符串版 Map + load/store 文件互转,是配置文件的标配
  • 这些特殊流各有适用场景,记住”干什么事用什么流”,比死记 API 更重要

至此 IO 篇收尾。从字节流到序列化,这套 API 的骨架已经完整了。