CodeGym /課程 /JAVA 25 SELF /二進位序列化中的物件同一性

二進位序列化中的物件同一性

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

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:期望序列化後得到「全新」的物件。 序列化不會建立「全新」的結構——它會如實還原原始的引用關係。這在處理快取或樣板物件時,可能導致意料之外的行為。

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