1. はじめに
Java(そして一般に OOP)では、各オブジェクトには 同一性 があります—それは単なるフィールド値の集合ではなく、メモリ上での「何者か(誰なのか)」を指します。2つのオブジェクトは「同値」になり得ます(例えば、a.equals(b) が true を返す)が、同一性は異なる場合があります(a != b)。
同一性とは、2つの変数がメモリ上の同じオブジェクトを指していること(a == b)です。
なぜシリアライズで重要なのか?
オブジェクトグラフ(例えば木構造、リスト、または同じネストオブジェクトを指す2つのオブジェクトなど)をシリアライズ(保存)する際には、フィールドの値だけでなく、オブジェクト間の参照構造も保持することが重要です。
グラフに共有参照(複数のオブジェクトが同じネストオブジェクトを参照している)や、循環参照(A → B → A)がある場合、デシリアライズ後もこれらの関係は同じでなければなりません。
例: 2つのオブジェクト A と B が同じオブジェクト C を参照しているとします。シリアライズとデシリアライズの後でも、a.c == b.c(メモリ上で同一のオブジェクト)であるべきです。もしシリアライズが単にオブジェクトを「コピー」してしまうだけなら、復元後には別々の C が2つできてしまい、もはや同じグラフではありません。
2. ObjectOutputStream は同一性の問題をどう解決するか
Java のバイナリシリアライズには、ObjectOutputStream(書き出し)と ObjectInputStream(読み込み)のクラスペアが使われます。
ObjectOutputStream は単に「オブジェクトをファイルへ書く」だけではありません。賢いのです:
- 1つの書き出しストリーム(1回のセッション)の中で、すでにシリアライズ済みのすべてのオブジェクトを追跡します.
- 既にシリアライズ済みのオブジェクト参照に出会った場合、そのオブジェクトを再度書くのではなく、特別な「再参照」(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() を用いたシングルトンで、デシリアライズ後のすべての参照が同じシングルトンを指すようにできます)。
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. まとめと典型的なミス
エラー No.1: 共有参照を持つオブジェクトを個別にシリアライズする。 各オブジェクトをそれぞれ別の ObjectOutputStream インスタンスで書き出すと、相互の参照は保持されません—デシリアライズ後のインスタンスは別物になってしまい、元は同じ参照だったとしても同一にはなりません。
エラー No.2: writeReplace() と readResolve() の誤用。 これらのメソッドは(デ)シリアライズ中にオブジェクトを置き換えるため、同一性が変わります。仕組みを理解していないと、結果として想定外のインスタンスが得られることがあります。
エラー No.3: 共有された可変参照の思わぬ影響。 複数のオブジェクトが同じ可変なネストオブジェクト(例: リスト)を参照している場合、デシリアライズ後もそのままです。どこか一箇所の変更が他のすべてに影響します。
エラー No.4: シリアライズ後に「新しい」オブジェクトが得られると期待する。 シリアライズは「新規の」構造を作るのではなく、元の参照関係をそのまま復元します。これは、キャッシュやテンプレート的なオブジェクトを扱うときには意外に感じるかもしれません。
GO TO FULL VERSION