Skip to content

第21课:IO流 - 对象序列化

🎯 学习目标

  • 理解序列化的本质(对象图遍历 + 类元数据)与用途
  • 掌握 Serializable 接口、serialVersionUID、transient
  • 理解引用处理(同一对象序列化一次、循环引用)
  • 掌握自定义序列化(writeObject/readObject、Externalizable)
  • 识别陷阱与安全风险(反序列化漏洞),了解现代替代方案

📖 一、概念讲解:序列化是什么

1. 序列化与反序列化

序列化:把对象(内存中的对象图)转换为字节序列,用于存储到文件、传输到网络。 反序列化:把字节序列恢复为对象。

对象(内存)  ──序列化──→  字节流  ──存储/传输──→  字节流  ──反序列化──→  对象(另一JVM)

2. 原理:对象图遍历

序列化不只是写一个对象的字段,而是遍历整个对象图

java
class User implements Serializable {
    String name;        // String 也 Serializable
    Address address;    // Address 必须 Serializable,否则 NotSerializableException
}

序列化 User 时,会递归序列化 name、address 及其引用的所有对象,直到全部可达。这就是为什么被引用的对象也要 Serializable(或用 transient 排除)。

3. 引用去重

序列化时,同一对象只写一次,后续引用记录为"指向已写对象"的引用。所以对象图里多处指向同一对象,反序列化后仍是同一对象(引用相等)。


📖 二、基本用法

java
// 实现 Serializable(标记接口,无方法)
public class User implements Serializable {
    private static final long serialVersionUID = 1L;   // 显式声明版本号
    private String name;
    private int age;
    private transient String password;   // transient:不参与序列化

    public User(String name, int age, String password) { ... }
}

// 序列化
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.dat"))) {
    oos.writeObject(new User("Alice", 25, "secret"));
}

// 反序列化
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.dat"))) {
    User user = (User) ois.readObject();   // 反序列化后 password 为 null
}

注意:反序列化不调用构造方法(而是用特殊机制分配对象)。所以 transient 字段反序列化后是类型默认值(null/0),需要在 readObject 或 readResolve 里重新初始化。


📖 三、serialVersionUID:版本控制

java
private static final long serialVersionUID = 1L;

作用:序列化时把 UID 写入字节流;反序列化时比对 UID 与当前类的 UID,不一致抛 InvalidClassException

不显式声明的后果:编译器根据类结构自动生成 UID。类一改动(加字段/改方法)UID 就变,旧数据无法反序列化。所以必须显式声明,这样类做兼容性修改(加字段)时 UID 不变,仍能反序列化(新字段取默认值)。


📖 四、transient:排除字段

java
private transient String password;   // 敏感信息不序列化
private transient int cache;          // 缓存数据不持久化

用途:敏感数据(密码)、临时/缓存数据、不可序列化的字段(如 Connection)。

反序列化后 transient 字段是默认值。若需恢复,用自定义 readObject 或 readResolve。


📖 五、自定义序列化

1. writeObject/readObject

java
private void writeObject(ObjectOutputStream oos) throws IOException {
    oos.defaultWriteObject();            // 默认序列化非 transient 字段
    oos.writeObject(encrypt(password));  // 手动加密写 transient 字段
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
    ois.defaultReadObject();
    password = decrypt((String) ois.readObject());   // 解密恢复
}

这两个方法是私有的,由 ObjectOutputStream 通过反射调用(约定)。可用于加密敏感字段、压缩数据等。

2. Externalizable(完全自定义)

java
public class Foo implements Externalizable {
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeObject(...);   // 完全手动写
    }
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        // 完全手动读
    }
}

Externalizable 要 public 无参构造(反序列化时调用),序列化效率更高但需手写全部逻辑。


⚠️ 六、常见陷阱

陷阱1:未声明 serialVersionUID

类改动后旧数据无法反序列化。必须显式声明

陷阱2:引用的对象未实现 Serializable

java
class User implements Serializable {
    Connection conn;   // Connection 未实现 Serializable
}
// 序列化 User → NotSerializableException

规避:该字段用 transient,或使其 Serializable。

陷阱3:transient 字段反序列化后是 null/0

反序列化不调用构造方法,transient 字段需在 readObject 里恢复。

陷阱4:序列化的安全风险(重要!)

Java 原生序列化反序列化任意类,攻击者可构造恶意字节流触发任意代码执行(反序列化漏洞,曾影响 Apache Commons Collections 等库)。 规避

  • 不要反序列化不可信数据。
  • 优先用 JSON/Protobuf 等格式(只反序列化数据,不执行代码)。
  • 用 ObjectInputFilter(JDK 9+)白名单允许反序列化的类。

陷阱5:静态字段不参与序列化

序列化的是对象状态(实例字段),static 字段属于类,不序列化。

陷阱6:单例序列化破坏

反序列化会创建新对象,破坏单例。用 readResolve 返回单例实例:

java
private Object readResolve() { return INSTANCE; }

🆚 七、Java vs C / 现代替代

特性C 语言Java
对象持久化手动 fwrite/fread 结构体(无引用、无继承)自动遍历对象图序列化
跨语言二进制格式通用Java 序列化是 Java 专有格式
安全反序列化漏洞风险

现代替代(推荐):

  • JSON(Jackson/Gson):可读、跨语言、安全,适合配置/接口。
  • Protobuf:二进制、高效、跨语言、schema 演进,适合 RPC。
  • Java 原生序列化仅用于同 JVM 间临时传输(如 RMI、缓存),且数据可信。

💡 八、最佳实践

  1. 显式声明 serialVersionUID,保证兼容性。
  2. 敏感/临时字段用 transient
  3. 不要反序列化不可信数据,或用 ObjectInputFilter 白名单。
  4. 优先 JSON/Protobuf,Java 序列化仅限可信内部场景。
  5. 单例用 readResolve 防破坏。
  6. 引用的对象要 Serializable 或 transient

📝 练习预告

完成 练习/Ex21_Serializable.java 中的 6 道题:

  1. 基本 序列化/反序列化
  2. transient 字段
  3. serialVersionUID 兼容性
  4. 对象图(嵌套引用)
  5. 自定义序列化(加密)
  6. 综合:实现可序列化的缓存

完成后对比 答案/Sol21.java,查看逐行讲解与多解法。


📖 九、序列化兼容性

serialVersionUID 决定反序列化时是否认为类版本兼容。

通常兼容的修改:

text
新增字段。
删除字段。
把字段改为 transient。
新增方法。
修改方法实现。

通常不兼容或风险较高的修改:

text
修改字段类型。
修改类继承结构。
修改字段含义。
删除父类的 Serializable。
改变自定义 readObject/writeObject 协议。

新增字段时,旧数据反序列化后新字段是默认值:

text
引用类型:null。
int:0。
boolean:false。

因此反序列化后可能需要补默认值。


📖 十、readObject 中恢复不变量

反序列化不调用构造方法,因此构造器里的校验不会执行。

示例:

java
public class Range implements Serializable {
    private static final long serialVersionUID = 1L;
    private int start;
    private int end;

    public Range(int start, int end) {
        if (start > end) {
            throw new IllegalArgumentException();
        }
        this.start = start;
        this.end = end;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        if (start > end) {
            throw new InvalidObjectException("start > end");
        }
    }
}

安全原则:

text
构造器做过的不变量校验,readObject 也要做。
transient 字段需要在 readObject 中重新初始化。
不要信任输入流中的字段值。

🛡 十一、ObjectInputFilter 白名单

JDK 9+ 提供 ObjectInputFilter 限制反序列化类型。

示例:

java
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.example.User;com.example.Address;java.base/*;!*"
);

try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.dat"))) {
    in.setObjectInputFilter(filter);
    User user = (User) in.readObject();
}

规则含义:

text
允许 com.example.User。
允许 com.example.Address。
允许 java.base 模块基础类。
其他全部拒绝。

如果必须使用 Java 原生序列化,白名单是基本防线。


🧪 十二、readResolve 与单例

序列化会绕过构造器创建对象,可能破坏单例。

java
public final class Singleton implements Serializable {
    private static final long serialVersionUID = 1L;
    public static final Singleton INSTANCE = new Singleton();

    private Singleton() {
    }

    private Object readResolve() {
        return INSTANCE;
    }
}

readResolve 的作用:

text
反序列化创建临时对象后,用 readResolve 返回的对象替换它。
单例、枚举替代、缓存对象都可能使用。

更推荐的单例写法是枚举:

java
public enum Singleton {
    INSTANCE
}

枚举天然处理序列化单例问题。


📖 十三、现代替代方案对比

方案特点适用
Java 原生序列化自动对象图,Java 专有,风险高可信内部、历史系统
JSON可读、跨语言、生态好HTTP API、配置、日志
Protobuf二进制、高效、强 schemaRPC、跨语言、高性能
Avroschema 演进能力强数据平台、消息系统
KryoJava 内高性能二进制内部缓存、计算框架

选型建议:

text
对外接口优先 JSON 或 Protobuf。
消息队列优先使用有 schema 的格式。
长期存储不要轻易使用 Java 原生序列化。
不可信输入不要使用 ObjectInputStream。

🛠 十四、序列化排查清单

常见异常:

异常原因排查
NotSerializableException某个字段对象不可序列化找异常里的类名
InvalidClassExceptionserialVersionUID 不一致检查类版本
ClassNotFoundException反序列化端缺类检查 classpath
OptionalDataException读写顺序不一致检查自定义协议
StreamCorruptedException流格式损坏检查文件和写入方式

排查建议:

text
确认所有非 transient 字段可序列化。
显式声明 serialVersionUID。
确认读写顺序一致。
确认反序列化端类版本。
不要混用文本和对象流写同一个文件。
不要反序列化用户上传的不可信文件。

✅ 十五、掌握标准

学完本课后,应能做到:

text
能解释序列化和反序列化的本质。
能使用 ObjectOutputStream/ObjectInputStream。
能说明 serialVersionUID 的作用。
能使用 transient 排除敏感或不可序列化字段。
能理解反序列化不调用构造器。
能用 readObject 恢复不变量。
能说明 Java 原生反序列化的安全风险。
能根据场景选择 JSON、Protobuf 等替代方案。
能使用 ObjectInputFilter 为可信边界增加白名单保护。

对象序列化看似方便,但它把对象结构、类版本和安全风险都耦合到字节流里。现代系统应优先选择更明确、更可控的数据交换格式。


🎓 下一步

  • 第22课:NIO — Buffer、Channel、Selector、Path、Files