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