CodeGym /課程 /JAVA 25 SELF /循環參照的問題:偵測與處理

循環參照的問題:偵測與處理

JAVA 25 SELF
等級 44 , 課堂 2
開放

1. 什麼是循環參照?

循環參照是指某個物件(或集合)直接或間接包含指向自身的引用。這在集合中比你想像的更常見,尤其當你構建複雜的資料結構或處理圖(graph)時。

實務中的例子

  • 兩個物件互相引用:
    例如,你有類別 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. 處理循環參照時的常見錯誤

錯誤 №1:在 JSON 中序列化但未支援循環。 如果你打算透過 JacksonGson 將含有循環的物件序列化,卻沒有做任何設定,極有可能得到 StackOverflowError。例如有個 Node 類別,每個節點同時引用父節點與子節點,將這樣的樹序列化成 JSON 會導致無限巢狀。

錯誤 №2:類別結構被更動。 如果在序列化之後修改了類別結構(例如新增欄位),反序列化舊檔時可能出現相容性錯誤。對於含有循環的複雜圖尤為嚴重。

錯誤 №3:自製序列化時沒考慮循環。 如果你手動實作 writeObject/readObject 卻未呼叫 defaultWriteObject,Java 將無法追蹤循環,序列化可能卡住,或在反序列化時引用結構會被破壞。

錯誤 №4:不小心把集合加入了它自己。 有時候新手開發者會不小心把集合本身加入集合(例如在複製元素時),而不知道自己製造了循環。結果序列化雖然能運作,但程式邏輯可能會變得奇怪且難以預期。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION