1. 前言
在 Java(以及整體 OOP)中,每個物件都有其同一性——這不只是其欄位值的集合,而是它在記憶體中的「是誰」。兩個物件可以是「等價」的(例如 a.equals(b) 回傳 true),但在同一性上仍不同(a != b)。 同一性指的是兩個變數指向記憶體中的同一個物件(a == b)。
為什麼這對序列化很重要?
當你序列化(保存)物件圖(例如樹、清單,或只是兩個物件同時指向同一個內部物件)時,重要的不僅是欄位值,還有物件之間的引用結構。
如果你的物件圖中存在重複引用(多個物件指向同一個內部物件),甚至存在循環引用(A → B → A),那麼在反序列化之後,這些關係也必須保持一致。
範例:有兩個物件 A 和 B,它們都指向同一個物件 C。序列化與反序列化後應該滿足:a.c == b.c(記憶體中的同一個物件)。如果序列化只是「複製」物件,那麼復原後會得到兩個不同的 C——這就變成另一個圖了。
2. ObjectOutputStream 如何解決同一性問題
在 Java 中,用於二進位序列化的是一對類別:ObjectOutputStream(負責寫入)與 ObjectInputStream(負責讀取)。
ObjectOutputStream 不只是「把物件寫到檔案」。它很聰明:
- 會追蹤在同一寫入流程(同一個會話)中已被序列化過的所有物件。
- 若遇到引用指向已序列化過的物件,便不會再次寫入整個物件,而是寫入特殊的「重複引用」(reference handle)。
- 在反序列化時,ObjectInputStream 會還原結構,使這些重複引用指向記憶體中的同一個物件。
這在循環引用時也能運作!也就是說,如果有 A → B → A,序列化與反序列化不會卡住或「掛掉」:引用會被正確還原。
實作原理是什麼?
在 ObjectOutputStream 內部會使用特殊的表(identity map)來保存已被序列化的物件。當遇到新物件時,會將其加入表中並完整序列化;當遇到已在表中的物件時,就只會將「重複引用」(handle)寫入到資料流中。
3. 範例示範
範例 1:具有循環引用的圖 (A → B, B → A)
import java.io.*;
class Node implements Serializable {
String name;
Node next;
Node(String name) {
this.name = name;
}
}
public class CyclicSerializationDemo {
public static void main(String[] args) throws Exception {
Node a = new Node("A");
Node b = new Node("B");
a.next = b;
b.next = a; // 循環!
// 序列化
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic.dat"))) {
out.writeObject(a);
}
// 反序列化
Node a2;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("cyclic.dat"))) {
a2 = (Node) in.readObject();
}
System.out.println("a2.name = " + a2.name); // A
System.out.println("a2.next.name = " + a2.next.name); // B
System.out.println("a2.next.next == a2: " + (a2.next.next == a2)); // true!
}
}
輸出:
a2.name = A
a2.next.name = B
a2.next.next == a2: true
說明:
循環被保留了!反序列化後,a2.next.next 再次指向 a2。
範例 2:重複引用 (A → C, B → C)
import java.io.*;
class Wrapper implements Serializable {
String name;
Object ref;
Wrapper(String name) { this.name = name; }
}
public class SharedReferenceDemo {
public static void main(String[] args) throws Exception {
Wrapper a = new Wrapper("A");
Wrapper b = new Wrapper("B");
Wrapper c = new Wrapper("C");
a.ref = c;
b.ref = c;
// 序列化
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("shared.dat"))) {
out.writeObject(new Wrapper[] {a, b});
}
// 反序列化
Wrapper[] arr;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("shared.dat"))) {
arr = (Wrapper[]) in.readObject();
}
Wrapper a2 = arr[0];
Wrapper b2 = arr[1];
System.out.println("a2.ref == b2.ref: " + (a2.ref == b2.ref)); // true!
System.out.println("a2.ref.name = " + ((Wrapper)a2.ref).name); // C
}
}
輸出:
a2.ref == b2.ref: true
a2.ref.name = C
說明:
反序列化後,兩個引用再次指向同一個物件。
4. 與 writeReplace 與 readResolve 的關聯
在 Java 中,可以透過特殊方法「介入」(反)序列化流程:
writeReplace()——在物件被序列化之前呼叫。可以回傳另一個物件,該物件會取代原物件被序列化。
readResolve()——在物件反序列化之後呼叫。可以回傳另一個物件,該物件會取代剛建立的那個物件。
對同一性的影響:
- 若使用 writeReplace() 或 readResolve(),則由這些方法回傳的物件才會被放入引用表。
- 這可能改變同一性:如果 readResolve() 回傳新的/不同的物件,重複引用可能會指向不同的實例。
- 務必小心:你既可能「破壞」同一性,也可以刻意確保它(經典範例是透過 readResolve() 的 Singleton,使所有引用在反序列化後都指向同一個單例)。
5. 實作:展示如何保留同一性的程式碼
import java.io.*;
class Shared implements Serializable {
String value;
Shared(String value) { this.value = value; }
}
class Holder implements Serializable {
String name;
Shared shared;
Holder(String name, Shared shared) {
this.name = name;
this.shared = shared;
}
}
public class IdentityDemo {
public static void main(String[] args) throws Exception {
Shared c = new Shared("C");
Holder a = new Holder("A", c);
Holder b = new Holder("B", c);
// 序列化
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("identity.dat"))) {
out.writeObject(new Holder[] {a, b});
}
// 反序列化
Holder[] arr;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("identity.dat"))) {
arr = (Holder[]) in.readObject();
}
Holder a2 = arr[0];
Holder b2 = arr[1];
System.out.println("a2.shared == b2.shared: " + (a2.shared == b2.shared)); // true!
System.out.println("a2.shared.value = " + a2.shared.value); // C
}
}
輸出:
a2.shared == b2.shared: true
a2.shared.value = C
說明:
物件 c 的同一性被保留:反序列化後,兩個引用都指向同一個實例。
6. 總結與常見錯誤
錯誤 1:分別序列化帶有共享引用的物件。 如果你為每個物件分別使用各自的 ObjectOutputStream 來寫入,彼此之間的引用將不會被保留下來——反序列化後的實例會變成不同的,即使在原始資料中它們本是同一個引用。
錯誤 2:錯誤使用 writeReplace() 與 readResolve()。 這些方法可能在(反)序列化期間替換物件,從而改變其同一性。如果不了解其機制,最後可能得到出乎意料的實例。
錯誤 3:共享可變引用帶來的意外影響。 如果多個物件指向同一個可變的內部物件(例如清單),反序列化後仍會如此。在一處的變更會影響到所有其他地方。
錯誤 4:期望序列化後得到「全新」的物件。 序列化不會建立「全新」的結構——它會如實還原原始的引用關係。這在處理快取或樣板物件時,可能導致意料之外的行為。
GO TO FULL VERSION