Java序列化踩坑记:对象循环引用让我加班两小时

文章来源声明: 原文作者:阿橙的百宝箱; 来源站点:掘金; 原文链接:https://juejin.cn/post/7702434799865233423; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

文章以真实故障串联根因、方案与性能对比,实用性强;适合后端开发与架构师排查RPC序列化、对象图设计问题时参考。

凌晨1点23分,监控突然报警——某核心服务的RPC接口超时率飙升到40%。紧急回滚后,排查发现日志里堆满了`StackOverflowError`。谁能想到,一个看似简单的`Serializable`接口实现,竟因为两个POJO的互相引用,让整个集群差点瘫痪。
  1. 故障现场:RPC超时与幽灵日志

那晚我们在灰度上线一个订单履约系统的新版本,涉及订单和履约单的双向关联。核心逻辑是这样的:

class Order implements Serializable {
    private String orderId;
    private Fulfillment fulfillment;  // 关键点:持有履约对象引用
    // getters & setters...
}

class Fulfillment implements Serializable {
    private String fulfillmentId;
    private Order order;  // 关键点:反向引用订单
    // getters & setters...
}

当服务A通过Dubbo调用服务B时,传输的Order对象包含Fulfillment,而Fulfillment又指回Order。测试环境一切正常,但生产环境订单量级是测试的200倍,序列化时直接爆栈。

  • 诡异的是*:日志中只有下游服务的超时记录,本应打印的序列化异常像被黑洞吞噬了一样。后来才知道,Dubbo默认的Hessian序列化在遇到循环引用时,会尝试无限递归直到栈溢出,而原始异常被包装成了RpcException。
  1. 根因解剖:JVM如何掉进递归黑洞

Java原生序列化(ObjectOutputStream)和Hessian这类二进制协议处理循环引用的策略完全不同:

  • 原生序列化:遇到重复引用时会写入TC_REFERENCE标记(原理类似内存地址引用),不会重复序列化同一对象
  • Hessian2:默认开启引用检测(SerializerFactory#setAllowNonSerializable),但对深度递归的保护有限
  • JSON序列化:Jackson/Gson默认不处理循环引用,直接抛出StackOverflowError

我们用的Hessian2看似支持循环引用,但在高并发+深层次对象图时:

  1. 序列化线程不断压栈构建引用关系

  2. JVM的栈空间(默认1MB)被耗尽

  3. 线程死亡,但Dubbo的线程池捕获了异常,只记录"远程调用超时"

  4. 解法实战:打破循环引用的三种姿势


错误写法:天真地实现Serializable

// 直接序列化循环引用对象(定时炸弹!)
Order order = new Order();
Fulfillment fulfillment = new Fulfillment();
order.setFulfillment(fulfillment);
fulfillment.setOrder(order); // 循环引用形成

byte[] data = SerializationUtils.serialize(order); // 迟早StackOverflow

正确方案1:瞬态化(Transient)反向引用

class Fulfillment implements Serializable {
    private transient Order order; // 不会被序列化
    // 需要手动处理关联逻辑
}

正确方案2:定制序列化(writeObject/readObject)

class Order implements Serializable {
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();  // 先序列化非transient字段
        oos.writeObject(fulfillment.getFulfillmentId()); // 只写ID而非对象
    }
    private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
        ois.defaultReadObject();
        String fulfillmentId = (String) ois.readObject();
        this.fulfillment = loadFulfillmentFromDB(fulfillmentId); // 反序列化时重建
    }
}

正确方案3:改用DTO投影

class OrderDTO {
    private String orderId;
    private String fulfillmentId; // 只有ID,没有对象引用
    // 无循环引用风险
}

  • 性能对比*(序列化1万次平均耗时):
方案耗时(ms)内存峰值(MB)
原生循环引用423210
Transient5245
定制序列化7860
DTO3832
  1. 避坑清单:循环引用的五个致命马蜂窝

  1. 跨进程陷阱:RPC/消息队列传输的对象,永远假设对方可能重构你的类结构

  2. 缓存穿透:Redis序列化循环引用对象时,可能耗尽连接池(实测Jedis比Lettuce更敏感)

  3. 深度克隆灾难:org.apache.commons.lang3.SerializationUtils.clone()会忠实复制整个引用网

  4. JSON的隐秘行为:Jackson的@JsonIdentityInfo能处理循环引用,但牺牲了可读性

  5. JPA/Hibernate的延迟加载:FetchType.LAZY + 序列化 = 随机LazyInitializationException

  6. 终极防御:对象图序列化检查清单


下次实现Serializable前,先问自己:

  • 这个对象需要跨JVM传输吗?(不需要就别瞎实现接口)
  • 对象图中是否存在双向箭头?(用ID代替对象引用)
  • 有没有非线程安全的字段?(例如SimpleDateFormat必须transient)

凌晨3点,当我用transient切断那个反向引用时,监控曲线瞬间回落。你看,有时候解决复杂问题的方法,反而简单得可笑——真正的高手不是能解决多难的问题,而是能提前发现那些看似简单的陷阱。

你在项目里遇到过更诡异的序列化问题吗?欢迎在评论区分享你的“血泪史”。