云计算百科
云计算领域专业知识百科平台

Java 序列化进阶实战:循环引用、反序列化漏洞与生产避坑指南

文章收录专栏:Java 核心原理全解:源码・并发・面试实战

前言

本文为 Java 序列化系列 · 下篇(进阶生产篇) 承接上篇基础内容,专注底层原理、对象缓存机制、版本兼容高阶API、序列化代理模式、反序列化安全漏洞、JDK版本变化、数据库存储实战、线上踩坑复盘、企业选型最佳实践。

适合已经掌握Serializable基础,想要深挖底层、规避线上BUG、应对高阶面试的开发者。

👉 基础概念、transient、serialVersionUID、继承规则请阅读上篇: 【面试必背】Java序列化:Serializable、transient、serialVersionUID完整梳理


一、底层核心:对象引用缓存与循环引用

1.1 缓存实现原理

ObjectOutputStream 内部持有一个对象‑输出句柄映射表。 每序列化一个对象,就把对象存入缓存,后续流中遇到同一个对象,不会重复序列化对象数据,只会输出一个引用编号。

带来两个特性:

  • 天然支持对象循环引用,不会无限递归栈溢出
  • 同一流多次写出同一个对象,读取得到的是同一个对象引用
  • 1.2 高频生产BUG:修改对象后再次序列化不生效

    User user = new User("张三",18);
    oos.writeObject(user);
    // 修改对象属性
    user.setName("李四");
    oos.writeObject(user);

    反序列化出来,第二个对象名字依旧是张三。

    原因:流缓存已经记录该对象,直接输出引用,不会重新扫描对象最新状态。

    1.3 两种解决方案

  • oos.reset():清空整个流的全部对象缓存,适合简单场景;缺点会重置全部历史对象记录。
  • oos.writeUnshared(user):本次不使用缓存,强制把当前对象完整序列化一遍;不会改动历史缓存。
  • 面试考点:区分 writeObject 和 writeUnshared 的区别。

    二、高阶版本兼容API

    2.1 readObjectNoData()

    当反序列化时,流里面完全没有当前子类的任何字段数据时,才会回调这个方法。 典型场景:父类子类继承关系发生改动,旧版本字节流缺少子类信息。

    注意:仅仅新增几个字段,不会触发 readObjectNoData,很多面试题在这里挖坑。 在方法内可以给字段设置业务默认值,避免字段为null。

    2.2 ObjectInputFilter 输入过滤

    JDK9 正式引入,JDK8u121之后回移植到JDK8。 用来做反序列化黑白名单,限制允许反序列化的类,拦截恶意 Gadget 类,是JDK官方提供的防御手段。

    三、readResolve / writeReplace 深度剖析(面试高频)

    上篇简单介绍,这里补充生产坑。

    • writeReplace:序列化发生之前,替换要写入流的对象。
    • readResolve:反序列化完成,对象构造出来之后,替换最终返回给程序的对象。

    3.1 单例序列化漏洞

    仅仅单例私有构造,没有 readResolve,反序列化会产生全新对象,破坏单例。

    private Object readResolve() {
    return INSTANCE;
    }

    坑点:readResolve 只改变返回对象,流里面依旧保存着旧实例的完整字节数据,不会改变流内容。

    3.2 和序列化代理模式的区别

    readResolve 只是替换返回对象;序列化代理模式会把代理对象真正写入字节流,安全性更高。

    四、Externalizable 生产坑点回顾

    Externalizable 完全自己实现读写逻辑。

  • 必须有无参构造器,反序列化会反射调用无参构造;没有直接抛异常。
  • transient 修饰的字段依然需要手动读写,transient关键字失效。
  • serialVersionUID依然生效。
  • 业务中极少使用,大部分场景用Serializable即可。
  • 五、序列化代理模式(Effective Java推荐最安全方案)

    普通 readResolve 存在攻击绕过风险,Effective Java 重点推荐序列化代理模式。

    核心思路:

  • 为业务实体编写一个私有静态代理类,代理类保存业务对象全部核心字段。
  • 原类实现 writeReplace,返回代理实例写入流。
  • 代理类实现 readResolve,重新构建原始业务对象返回。
  • // 业务实体
    public class User implements Serializable {
    private String name;
    private Integer age;

    public User(String name, Integer age) {
    this.name = name;
    this.age = age;
    }

    // 序列化代理,写入流的实际是这个代理对象
    private static class SerializationProxy implements Serializable {
    private final String name;
    private final Integer age;
    public SerializationProxy(User user) {
    this.name = user.name;
    this.age = user.age;
    }
    // 反序列化代理,重建原始对象
    private Object readResolve() {
    return new User(name, age);
    }
    }

    // 序列化时替换为代理
    private Object writeReplace() {
    return new SerializationProxy(this);
    }
    // 禁止直接反序列化本类,防止攻击者构造恶意字节
    private void readObject(ObjectInputStream in) throws InvalidObjectException {
    throw new InvalidObjectException("请使用序列化代理");
    }
    }

    优势:

    • 攻击者无法构造恶意子类进行攻击;
    • 反序列化强制调用业务构造器,可以做参数校验;
    • 可以无视原对象所有字段访问权限。

    缺点:会额外多一层对象,对超大对象会带来少量性能开销。

    六、容器类自定义序列化原理:ArrayList / HashMap

    JDK集合类并没有简单把全部成员变量序列化。 以ArrayList举例:

    • 底层elementData数组会预留大量空位置,如果直接序列化会把大量null空间写入字节流,体积膨胀。
    • 重写 writeObject,只序列化数组中有效size个元素;
    • readObject读取时,再重建elementData数组。

    HashMap同理,会重新构建哈希表,不会直接序列化table数组。

    面试考点:为什么集合类要重写writeObject/readObject?减少序列化字节体积。

    七、反序列化高危安全漏洞

    7.1 漏洞原理

    Java原生反序列化不需要调用业务构造器,读取字节即可还原对象。 攻击者构造恶意序列化字节,程序反序列化时,会自动执行类内部readObject等逻辑,配合第三方库的恶意调用链(Gadget),实现远程代码执行。

    受影响:JDK原生序列化,只要反序列化不受信任的字节流就有风险。 历史中招组件:Commons‑Collections、Commons‑Beanutils 等。

    7.2 分层防御策略

  • 最高原则:永远不要反序列化外部不可信来源的数据,这是根本。
  • 使用 ObjectInputFilter 设置类黑白名单,拦截危险类。
  • 在自定义 readObject 内部做严格字段校验,拒绝非法参数。
  • 业务系统,尽量彻底放弃Java原生序列化,使用JSON、Protobuf替代。
  • 八、其他容易被忽略的序列化坑

  • final修饰的字段,可以通过序列化赋值,绕过编译期final限制。
  • 不同类加载器场景:类全限定名一样,类加载器不同,反序列化抛出类型不匹配异常。
  • 引用类型:SoftReference、WeakReference序列化之后引用会丢失。
  • 枚举:枚举序列化依靠枚举名字,修改枚举常量名字,旧字节流反序列化直接报错。
  • 静态内部类可以序列化;非静态内部类自带外部类隐式引用,极易序列化失败,业务禁止序列化非静态内部类。
  • 九、JDK版本演进:原生序列化现状

    • JDK8:序列化完整可用,没有标记废弃;ObjectInputFilter需要升级小版本。
    • JDK9:原生序列化被标记为 Legacy(遗留机制),官方明确不推荐继续使用。
    • Java17/21:提供开关可以禁用原生序列化;未来版本会逐步移除。

    重点:面试经常问,Java官方对原生序列化的态度。

    十、数据库存储场景序列化实战规范

    很多项目会直接把 Java 序列化后的二进制字节存入数据库BLOB字段,这里有大量线上踩坑点。

    10.1 重要概念区分

    数据库事务隔离级别 SERIALIZABLE 和 Java 对象序列化完全无关,不要混淆。

    10.2 几种存储方案对比

  • Java原生序列化二进制(BLOB) 不建议业务使用。 缺点:版本升级极易反序列化失败;可读性为0;数据库无法做条件查询;跨语言完全不兼容;存在反序列化安全风险。
  • JSON字符串(VARCHAR / TEXT) 业务最常用方案。 优点:可读性好,数据库支持JSON函数查询;跨语言兼容;版本向前向后兼容好。 缺点:体积相比二进制偏大。
  • Protobuf / Hessian 二进制 适合高性能内部系统。 优点:体积小,序列化性能高,版本兼容性强。 缺点:数据库不能直接解析查询,调试不方便。
  • XML 基本淘汰,体积冗余大。
  • 10.3 什么场景可以把对象序列化存入数据库

    适合:对象整体保存、整体读取,几乎不会做where条件查询、不需要join关联的数据。 例如:流程快照、历史备份快照、缓存备份。

    10.4 什么场景绝对不要序列化存储

    需要按对象内部字段做查询、过滤、统计、关联查询,必须拆成数据库普通表字段,不要塞二进制大字段。

    10.5 历史遗留系统注意事项

    老项目已经使用Java序列化BLOB存入数据库:

  • 类升级务必维护好serialVersionUID;
  • 禁止随意修改类继承、字段类型;
  • 尽量做数据迁移,逐步转为JSON存储。
  • 十一、生产环境完整最佳实践总结

  • 如果业务迫不得已要实现Serializable:
    • 手写固定 serialVersionUID,不要依赖自动生成。
    • 敏感字段标记transient,业务层做加密,不要依靠序列化做安全。
  • 同一个ObjectOutputStream多次写对象,修改对象后记得 reset 或者 writeUnshared。
  • 单例、不可变类优先考虑序列化代理模式,规避单例被破坏风险。
  • 网络接口、存储到数据库,不要使用Java原生序列化字节;优先JSON/Protobuf。
  • 禁止把Java序列化字节对外暴露给外部接口。
  • 不要序列化非静态内部类、Lambda。
  • 写在最后

    Java原生序列化看着API简单,但是缓存机制、继承、安全漏洞坑点极多。面试是高频考点,但是新项目尽量避免直接使用。

    本系列上篇讲解基础考点,本篇讲解底层原理与生产踩坑,两篇结合完整覆盖Java序列化全部核心内容。

    上一篇:【面试必背】Java序列化:Serializable、transient、serialVersionUID完整梳理

    标签:Java,序列化,反序列化漏洞,readResolve,writeReplace,生产踩坑,Java安全,JDK,后端面试


    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Java 序列化进阶实战:循环引用、反序列化漏洞与生产避坑指南
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!