CodeGym /课程 /JAVA 25 SELF /循环引用问题:检测与规避

循环引用问题:检测与规避

JAVA 25 SELF
第 44 级 , 课程 2
可用

1. 什么是循环引用?

循环引用是指对象(或集合)直接或间接地包含指向其自身的引用。在集合中这种情况比你想象的更常见,尤其当你构建复杂的数据结构或处理图时。

实际示例

  • 两个对象相互引用:
    例如,有一个类 User,它包含指向 Profile 的引用,而 Profile 又包含回指 User 的引用。
  • 集合包含自身:
    最简单且“有趣”的示例:
List<Object> list = new ArrayList<>();
list.add(list); // 注意!list 包含了它自己
  • 对象图:
    相互关联的对象,例如树的节点,每个节点都可能持有对父节点和子节点的引用。

可视化

graph LR
A[User] -- profile --> B[Profile]
B -- user --> A

或者以集合为例:

graph TD
L[List] -- add(self) --> L

为什么这会成为问题?

如果序列化器不会跟踪循环,它可能会“陷入无限”,不断尝试序列化嵌套对象,直到栈溢出(StackOverflowError)。好消息是:Java 的标准序列化了解这些套路,并能妥善处理!

2. Java 标准序列化如何处理循环?

当你通过 ObjectOutputStream 序列化对象时,Java 会自动跟踪在该流中已被序列化过的对象。如果再次遇到某个对象,它不会重新序列化其内容,而是写入一个指向已序列化对象的特殊引用。这使得即便是带有循环的复杂结构也能被正确序列化。

示例:包含自身的集合

我们来尝试序列化一个包含自身的集合。这不是玩笑 — 这样的代码可以编译并且确实能运行:

import java.io.*;
import java.util.*;

public class CyclicListDemo {
    public static void main(String[] args) throws Exception {
        List<Object> list = new ArrayList<>();
        list.add("Hello, cyclic world!");
        list.add(list); // 把它自己加进去

        // 序列化
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic_list.ser"))) {
            out.writeObject(list);
        }

        // 反序列化
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("cyclic_list.ser"))) {
            List<?> deserialized = (List<?>) in.readObject();

            System.out.println(deserialized.get(0)); // "Hello, cyclic world!"
            System.out.println(deserialized.get(1) == deserialized); // true!
        }
    }
}

结果:
— 第一个元素是普通字符串。
— 第二个元素是……集合本身!检查 deserialized.get(1) == deserialized 将返回 true
Java 没有陷入死循环或崩溃,而是正确还原了引用结构。

内部原理是什么?

ObjectOutputStream 维护一个内部“登记簿”来记录已序列化的对象。如果某个对象已被序列化,写入到流中的将是一个指向它的特殊引用(handle),而非其内容。反序列化时,ObjectInputStream 会恢复相同的关系。

3. 问题与限制

  • 不小心序列化了庞大的图。
    如果你的数据结构很大且包含大量交叉引用,序列化可能耗时很长并生成巨大的文件。
  • 类结构发生变化。
    如果你序列化了对象,随后修改了其类(例如添加或删除字段),反序列化时可能会出现 InvalidClassException。尤其当变更涉及参与循环的字段时更是如此。
  • 自定义序列化中的问题。
    如果你手动实现 writeObjectreadObject,你就必须自己正确处理循环。若忘记调用默认方法(defaultWriteObject/defaultReadObject),序列化器将无法跟踪循环。
  • 序列化为其他格式(例如 JSON)。
    Java 的标准序列化(ObjectOutputStream)可以处理循环,但如果你将对象序列化为 JSON(例如通过 JacksonGson),循环可能导致 StackOverflowError 或异常。这类库默认不支持循环 — 需要显式配置。

4. 处理循环引用

在 Java 标准序列化中

开箱即用!你无需做任何特殊处理 — Java 会自动发现循环并保存引用结构。

手动处理:序列化为其他格式

  • 用标识符替代引用。
    与其保存对其他对象的引用,不如保存其唯一标识符。反序列化后再根据这些 id 恢复关系。
  • 使用专用注解或配置。
    Jackson 中可以使用注解 @JsonIdentityInfo,或使用 @JsonBackReference/@JsonManagedReference 组合来控制循环的序列化。
  • 在序列化前打断循环。
    临时将造成循环的字段置空,或通过 transient 或注解将其排除。

示例:序列化包含循环的图

来看一个更复杂的结构 — 用户图,其中每个用户都可能是另一个用户的朋友。

import java.io.*;
import java.util.*;

class User implements Serializable {
    String name;
    List<User> friends = new ArrayList<>();

    User(String name) { this.name = name; }

    public String toString() {
        return name + " (" + friends.size() + " friends)";
    }
}

public class CyclicGraphDemo {
    public static void main(String[] args) throws Exception {
        User alice = new User("Alice");
        User bob = new User("Bob");
        User charlie = new User("Charlie");

        // 构造带循环的好友关系
        alice.friends.add(bob);
        bob.friends.add(charlie);
        charlie.friends.add(alice); // 循环!

        // 序列化
        try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("users.ser"))) {
            out.writeObject(alice);
        }

        // 反序列化
        try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("users.ser"))) {
            User restoredAlice = (User) in.readObject();
            System.out.println(restoredAlice);
            System.out.println(restoredAlice.friends.get(0));
            System.out.println(restoredAlice.friends.get(0).friends.get(0));
            System.out.println(restoredAlice.friends.get(0).friends.get(0).friends.get(0) == restoredAlice); // true!
        }
    }
}

结果:
— 循环结构被正确恢复:沿着好友关系跳转三次又回到 Alice。
— Java 没有混乱也没有陷入死循环。

5. 处理循环引用时的常见错误

错误 No. 1:在不支持循环的情况下序列化为 JSON。 如果你使用 JacksonGson 在未配置的情况下序列化带循环的对象,很可能会得到 StackOverflowError。例如,如果有类 Node,每个节点都引用父节点和子节点,那么将这样的树序列化为 JSON 会导致无限嵌套。

错误 No.2:类结构被破坏。 在序列化之后修改了类的结构(例如添加字段),对旧文件进行反序列化时可能出现不兼容错误。对于包含循环的复杂图,这一点尤为关键。

错误 No.3:自制序列化未考虑循环。 如果你手动实现 writeObject/readObject 却不调用 defaultWriteObject,Java 将无法跟踪循环,序列化要么会陷入循环,要么在反序列化时引用结构会被破坏。

错误 No.4:不小心把集合加入了它自己。 有时新手开发者会不小心将集合加入自身(例如在拷贝元素时),却没意识到自己创建了循环。结果是序列化虽能运行,但程序逻辑可能变得怪异且不可预测。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION