Lesson 49 · Java 高级特性
序列化机制:Serializable、transient 与 serialVersionUID
Serializable 接口:一个空标记接口背后的魔法
Java 序列化是指将对象转换为字节流(序列化)和从字节流恢复对象(反序列化)的过程。核心接口是 java.io.Serializable——一个没有任何方法的标记接口(Marker Interface)。
public class User implements Serializable {
private String name;
private int age;
private transient String password; // ★ transient 字段不参与序列化
}
// 序列化:对象 → 字节流
User user = new User("张三", 28, "secret");
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("user.dat"))) {
oos.writeObject(user);
}
// 反序列化:字节流 → 对象
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("user.dat"))) {
User restored = (User) ois.readObject();
System.out.println(restored.name); // 张三
System.out.println(restored.age); // 28
System.out.println(restored.password); // null ★ transient 字段丢失
}
/*
* 为什么 Serializable 是空接口?
* 它只是一个"标记",告诉 JVM:这个类允许被序列化。
* 实际的序列化逻辑在 ObjectOutputStream 内部实现。
* 如果一个类没有 implements Serializable,序列化时会抛出 NotSerializableException。
*/
不会。反序列化通过 Unsafe.allocateInstance() 直接分配内存,跳过构造函数。但会调用最近的非 Serializable 父类的无参构造函数。所以:
- 如果父类没有实现 Serializable,父类字段会被初始化为默认值(不是反序列化的值)
- 如果父类也实现了 Serializable,父类字段正常恢复
serialVersionUID:版本兼容的"通行证"
serialVersionUID 是序列化流的版本号。反序列化时,JVM 会比较字节流中的 UID 和当前类的 UID,如果不一致就抛出 InvalidClassException。
// ═══ 版本 1:原始类 ═══
public class Order implements Serializable {
// 没有显式声明 serialVersionUID
private String orderId;
private double amount;
}
// 序列化一个 Order 对象,保存到文件/Redis/数据库
// ═══ 版本 2:新增字段 ═══
public class Order implements Serializable {
// JVM 自动生成的 UID 变了!(因为类结构变了)
private String orderId;
private double amount;
private String status; // 新增字段
}
// 反序列化之前保存的 Order 对象:
// ❌ InvalidClassException: Order;
// local class incompatible:
// stream classdesc serialVersionUID = -3287764903...
// local class serialVersionUID = 4829105732...
/*
* 正确做法:显式声明 serialVersionUID
*/
public class Order implements Serializable {
private static final long serialVersionUID = 1L; // ★ 显式声明!
private String orderId;
private double amount;
private String status; // 新增字段,反序列化时为 null(默认值)
}
1. 显式声明:
private static final long serialVersionUID = 1L;——开发者控制,类结构变化不影响2. JVM 自动生成:根据类名、字段、方法签名等计算一个 hash 值。只要类结构有任何变化(增删字段、修改方法),UID 就变
规则:永远显式声明 serialVersionUID。这是 Java 编码规范的铁律。
| 类结构变更 | 有显式 UID | 无显式 UID(自动生成) |
|---|---|---|
| 新增字段 | 反序列化成功,新字段为默认值 | UID 变化 → 反序列化失败 |
| 删除字段 | 反序列化成功,旧数据丢弃 | UID 变化 → 反序列化失败 |
| 修改字段类型 | 可能 ClassCastException | UID 变化 → 反序列化失败 |
| 新增方法 | 无影响 | UID 可能变化 → 反序列化可能失败 |
非常严重。假设 Redis 中缓存了序列化后的 Order 对象,新版本上线后 Order 类增加了字段。如果没显式声明 UID:
① 灰度发布期间,新旧版本的 UID 不同 → 反序列化报错
② 回滚版本后,之前新版本写入的数据也无法反序列化
③ 不同服务用不同版本的类 → 互相通信失败
这就是为什么生产环境不推荐 Java 原生序列化的原因之一。
transient 关键字:哪些字段不该序列化?
transient 修饰的字段不参与序列化。反序列化时,transient 字段被初始化为默认值(null / 0 / false)。
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private String username;
// ★ 场景 1:敏感数据不应序列化(安全)
private transient String password;
private transient String token;
// ★ 场景 2:可以重新计算的派生字段
private transient int age; // 可以从 birthday 重新算
// ★ 场景 3:非序列化类型的字段(Thread、Socket 等)
private transient Thread worker;
private transient Socket connection;
// ★ 场景 4:static 字段天然不参与序列化
// (static 属于类,不属于对象实例,不需要 transient)
private static String appName = "MyApp";
}
如果 transient 字段需要在反序列化后恢复值怎么办?JDK 提供了两个"钩子方法":
public class SensitiveData implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private transient String secret; // 不直接序列化
// ★ 自定义序列化:加密后写入
private void writeObject(ObjectOutputStream oos) throws Exception {
oos.defaultWriteObject(); // 先序列化非 transient 字段
oos.writeUTF(encrypt(secret)); // 再手动序列化 transient 字段(加密后)
}
// ★ 自定义反序列化:读取后解密
private void readObject(ObjectInputStream ois) throws Exception {
ois.defaultReadObject(); // 先反序列化非 transient 字段
this.secret = decrypt(ois.readUTF()); // 再手动恢复 transient 字段
}
// ★ 另一个钩子:readResolve()——控制反序列化返回的对象
// 常用于实现序列化安全的单例
private Object readResolve() {
return Singleton.INSTANCE; // 反序列化时返回已有单例,而非新建对象
}
}
ObjectOutputStream 在序列化时,会通过反射查找类中是否有 private void writeObject(ObjectOutputStream) 方法。如果有,就调用它而不是默认的序列化逻辑。这是一种"约定优于配置"的设计——虽然没有接口约束,但方法签名必须严格匹配。
Externalizable:完全自定义的序列化控制
Externalizable 是 Serializable 的子接口,提供了两个必须实现的抽象方法:writeExternal() 和 readExternal()。与 writeObject/readObject 钩子不同,Externalizable 要求你完全控制序列化过程。
public class Config implements Externalizable {
// ★ Externalizable 反序列化时会调用无参构造函数!
// 所以必须提供 public 无参构造(Serializable 不需要)
public Config() { }
public Config(String key, String value) {
this.key = key;
this.value = value;
}
private String key;
private String value;
@Override
public void writeExternal(ObjectOutput out) throws IOException {
// ★ 完全由你控制写什么、怎么写
out.writeUTF(key);
out.writeUTF(encrypt(value)); // 可以加密
out.writeInt(1); // 可以写版本号
}
@Override
public void readExternal(ObjectInput in) throws IOException {
// ★ 读取顺序必须和写入顺序完全一致
this.key = in.readUTF();
this.value = decrypt(in.readUTF());
int version = in.readInt();
// 可以根据 version 做兼容性处理
}
}
| 特性 | Serializable | Externalizable |
|---|---|---|
| 接口方法 | 无(标记接口) | writeExternal() / readExternal() |
| 序列化控制 | 自动 + 可选钩子 | 完全手动 |
| 无参构造 | 不需要 | 必须提供 public 无参构造 |
| transient 有效? | 有效 | 无效(你自己控制写什么) |
| serialVersionUID | 需要 | 需要 |
| 性能 | 较慢(反射探测) | 较快(直接调用) |
安全隐患:Java 反序列化漏洞
Java 原生序列化有一个著名的安全问题:反序列化可以执行任意代码。攻击者构造恶意字节流,在 readObject() 过程中触发危险操作。
/*
* 攻击原理(以 Apache Commons Collections 为例):
*
* 1. 攻击者构造一个特殊的 HashMap:
* HashMap → TiedMapEntry → LazyMap → ChainedTransformer
* → InvokerTransformer → Runtime.exec("rm -rf /")
*
* 2. HashMap 的 readObject() 会调用 hash() → hashCode()
* 触发 TiedMapEntry.hashCode() → LazyMap.get()
* → ChainedTransformer.transform() → Runtime.exec()
*
* 3. 只要服务端对输入的字节流执行 readObject(),就会触发命令执行
*
* 这是 2015 年 Gabriel Lawrence 发现的经典攻击链
* 影响了大量使用 Commons Collections 3.x 的系统
*/
// 防御措施:
// ① 使用 ObjectInputFilter(JDK 9+)限制可反序列化的类
ObjectInputStream ois = new ObjectInputStream(in);
ois.setObjectInputFilter(filterInfo -> {
if (filterInfo.serialClass() != null) {
String name = filterInfo.serialClass().getName();
// 只允许特定类被反序列化
if (name.startsWith("com.myapp.model."))
return ObjectInputFilter.Status.ALLOWED;
return ObjectInputFilter.Status.REJECTED;
}
return ObjectInputFilter.Status.UNDECIDED;
});
1. 不要反序列化不可信的数据——来自网络的字节流、用户提交的数据都不应使用 Java 原生反序列化
2. 升级依赖——Commons Collections 3.x 已修复,升级到 4.x+
3. 使用白名单——JDK 9+ 的 ObjectInputFilter 限制可反序列化的类
4. 根本方案——使用 JSON/Protobuf 等不包含"执行逻辑"的序列化格式
现代替代方案与总结
Java 原生序列化在性能、安全性、跨语言兼容性上都有明显不足。生产环境中几乎不会直接使用,而是采用以下替代方案:
| 方案 | 格式 | 跨语言 | 性能 | Schema 演进 | 典型场景 |
|---|---|---|---|---|---|
| Java 原生 | 二进制 | 否 | 差 | 差 | 不推荐 |
| Protobuf | 二进制 | 是 | 极好 | 好(字段编号) | gRPC、微服务通信 |
| Hessian | 二进制 | 是 | 好 | 中 | Dubbo RPC |
| Jackson JSON | 文本 | 是 | 中 | 好 | REST API、配置 |
| Kryo | 二进制 | 否 | 极好 | 差 | Spark、Flink 内部 |
| MessagePack | 二进制 | 是 | 好 | 中 | Redis 缓存 |
/* ═══ Protobuf(需要定义 .proto 文件)═══ */
// user.proto:
// message User { string name = 1; int32 age = 2; }
//
// protoc --java_out=. user.proto 生成 Java 类
User user = User.newBuilder().setName("张三").setAge(28).build();
byte[] data = user.toByteArray(); // 序列化(极小体积)
User restored = User.parseFrom(data); // 反序列化
/* ═══ Hessian(Dubbo 默认序列化)═══ */
Hessian2Output ho = new Hessian2Output(outputStream);
ho.writeObject(user); // 不需要 implements Serializable!
ho.flush();
Hessian2Input hi = new Hessian2Input(inputStream);
User u = (User) hi.readObject();
/* ═══ Jackson JSON ═══ */
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(user); // {"name":"张三","age":28}
User u2 = mapper.readValue(json, User.class);
/* ═══ Kryo(高性能,但不跨语言)═══ */
Kryo kryo = new Kryo();
kryo.register(User.class);
kryo.writeObject(output, user); // 序列化(速度极快)
User u3 = kryo.readObject(input, User.class);
Protobuf 使用字段编号(而非字段名)来标识字段。新增字段只需分配新编号,旧数据反序列化时忽略未知字段。删除字段只需标记为 reserved,不会冲突。这使得不同版本的服务可以同时运行,非常适合微服务的灰度发布。
- "为什么要显式声明 serialVersionUID?"——不声明时 JVM 会根据类结构自动生成,任何类结构变化都会导致 UID 变化,反序列化旧数据时抛出 InvalidClassException
- "transient 有什么用?"——标记不参与序列化的字段。反序列化时为默认值。可以配合 writeObject/readObject 自定义序列化逻辑
- "Serializable 和 Externalizable 的区别?"——Serializable 是自动序列化 + 可选钩子,Externalizable 是完全手动控制。Externalizable 必须有无参构造函数
- "为什么生产不推荐 Java 原生序列化?"——性能差(反射+冗余信息)、安全漏洞(反序列化执行任意代码)、不跨语言、版本兼容性差
- "生产用什么替代?"——RPC 用 Protobuf/Hessian,HTTP API 用 Jackson JSON,内部框架用 Kryo
面试回答模板:
"Java 序列化通过 Serializable 标记接口启用,ObjectOutputStream/ObjectInputStream 执行序列化/反序列化。serialVersionUID 用于版本兼容——不显式声明时 JVM 自动生成,类结构变化会导致 UID 不匹配而反序列化失败。transient 字段不参与序列化,适合标记敏感数据和非序列化资源。Java 原生序列化存在安全漏洞(反序列化可执行任意代码),性能也较差,生产环境推荐 Protobuf(gRPC 场景)、Hessian(Dubbo 场景)或 Jackson(REST API 场景)。"