序列化核心概念(Serializable)
2019年双十一,Redis 崩了 3 分钟。排查后发现:某服务升级后 serialVersionUID 没声明,新版本算出的 UID 和缓存里的不匹配——线上几百万用户数据反序列化全挂了。
对象的一生:从 JVM 出生,在 JVM 里活着,最后要么被 GC 回收,要么...序列化后继续存在于磁盘或网络里。
从对象生命周期理解序列化
一个 Java 对象的命运其实有三条路:
1. 普通死亡:对象无人引用 → GC 回收 → 尘归尘土归土
2. 序列化死亡:对象被序列化到文件/Redis/网络 → 等待被唤醒
3. 克隆死亡:对象被 clone() → 产生一个副本序列化就是那条让对象「死而复生」的路。你把它转成字节流,存到任何地方;需要的时候,再把字节流转回对象。
内存对象 ──序列化──▶ 字节流 ──▶ 文件 / Redis / 网络 / 消息队列
内存对象 ◀──反序列化── 字节流 ◀── 文件 / Redis / 网络 / 消息队列Serializable 接口:入场券
java.io.Serializable 是一个标记接口(marker interface),没有任何方法。它存在的意义只有一个:告诉 JVM「这个类的对象可以被序列化」。
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private transient String password; // 这个字段不会被序列化
}没实现它就序列化? 恭喜,你会收到一个 NotSerializableException:
java.io.NotSerializableException: class User does not implement Serializable三个容易混淆的点
| 修饰符 | 序列化行为 | 原因 |
|---|---|---|
| 普通字段 | ✅ 序列化 | 属于对象状态 |
static 字段 | ❌ 不序列化 | 属于类,不属于对象 |
transient 字段 | ❌ 不序列化 | 明确声明不需要 |
序列化操作:writeObject / readObject
JDK 提供了一对流工具来完成序列化和反序列化:
// 序列化:把对象写入文件
User user = new User("张三", 25, "secret");
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(
new FileOutputStream("user.obj")))) {
oos.writeObject(user);
}
// 反序列化:把对象从文件读出来
try (ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(
new FileInputStream("user.obj")))) {
User restored = (User) ois.readObject();
System.out.println(restored.getName()); // 张三
}关键细节:反序列化不调用构造函数,对象直接从字节流重建。这很重要——如果你在构造函数里做初始化,别指望反序列化会执行它。
序列化集合
集合本身就是对象,直接序列化即可:
List<User> users = Arrays.asList(
new User("张三", 20, "pass1"),
new User("李四", 25, "pass2")
);
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("users.obj"))) {
oos.writeObject(users); // 整个 List 序列化
}serialVersionUID:版本号的故事
每个序列化类都有一个版本号。序列化时,这个值会写入流;反序列化时,JVM 比对流中的 UID 和当前类的 UID——不一致就抛 InvalidClassException。
┌─────────────────────────────────────────────────────────────┐
│ 如果你不声明 UID,编译器会根据类结构自动生成一个 │
│ 类名、字段、方法签名,任何改动都会让 UID 改变 │
└─────────────────────────────────────────────────────────────┘这意味着:旧代码序列化了一个对象,升级代码改了字段,旧数据就读不回来了。
最佳实践:始终显式声明 serialVersionUID,只在不兼容变更(删字段、改字段类型)时才修改它。
transient:跳过敏感数据
transient 修饰的字段在序列化时被跳过,反序列化后得到类型默认值:
public class User implements Serializable {
private String name;
private transient String password; // 序列化时跳过
private transient String sessionToken; // 缓存,没必要序列化
// 反序列化后:
// name = "张三"
// password = null
// sessionToken = null
}典型应用场景:
- 敏感信息:密码、密钥、身份证号
- 运行时数据:缓存、数据库连接、临时状态
- 可重新计算的数据:派生字段、冗余缓存
父子类序列化:一个容易踩的坑
如果父类没有实现 Serializable,子类序列化时父类字段会丢失:
public class Person { } // 没有 Serializable
public class User extends Person implements Serializable {
private String name;
// Person 的字段不会被序列化!
}解法:让父类也实现 Serializable。
单例与序列化:反序列化破坏单例
反序列化会创建新对象。如果你的类用了单例模式,反序列化会破坏它:
public class Singleton implements Serializable {
private static final Singleton INSTANCE = new Singleton();
private Singleton() { }
// 反序列化时返回同一个实例
protected Object readResolve() {
return INSTANCE;
}
}readResolve() 方法在反序列化完成后被调用,返回的对象会替换原对象——保护单例的经典手法。
典型应用场景
- 持久化存储:对象存入文件或数据库
- 网络传输:RMI、WebService、RPC 框架(Dubbo、gRPC)
- 缓存:Redis 把对象序列化后存储
- Session 复制:Tomcat 等容器中 session 跨 JVM 传递
记住这个口诀:对象要「出 JVM」必须序列化,出去了还能回来靠 UID。
