← 返回题目列表

Java 序列化的原理是什么?为什么不建议反序列化不可信数据?

高频 困难 第 15 / 22 题 更新于 2026/07/25
序列化SerializableserialVersionUIDObjectInputFilter

简化版

Java 原生序列化通过 ObjectOutputStreamSerializable 对象及其可达对象图写成字节流,ObjectInputStream 再根据类描述和字段数据重建对象。它与 Java 类结构紧密耦合,版本演进脆弱,而反序列化还可触发类中的自定义代码。因此不应反序列化不可信数据;无法避免时要配置严格的 ObjectInputFilter,并优先使用 JSON、Protobuf 等可控协议。

详细版

Serializable 是标记接口,用来表示类允许使用 Java 原生序列化。默认机制会保存对象的非 static、非 transient 字段,并沿着其中的对象引用遍历对象图,通过句柄保留共享引用和循环引用关系。

高频规则:

  • static 字段属于类,不是对象状态,不参与默认序列化。
  • transient 字段被默认机制忽略,反序列化后是该类型的默认值,除非自定义读写。
  • serialVersionUID 用于校验流中类版本与当前类是否兼容。不显式声明时 JDK 会根据类结构计算,普通修改就可能导致 UID 改变。
  • 默认反序列化不调用可序列化类的普通构造器;继承链中第一个不可序列化父类的可访问无参构造器会被调用。如果该类没有可访问的无参构造器,反序列化会失败。

最大风险是:对象重建过程可以触发 readObjectreadResolve 等逻辑,一个危险的类组合可能在业务校验之前就造成副作用。

完整版教学

一、序列化的不是一个对象,而是对象图

final class Order implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    private String id;
    private Customer customer;
    private transient String accessToken;
}

序列化 Order 时,如果 customer 不是 null,它指向的 Customer 也必须可序列化,否则会抛出 NotSerializableException。如果多个字段指向同一个对象,流会记录句柄,反序列化后仍能保持“同一引用”的语义,而不是无限复制。

ObjectOutputStream.writeObject() 连续写同一对象时还会使用已有句柄。如果对象修改后希望写入新状态,需要理解 reset()writeUnshared() 的语义,否则可能读到与预期不同的共享引用。

二、serialVersionUID 能解决什么

serialVersionUID 是版本身份标识,不是自动升级方案。显式保持同一 UID,只代表你允许尝试按兼容规则读取旧数据,不代表任意类结构变更都能成功。

例如新增字段通常可以用默认值兼容,但删除必要字段、改变字段类型、调整类层次或改变不变式,都可能使旧数据无法安全使用。长期存储格式应该有显式 schema 和迁移策略,不应只依赖 UID。

三、反序列化为什么危险

反序列化不是单纯的“字节转字段”。类可以定义特殊钩子,并且对象图中的不同类可能在重建过程中产生组合副作用。攻击者可能构造特定对象图,触发类路径中已存在的危险操作,或构造过深、过大的对象图消耗 CPU 和内存。

最稳妥的策略是不反序列化不可信数据。如果遗留系统无法立即替换,应在创建的每个 ObjectInputStream 上应用尽可能严格的允许列表和资源限制:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
        "maxdepth=20;maxrefs=10000;maxbytes=1048576;"
      + "com.example.dto.*;java.lang.String;java.util.ArrayList;!*"
);

try (ObjectInputStream in = new ObjectInputStream(input)) {
    in.setObjectInputFilter(filter);
    Object value = in.readObject();
}

允许列表必须根据实际 DTO 对象图列出必需类,上面只是格式示例。Java 序列化过滤不能理解业务字段是否合法,也不代表配了过滤就可以安全接受任意数据。而且过滤策略需要显式配置,不能假设它会自动保护每个 ObjectInputStream

四、替代方案怎么选

  • 对外 HTTP API:JSON 可读性和生态较好,要配合 DTO 白名单、字段校验和大小限制。
  • 高性能跨语言 RPC:Protobuf、Avro 等有明确 schema 的格式更容易做兼容演进。
  • 持久化:使用数据库或有版本的文档/二进制格式,明确迁移逻辑。
  • 缓存和消息队列:数据协议应独立于具体 Java 类的实现细节。

五、对象重建顺序与不变式

默认反序列化会先分配对象并恢复字段,可序列化类的普通构造器不会像 new 那样执行。继承链上第一个不可序列化父类的可访问无参构造器会运行,因此安全校验若只写在可序列化子类构造器中,反序列化路径可能绕过它。

类可通过私有 readObject 读取字段并重新校验不变式,也可使用 serialization proxy pattern 把稳定代理作为流格式。但这些机制增加了隐藏执行路径,正是原生序列化难以审计的原因。对外协议使用显式 DTO、schema 和普通解析校验通常更清晰。

读取类描述 → 分配对象/恢复对象图 → 执行序列化钩子 → 返回业务对象
                                      ↑ 风险在业务拿到对象前就可能发生

六、用版本例子理解兼容边界

假设 v1 的 Order 只有 idamount,UID 为 1;v2 新增 currency,仍保持 UID 为 1。读取旧流时新字段通常得到默认值 null,程序必须把它解释为旧版默认币种或执行迁移,UID 本身不会替你补业务语义。

如果把 amountlong 改成 BigDecimal,即使强行保留 UID,字段类型不兼容仍可能导致 InvalidClassException 或业务不变式破坏。长期保存 1,000,000 条订单时,逐条版本迁移、回滚和跨语言访问都要求显式 schema,而不是只保留一个数字 UID。

变更保持 UID 后的典型结果仍需处理的问题
新增可选字段旧流中为默认值默认语义与校验
删除字段旧数据可能被忽略信息丢失
改变字段类型通常不兼容数据迁移
改变类层次/不变式风险较高构造与安全约束

心法:serialVersionUID 只回答“是否允许尝试兼容”,不回答“旧数据在新业务里是否仍然正确”。

七、常见误区与追问

  • 误区:实现 Serializable 后对象就天然适合长期存储。 原生格式与 Java 类结构耦合,缺少独立 schema 和清晰迁移策略。
  • 误区:serialVersionUID 相同就一定兼容。 字段类型、类层次和业务不变式变化仍可能失败或产生错误语义。
  • 误区:transient 字段永远无法进入序列化流。 默认机制忽略它,但自定义 writeObject 仍可以显式写入,因此敏感数据要审查完整代码路径。
  • 误区:配置 ObjectInputFilter 后即可放心接收任意数据。 过滤器只能限制类和对象图资源,不能验证所有业务字段或消除已允许类的逻辑风险。
  • 追问:为什么反序列化不调用普通构造器很危险? 构造器中的校验可能被绕过,类必须在反序列化钩子或代理中重新建立不变式。
  • 追问:过滤器应限制哪些资源? 除类允许列表外,还应按场景限制深度、引用数、数组长度和输入字节数。
  • 追问:JSON 是否绝对安全? 不是;仍要限制大小、类型绑定和字段值,但它通常避免了任意 Java 对象图与序列化钩子的隐式执行。

八、加强记忆

Serializable 只说明对象技术上可以被 Java 原生机制转换,不代表它适合做对外协议或长期存储。serialVersionUID 是兼容性身份标识,不是自动升级方案;对不可信数据应避免原生反序列化,无法避免时要使用严格允许列表和对象图资源限制。