Skip to content

ObjectInputStream / ObjectOutputStream

你有没有想过这个问题:为什么 writeObject() 能把一个 Java 对象转成字节流,又能把字节流转回对象?

答案藏在流格式里。

流格式:不止是对象数据

当你调用 writeObject() 写入一个对象时,写入的内容远不止对象字段的值:

┌────────────────────────────────────────────────────────────────┐
│  ObjectOutputStream 写入的字节序列包含:                         │
│                                                                │
│  1. 魔数(stream magic):0xAC ED ——「这是一个序列化流」         │
│  2. 版本号(stream version):0x00 05 —— 流格式版本             │
│  3. 类描述符:类名 + serialVersionUID                          │
│  4. 字段值:每个字段的序列化值                                  │
│  5. 结束标记:TC_ENDBLOCKDATA                                   │
└────────────────────────────────────────────────────────────────┘

这解释了为什么 Java 原生序列化比 JSON 大——它携带了自描述信息,保证版本兼容性。

基本用法

java
// 序列化:写入对象
User user = new User("张三", 25, "pass123");
try (ObjectOutputStream oos = new ObjectOutputStream(
        new BufferedOutputStream(
            new FileOutputStream("user.dat")))) {
    oos.writeObject(user);
}

// 反序列化:读取对象
try (ObjectInputStream ois = new ObjectInputStream(
        new BufferedInputStream(
            new FileInputStream("user.dat")))) {
    User restored = (User) ois.readObject();
    System.out.println(restored.getName()); // 张三
}

写入多个对象

写入多个对象到同一个流,反序列化时按写入顺序读取:

java
try (ObjectOutputStream oos = new ObjectOutputStream(
        new FileOutputStream("data.obj"))) {
    oos.writeObject(new User("张三", 20));
    oos.writeObject(new User("李四", 25));
    oos.writeObject(new User("王五", 30));
}

try (ObjectInputStream ois = new ObjectInputStream(
        new FileInputStream("data.obj"))) {
    while (true) {
        try {
            User u = (User) ois.readObject();
            System.out.println(u.getName());
        } catch (EOFException e) {
            break; // 读到流末尾,正常结束
        }
    }
}

注意:通过捕获 EOFException 来判断流是否结束——这是标准做法。不是 readObject() 返回 null,它从来不返回 null,只抛 EOFException

readObject 可能抛出的异常

异常触发场景
ClassNotFoundException读取的类在 classpath 中不存在
InvalidClassExceptionserialVersionUID 不匹配
OptionalDataException读取的位置不是对象(如读取了 primitive 数据)
EOFException流已结束,无更多数据
java
try (ObjectInputStream ois = new ObjectInputStream(
        new FileInputStream("data.obj"))) {
    while (true) {
        try {
            Object obj = ois.readObject();
            // 处理对象
        } catch (ClassNotFoundException e) {
            System.err.println("类不存在: " + e.getMessage());
        } catch (EOFException e) {
            break; // 正常结束
        }
    }
}

引用链:对象图的序列化

如果对象 A 持有对象 B 的引用,对象 B 又持有对象 C 的引用,序列化时 JVM 怎么处理的?

答案是对象表机制(Object Table):

java
public class Department implements Serializable {
    private String name;
    private List<Employee> employees;
}

public class Employee implements Serializable {
    private String name;
    private Department department; // 反向引用,形成循环
}

序列化时,JVM 维护一个对象表,每个对象只序列化一次。如果后续遇到相同引用,就写入表中的索引号,而不是重新序列化整个对象。

这保证了引用关系的完整性——序列化后再反序列化,引用链依然正确。

版本兼容机制

如果类增加了新字段,serialVersionUID 不变的情况下,JVM 认为可以兼容:

java
// V1:发布版本
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
}

// V2:新增 age 字段
public class User implements Serializable {
    private static final long serialVersionUID = 1L; // 没变!
    private String name;
    private int age; // 新字段
}

读取旧 V1 数据时,age 字段没有值,JVM 设为 0(基本类型的默认值)。

但如果 serialVersionUID 不一致,直接抛 InvalidClassException——不会尝试兼容。

总结

┌─────────────────────────────────────────────────────────────────┐
│  ObjectOutputStream / ObjectInputStream 是 Java 原生序列化的核心 │
│                                                                 │
│  记住三点:                                                      │
│  1. 写入内容包含魔数、版本号、类描述——比纯数据大,但自描述        │
│  2. 读取多个对象用 while + EOFException 判断结束                  │
│  3. 引用链自动维护,序列化一个对象等于序列化整个对象图            │
└─────────────────────────────────────────────────────────────────┘

基于 VitePress 构建