跳到主要内容
约 11 分钟3 张卡

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。

攻击者可以利用依赖库中的类型组合,在业务字段校验之前触发副作用。存在可利用的调用链时,后果包括执行任意代码或耗尽资源,目标业务类本身无需包含漏洞。

不要默认反序列化不可信 Java 对象流

网络请求、用户上传、消息队列和外部可修改的存储数据,都应按不可信输入处理。外部协议应使用结构明确的数据格式,只构造预期的 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 字段反序列化后是什么值?​

9.4 什么时候应该改变 serialVersionUID?​

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 的新快照。第三次读取因此得到新的列表,之后的共享引用也从这次记录重新建立。

11. 参考资料​