Java 序列化深度实战——Java 原生序列化的7个坑与替代方案选型
Java 序列化深度实战——Java 原生序列化的7个坑与替代方案选型
适读人群:有 Java 项目开发经验,涉及对象序列化场景的开发者 | 阅读时长:约 16 分钟 | 核心价值:系统认识 Java 原生序列化的问题,给出清晰的替代方案选型建议
2022 年 8 月,我们有一个服务出了个生产事故,根本原因是 Java 原生序列化的兼容性问题。
背景:服务 A 和服务 B 共享一个对象类 UserProfile,通过 Redis 序列化存储。服务 A 升级,给 UserProfile 加了一个新字段,但 serialVersionUID 没有改。服务 B 还是旧版本,从 Redis 读出来反序列化时抛出了 InvalidClassException,直接导致接口 500。
错误日志:
java.io.InvalidClassException: com.example.model.UserProfile;
local class incompatible: stream classdesc serialVersionUID = 3847291029384712,
local class serialVersionUID = -7394812930123847291两个 serialVersionUID 不一样。问题在于,serialVersionUID 如果不手动声明,JVM 会根据类的结构自动生成,加了字段之后自动生成的值就变了,反序列化就爆了。
那次事故让我花了一天时间深入研究 Java 序列化,这篇文章把我的研究结果整理出来。
坑一:不显式声明 serialVersionUID
上面说的事故就是这个坑。
// 危险:没有声明 serialVersionUID
public class UserProfile implements Serializable {
private String userId;
private String name;
// 加了新字段,自动生成的 serialVersionUID 会变
}正确做法:
package com.example.model;
import java.io.Serializable;
public class UserProfile implements Serializable {
// 显式声明 serialVersionUID,不让 JVM 自动生成
// 一旦声明了,后续加字段不会影响序列化兼容性(在反序列化时不认识的字段会被忽略)
private static final long serialVersionUID = 1L;
private String userId;
private String name;
private String email; // 后来加的字段,不会破坏兼容性
}serialVersionUID = 1L 这个值没有任何魔法,它就是一个版本标识。只要你不人为改变它,新旧版本的类就能互相反序列化(新字段会用默认值填充)。
坑二:transient 字段和 static 字段不参与序列化
这个行为本身不是 bug,是设计。但很多人不知道,导致数据意外丢失。
package com.example.model;
import java.io.Serializable;
public class Session implements Serializable {
private static final long serialVersionUID = 1L;
private String sessionId;
// transient:不参与序列化,反序列化后这个字段是 null
private transient String cachedToken;
// static:不参与序列化,static 字段属于类,不属于实例
private static int instanceCount = 0;
// 如果你把敏感信息标为 transient,是正确的安全实践
private transient String password;
}有一次我们把一个 Map 类型的缓存字段加了 transient,反序列化之后发现每次读出来的对象里这个 Map 都是 null,然后代码里有个地方没做 null 检查,直接 NPE。排查了半天才找到原因。
坑三:序列化不调用构造器,绕过了校验逻辑
这是一个安全问题,也是一个逻辑正确性问题。
Java 反序列化不走构造函数,它直接从字节流重建对象状态。如果你的构造函数里有重要的校验逻辑,反序列化就绕过去了:
public class Money implements Serializable {
private static final long serialVersionUID = 1L;
private long amount;
public Money(long amount) {
if (amount < 0) {
throw new IllegalArgumentException("金额不能为负"); // 这里的校验在反序列化时不执行
}
this.amount = amount;
}
}如果攻击者构造一个 amount = -1 的字节流,反序列化出来就是一个非法的 Money 对象,构造器里的校验完全没用。
解决方案是实现 readObject 方法:
package com.example.model;
import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.Serializable;
public class Money implements Serializable {
private static final long serialVersionUID = 1L;
private long amount;
public Money(long amount) {
if (amount < 0) throw new IllegalArgumentException("金额不能为负");
this.amount = amount;
}
// 反序列化时会调用这个方法,在这里重新做校验
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject(); // 先走默认的字段填充
// 然后重新校验
if (amount < 0) {
throw new InvalidObjectException("反序列化的金额不能为负: " + amount);
}
}
}坑四:序列化图太深,StackOverflow
如果你有一个深度嵌套的对象图(比如树形结构),Java 序列化使用递归遍历,很容易打爆调用栈:
java.lang.StackOverflowError
at java.io.ObjectStreamClass.lookup(ObjectStreamClass.java:372)
at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1134)
...我在一个递归表示的部门树上踩过这个坑,部门树有 300 多层,序列化直接 StackOverflow。
解决方案:
- 改造数据结构,避免深度递归(用迭代或父 ID 表示)
- 增大 JVM 栈大小(治标,不推荐):
-Xss4m - 用 JSON 等其他格式序列化,Jackson 对递归有保护机制
坑五:序列化性能差
Java 原生序列化的性能在各种序列化方案里属于比较差的那一档。
我做过一个简单的测试(序列化/反序列化 100 万次,一个包含 10 个字段的 POJO):
| 方案 | 序列化耗时 | 反序列化耗时 | 序列化大小 |
|---|---|---|---|
| Java 原生 | ~2800ms | ~3100ms | 487 bytes |
| Jackson JSON | ~980ms | ~1100ms | 203 bytes |
| Protobuf | ~210ms | ~190ms | 89 bytes |
| Kryo | ~180ms | ~160ms | 112 bytes |
这不是精确 benchmark,但量级关系是对的。Java 原生序列化在大多数场景下是最慢的,而且序列化出来的数据还是最大的。
坑六:反序列化漏洞(最严重的坑)
Java 反序列化漏洞是 Java 安全史上影响最大的漏洞类型之一。核心原理是:反序列化时会执行字节流中指定的类的 readObject 方法,如果 classpath 上有"危险类"(如 Apache Commons Collections 早期版本),攻击者可以构造特殊字节流,让服务器执行任意代码。
我的立场很明确:在任何对外暴露的接口上,不要接收 Java 原生序列化数据。
这不是技术能力的问题,是架构原则的问题。即使你对所有已知漏洞都做了补丁,也不代表没有新漏洞。
如果你的系统还在使用 Java 序列化协议对外通信(比如 RMI),应该认真评估迁移计划。
坑七:序列化单例破坏了单例性
// 序列化会破坏单例
public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }
}反序列化会创建一个新的 Singleton 实例,== 比较两个 Singleton 实例会返回 false,单例被破坏了。
修复方法:实现 readResolve 方法:
package com.example.singleton;
import java.io.ObjectStreamException;
import java.io.Serializable;
public class SafeSingleton implements Serializable {
private static final long serialVersionUID = 1L;
private static final SafeSingleton INSTANCE = new SafeSingleton();
private SafeSingleton() {}
public static SafeSingleton getInstance() {
return INSTANCE;
}
// 反序列化完成后,JVM 会调用这个方法,用返回值替换反序列化出来的对象
// 这样就保证了单例性
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}替代方案选型:我的建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| REST API / HTTP 通信 | Jackson JSON | 生态最好,调试方便,人可读 |
| 微服务内部 RPC | Protobuf | 性能好,类型安全,跨语言 |
| 缓存(Redis) | Jackson JSON 或 Protobuf | 不要用原生序列化,升级时会有兼容性问题 |
| 高性能本地序列化 | Kryo | 同一 JVM 版本内使用,性能最好 |
| 消息队列 | Protobuf 或 JSON | 取决于消费者是否跨语言 |
我不选 Java 原生序列化的场景: 几乎所有需要持久化或网络传输的场景。
Java 原生序列化还能用的场景: 几乎没有。唯一可以接受的是短期的内存内对象克隆(但即便如此,深拷贝用序列化也比自己实现 clone 慢得多)。
最后:Protobuf 的简单示例
package com.example.proto;
// 假设你已经有了 user.proto 生成的 User.java 类
// 以下是 Protobuf 序列化的基本用法
import com.example.proto.generated.UserProto.User;
public class ProtoSerializationDemo {
public static void main(String[] args) throws Exception {
// 序列化
User user = User.newBuilder()
.setUserId("user_20230811")
.setName("张三")
.setAge(28)
.build();
byte[] bytes = user.toByteArray(); // 序列化为字节数组
// 反序列化
User deserialized = User.parseFrom(bytes);
System.out.println("userId: " + deserialized.getUserId());
System.out.println("name: " + deserialized.getName());
System.out.println("序列化大小: " + bytes.length + " bytes");
}
}Protobuf 的 proto 文件定义字段时有编号,字段名可以改(只要编号不变),天然支持向前/向后兼容,完美解决了 Java 原生序列化的兼容性问题。
