Java 序列化
Java 原生序列化把对象及其引用的对象写入字节流,再按类信息恢复。对象和它们之间的引用关系构成对象图。本文先解释字段和构造方法如何参与恢复,再说明版本兼容、自定义读写和不可信输入的风险。
这种格式与 Java 类型紧密绑定,适合短期、受控的 JVM 内部状态。对外交换数据或长期存储时,需要另外考虑协议演进与输入校验。
1. 面试核心
先记住这几条,再读正文- 默认保存范围。 实现
Serializable的类可由对象流写出,默认保存非static、非transient的实例字段。卡 · Serializable - 对象恢复。 普通可序列化类恢复时跳过自身构造器,执行的是第一个不可序列化父类的可访问无参构造器。
- 版本标识。 普通类的
serialVersionUID与流中标识不匹配时,会抛出InvalidClassException。 - 不可信输入。 不可信对象流能触发危险方法调用或耗尽资源,
ObjectInputFilter可以限制类型与对象图规模。卡 · 不可信输入风险 - 重复写入句柄。 同一输出流再次写入同一对象时,会用句柄引用首次写出的对象,修改后的字段不会自动重写。卡 · 对象句柄与特殊构造规则
2. Serializable
Serializable 是标记接口,也就是用类型关系声明能力,而不要求实现接口方法。类实现它后,ObjectOutputStream 就可以按序列化规则写出对象:
public final class UserProfile implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final long userId;
private final String displayName;
public UserProfile(long userId, String displayName) {
this.userId = userId;
this.displayName = displayName;
}
}
下面使用对象流写入 UserProfile,再从文件中读取:
try (ObjectOutputStream output = new ObjectOutputStream(
Files.newOutputStream(path)
)) {
output.writeObject(profile);
}
try (ObjectInputStream input = new ObjectInputStream(
Files.newInputStream(path)
)) {
UserProfile restored = (UserProfile) input.readObject();
}
写入时,默认序列化会保存实例字段,并沿引用继续处理其他对象。读取时,ObjectInputStream 再根据类信息恢复这些对象及其关系。默认保存的字段若指向不可序列化对象,写入会抛出 NotSerializableException。
2.1 默认序列化的字段范围
默认序列化保存对象的实例状态,其中有两类字段会被排除:
static字段属于类,由类管理,因此不作为单个对象的状态写入。transient字段表示这份状态需要跳过默认序列化,比如可以重新计算的缓存。
private transient String accessToken;
示例中的 accessToken 会被默认序列化跳过。临时缓存、文件句柄和敏感派生值也可以这样排除。保护敏感信息时,还要检查其他字段、嵌套对象和自定义写入方法,因为它们仍能把数据写入流中。
2.2 反序列化与构造方法
反序列化恢复普通 Serializable 类时,会跳过可序列化类的构造器和实例字段初始化表达式。它执行的是继承链中第一个不可序列化父类的可访问无参构造器,随后恢复流中保存的字段。
所以,构造器里的参数检查也会被跳过。对象必须始终满足的约束称为不变量,比如用户 ID 必须大于 0。读取外部数据时,需要在 readObject 等读取钩子中重新校验这些约束。
3. serialVersionUID
serialVersionUID 是普通可序列化类的版本标识。反序列化会比较流中的标识与本地类的标识,不匹配时抛出 InvalidClassException。record 和枚举采用专门规则,后文会分别说明。
@Serial
private static final long serialVersionUID = 1L;
未显式声明时,运行时会按类结构计算默认 UID。细小的结构变化,或编译器生成成员的差异,都可能改变计算结果。因此,需要兼容旧数据时应显式声明 UID,再验证字段演进是否兼容。
3.1 UID 与语义兼容
UID 相同只是通过了版本标识检查,字段仍需满足兼容规则。判断新类能否读取旧数据时,要检查以下四类变化:
- 基本类型字段改变类型属于不兼容变化,例如把
int改为long。 - 读取旧流时,新增字段得到 Java 默认值。默认值是否是有效业务状态,需要读取方校验。
- 删除字段后,旧流里对应的信息会被忽略,因此新对象会丢失这部分状态。
- 类的继承关系和自定义读写钩子发生变化时,也要验证恢复结果是否符合预期。
保持 UID 不变,就需要保证新类能够正确解释旧数据。无法兼容时,应改变 UID 并明确拒绝读取,或者提供独立工具迁移历史数据。
3.2 长期数据的模式版本
数据库记录、跨服务消息和长期文件需要跨越多次发布。它们需要明确的字段兼容规则、版本迁移方式和多语言支持,serialVersionUID 只处理类版本匹配。
JSON、Protocol Buffers 和 Avro 可以把传输格式与 Java 类实现分开。比如,Protocol Buffers 用字段编号标识字段,JSON 则需要另外约定字段结构和校验规则。选择格式时,要一起比较体积、解析性能、兼容能力与维护成本。
4. 自定义序列化
需要补充字段校验或保存派生状态时,类可以用特定签名的私有方法参与读写。writeObject 和 readObject 是对象流会调用的钩子,下面在恢复字段后检查用户信息:
@Serial
private void writeObject(ObjectOutputStream output) throws IOException {
output.defaultWriteObject();
}
@Serial
private void readObject(ObjectInputStream input)
throws IOException, ClassNotFoundException {
input.defaultReadObject();
if (userId <= 0 || displayName == null || displayName.isBlank()) {
throw new InvalidObjectException("invalid user profile");
}
}
defaultWriteObject() 写出默认字段,defaultReadObject() 则恢复它们。恢复后,如果用户 ID 无效或显示名称为空,示例会抛出 InvalidObjectException。这样,即使构造器没有执行,非法状态也会被拒绝。
除了这两个钩子,还可以使用以下三种扩展方式:
writeReplace在写入前替换对象,让流保存替代对象的表示。readResolve在读取后替换恢复结果,常用于返回已有单例或统一复用的对象。Externalizable让类通过自己的方法控制格式和状态恢复,其构造规则见后文。
自定义读写会增加兼容测试和安全检查的工作。采用前应明确需要恢复什么状态、兼容什么版本。如果只是为了少写几个字节,应先测量节省的体积,再判断是否值得增加这些维护工作。
5. 不可信输入风险
反序列化会依据流中的类型信息创建对象,并调用恢复对象所需的方法。因此,读取对象本身就会执行逻辑,风险集中在以下几类行为:
- 对象流会调用
readObject、readResolve等自定义钩子。 - 集合重建时会执行比较、哈希等方法,进一步调用元素所属类型的代码。
- 大数组、深层对象图和大量引用会消耗内存与计算资源。
- 类路径中的现有类型可以被组合成危险调用链,这类组合称为 gadget chain。
攻击者可以利用依赖库中的类型组合,在业务字段校验之前触发副作用。存在可利用的调用链时,后果包括执行任意代码或耗尽资源,目标业务类本身无需包含漏洞。
网络请求、用户上传、消息队列和外部可修改的存储数据,都应按不可信输入处理。外部协议应使用结构明确的数据格式,只构造预期的 DTO,再校验字段。DTO 是用于传递数据的对象,其类型与字段由应用明确约定。
必须兼容原生对象流时,应在读取对象前配置类型与规模限制。
6. 反序列化过滤器
必须读取原生对象流时,可以用 ObjectInputFilter 拒绝意外类型和过大的对象图。过滤器要在 readObject() 之前设置,示例采用明确的类型允许列表:
try (ObjectInputStream input = new ObjectInputStream(source)) {
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"maxdepth=10;maxrefs=1000;maxarray=10000;maxbytes=1048576;"
+ "com.example.UserProfile;!*"
);
input.setObjectInputFilter(filter);
UserProfile profile = (UserProfile) input.readObject();
}
示例设置了以下类型与规模限制:
- 类名规则允许
com.example.UserProfile,末尾的!*拒绝其他被检查的类。 maxdepth=10限制对象图的最大深度,防止引用关系过深。maxrefs=1000限制读取过程中的引用数量。maxarray=10000限制单个数组的长度。maxbytes=1048576在过滤回调时检查已经读取的字节数。
允许列表应覆盖整个合法对象图,并测试所有仍需兼容的历史版本。宽泛放行整个包,会让其中更多类型进入反序列化过程,扩大可被利用的范围。
以字符串形式直接编码的内容和基本类型值不会逐项触发过滤。因此,maxbytes 不是输入流的硬字节上限,外层还需要限制整条消息的大小。字符串内容、字段含义和访问权限仍由业务校验,过滤器只增加了一层防护。
7. 适用范围
原生序列化适合双方受控、数据短期使用的场景。评估时,应同时满足以下使用前提:
- 写入方与读取方由同一应用和发布流程控制。
- 数据生命周期短,不需要多年兼容。
- 输入无法被不可信主体替换。
- 对象图类型范围小,并配置了过滤器。
- 现有性能和体积能满足需求,而且无需让其他语言读取。
以下跨边界或长期使用场景应优先选择其他格式:
- 数据通过对外 HTTP、RPC 或消息协议交换。
- 数据需要长期归档或保存在数据库字段中。
- 数据需要由浏览器、移动端或其他语言读取。
- 插件能够任意扩展类路径,反序列化可用的类型范围随之变化。
- 缓存中的历史字节来源不明,无法确认是否被外部修改。
8. 对象句柄与特殊构造规则
同一对象被多次引用时,对象流需要保留这种共享关系。它在首次写入对象时分配句柄,也就是流内标识这个对象的编号。后续再次写入同一个对象时,只写出句柄引用,所以两次写入之间的字段修改不会自动保存:
List<String> values = new ArrayList<>();
values.add("A");
output.writeObject(values);
values.add("B");
output.writeObject(values); // 引用首次写出的列表
output.reset();
output.writeObject(values); // 新快照包含 A、B
接收方前两次读取得到同一个列表,其中只有 A。reset() 清除流中的对象记录后,第三次写入才会保存包含 A、B 的新快照。句柄也用于记录循环引用,让对象能指回已经写出的对象。
恢复对象时,除了普通 Serializable 类,还要区分以下三种特殊规则:
Externalizable先调用公开无参构造器,再执行readExternal恢复状态。- record 在 Java 16 成为正式特性。可序列化 record 通过规范构造器恢复,也就是接收全部记录组件的构造器。它会忽略
readObject等定制钩子,并豁免 UID 匹配要求。 - 枚举按名称恢复为已有枚举常量,其 UID 固定为 0。改名或删除常量后,旧数据中的名称就无法匹配。
因此,回答构造器是否执行或 UID 如何匹配时,需要说明讨论的是哪一种类。
9. 常见问题
实现了序列化接口之后,还需要区分机制入口、对象状态与版本演进。
9.1 Serializable 为什么没有方法?
Serializable 只负责声明这个类允许参与序列化,因此无需定义读写方法。实际工作由 ObjectOutputStream、ObjectInputStream 和类提供的可选钩子完成。
9.2 所有字段都可序列化,就能安全恢复对象吗?
字段可序列化只满足机制上的要求,还要检查对象图规模、钩子行为和版本兼容。循环引用可以由句柄表示,但庞大的对象图仍会消耗资源。自定义钩子执行失败,或新旧类无法兼容,也会阻止对象恢复。
9.3 transient 字段反序列化后是什么值?
普通可序列化类的 transient 字段在默认恢复后得到 Java 默认值,例如引用为 null、整数为 0。这是因为流中没有保存它,类的实例字段初始化表达式也被跳过。可以重新计算的派生状态,应在读取钩子或首次使用时重建。
9.4 什么时候应该改变 serialVersionUID?
新类无法可靠解释旧数据时,应改变 UID 并拒绝读取。如果能保持反序列化兼容,也能正确处理新增字段的默认值,就可以保持 UID 不变。改变前还要安排历史数据的去向:迁移到新格式、清除,或继续交给旧版本读取。
10 · 面试题卡片
10.1 Java 原生序列化流程是什么,serialVersionUID 有什么作用
出现公司:网易、保利威、字节跳动
考察重点
- 解释实现
Serializable后,对象流怎样沿引用关系写出和恢复对象。 - 说明 UID 不一致时的结果,以及 UID 一致时还要验证哪些兼容规则。
- 说明
static、transient字段与普通可序列化类构造器各如何处理。
相关内容:Serializable、serialVersionUID。
参考回答
原生序列化用对象流写出并恢复对象图,serialVersionUID 用于检查普通类的版本是否匹配。
类实现 Serializable 后,ObjectOutputStream 沿引用关系写出默认实例状态。其中,static 和 transient 字段被排除。ObjectInputStream 再根据类信息恢复对象图。普通可序列化类会跳过自身构造器,因此读取钩子还要校验对象必须满足的约束。
流中的 UID 与本地类不匹配时,会抛出 InvalidClassException。显式声明 UID 可以稳定版本标识,避免类结构变化导致默认值意外改变。不过,字段和业务含义是否兼容,仍需单独验证。
10.2 为什么不应该反序列化不可信的 Java 对象流
出现公司:奇安信
考察重点
- 解释反序列化为什么会调用类型相关方法,而不只是填充字段。
- 说明对象图怎样耗尽资源,以及现有类型如何组成危险调用链。
- 说明
ObjectInputFilter的限制范围,并区分类型过滤与业务校验。
参考回答
反序列化会执行对象恢复逻辑,不可信对象流可以利用这些逻辑触发危险行为或耗尽资源。
ObjectInputStream 会调用 readObject、readResolve 等钩子,集合恢复还会执行比较和哈希方法。攻击者可以组合类路径中的现有类型,在字段校验之前触发副作用。过深或过大的对象图也会消耗大量内存和计算资源。
外部协议应采用结构明确的数据格式,只构造预期 DTO,再校验字段。必须兼容原生流时,用 ObjectInputFilter 限制类型、深度、引用数和数组长度。字节数限制在过滤回调时检查,所以外层还要限制消息大小,业务仍要校验字段含义与权限。
10.3 同一对象修改后再次 writeObject,为什么读不到新状态?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释对象句柄怎样保留共享引用,以及修改后重新保存快照需要什么条件。
相关内容:对象句柄与特殊构造规则。
参考回答
同一输出流再次写入同一个对象时,只写出已有句柄,因此接收方仍得到首次保存的状态。
对象字段改变后,流中的对象记录仍然有效。例如,先写入只含 A 的列表,再向同一列表添加 B 并写入,接收方前两次读取仍得到同一个只含 A 的列表。
调用 output.reset() 清除记录后再次写入,才会保存包含 A、B 的新快照。第三次读取因此得到新的列表,之后的共享引用也从这次记录重新建立。