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。尤其當變更的是參與循環的欄位時。 - 自訂序列化時的問題。
如果你手動實作 writeObject 與 readObject,就必須自行正確處理循環。若忘記呼叫預設方法(defaultWriteObject/defaultReadObject),序列化器將無法追蹤循環。 - 序列化為其他格式(例如 JSON)。
Java 的標準序列化(ObjectOutputStream)能處理循環,但若你將物件序列化為 JSON(例如透過 Jackson 或 Gson),循環可能導致 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 中序列化但未支援循環。 如果你打算透過 Jackson 或 Gson 將含有循環的物件序列化,卻沒有做任何設定,極有可能得到 StackOverflowError。例如有個 Node 類別,每個節點同時引用父節點與子節點,將這樣的樹序列化成 JSON 會導致無限巢狀。
錯誤 №2:類別結構被更動。 如果在序列化之後修改了類別結構(例如新增欄位),反序列化舊檔時可能出現相容性錯誤。對於含有循環的複雜圖尤為嚴重。
錯誤 №3:自製序列化時沒考慮循環。 如果你手動實作 writeObject/readObject 卻未呼叫 defaultWriteObject,Java 將無法追蹤循環,序列化可能卡住,或在反序列化時引用結構會被破壞。
錯誤 №4:不小心把集合加入了它自己。 有時候新手開發者會不小心把集合本身加入集合(例如在複製元素時),而不知道自己製造了循環。結果序列化雖然能運作,但程式邏輯可能會變得奇怪且難以預期。
GO TO FULL VERSION