Appearance
第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、缓存),且数据可信。
💡 八、最佳实践
- 显式声明 serialVersionUID,保证兼容性。
- 敏感/临时字段用 transient。
- 不要反序列化不可信数据,或用 ObjectInputFilter 白名单。
- 优先 JSON/Protobuf,Java 序列化仅限可信内部场景。
- 单例用 readResolve 防破坏。
- 引用的对象要 Serializable 或 transient。
📝 练习预告
完成 练习/Ex21_Serializable.java 中的 6 道题:
- 基本 序列化/反序列化
- transient 字段
- serialVersionUID 兼容性
- 对象图(嵌套引用)
- 自定义序列化(加密)
- 综合:实现可序列化的缓存
完成后对比 答案/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 | 二进制、高效、强 schema | RPC、跨语言、高性能 |
| Avro | schema 演进能力强 | 数据平台、消息系统 |
| Kryo | Java 内高性能二进制 | 内部缓存、计算框架 |
选型建议:
text
对外接口优先 JSON 或 Protobuf。
消息队列优先使用有 schema 的格式。
长期存储不要轻易使用 Java 原生序列化。
不可信输入不要使用 ObjectInputStream。🛠 十四、序列化排查清单
常见异常:
| 异常 | 原因 | 排查 |
|---|---|---|
NotSerializableException | 某个字段对象不可序列化 | 找异常里的类名 |
InvalidClassException | serialVersionUID 不一致 | 检查类版本 |
ClassNotFoundException | 反序列化端缺类 | 检查 classpath |
OptionalDataException | 读写顺序不一致 | 检查自定义协议 |
StreamCorruptedException | 流格式损坏 | 检查文件和写入方式 |
排查建议:
text
确认所有非 transient 字段可序列化。
显式声明 serialVersionUID。
确认读写顺序一致。
确认反序列化端类版本。
不要混用文本和对象流写同一个文件。
不要反序列化用户上传的不可信文件。✅ 十五、掌握标准
学完本课后,应能做到:
text
能解释序列化和反序列化的本质。
能使用 ObjectOutputStream/ObjectInputStream。
能说明 serialVersionUID 的作用。
能使用 transient 排除敏感或不可序列化字段。
能理解反序列化不调用构造器。
能用 readObject 恢复不变量。
能说明 Java 原生反序列化的安全风险。
能根据场景选择 JSON、Protobuf 等替代方案。
能使用 ObjectInputFilter 为可信边界增加白名单保护。对象序列化看似方便,但它把对象结构、类版本和安全风险都耦合到字节流里。现代系统应优先选择更明确、更可控的数据交换格式。
🎓 下一步
- 第22课:NIO — Buffer、Channel、Selector、Path、Files