CodeGym /コース /JAVA 25 SELF /循環参照の問題: 検出と回避

循環参照の問題: 検出と回避

JAVA 25 SELF
レベル 44 , レッスン 2
使用可能

1. 循環参照とは何か?

循環参照とは、オブジェクト(またはコレクション)が直接または間接に自分自身への参照を含む状況のことです。特に複雑なデータ構造を組み立てたりグラフを扱ったりしていると、コレクションでは思っている以上によく発生します。

実例

  • 2 つのオブジェクトが互いを参照する:
    例: クラス UserProfile への参照を持ち、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!
        }
    }
}

結果:
— 1 つ目の要素は普通の文字列。
— 2 つ目の要素は…コレクション自身! 判定 deserialized.get(1) == deserializedtrue になる。
Java はループせず、正しく参照構造を復元した。

内部ではどう動くのか?

ObjectOutputStream は内部に「シリアライズ済みオブジェクトのレジストリ」を保持します。既にシリアライズされたオブジェクトであれば、内容ではなくそのオブジェクトへの特別な参照(handle)をストリームに書き込みます。デシリアライズ時には ObjectInputStream が同じ関係を復元します。

3. 問題点と制約

  • 巨大なグラフをうっかりシリアライズしてしまう。
    データ構造が非常に大きく相互参照が多い場合、シリアライズに時間がかかり、ファイルも巨大になります.
  • クラス構造の変更。
    オブジェクトをシリアライズした後でそのクラスを変更(フィールドの追加・削除など)すると、デシリアライズ時に InvalidClassException が発生することがあります。特に循環に関わるフィールドが変わる場合は要注意。
  • カスタムシリアライズ時の問題。
    メソッド writeObjectreadObject を自前で実装する場合、循環を適切に処理する責任は開発者にあります。既定メソッド(defaultWriteObject/defaultReadObject)を呼び忘れると、シリアライザが循環を追跡できません。
  • 他形式(例: JSON)へのシリアライズ。
    Java の標準シリアライズ(ObjectOutputStream)は循環を処理できますが、オブジェクトを JSON(たとえば JacksonGson)にシリアライズする場合、循環によって StackOverflowError や例外が発生することがあります。これらのライブラリはデフォルトでは循環を扱えないため、明示的な設定が必要です。

4. 循環参照の対処法

Java の標準シリアライゼーションにおいて

すべて標準で動作します! 特別な対応は不要で、Java が自動的に循環を検出し、参照構造を保持します。

手作業での対応: 他形式へのシリアライズ

  • 参照の代わりに識別子を使う。
    他オブジェクトへの参照の代わりに一意の識別子を保持し、デシリアライズ後にその ID から関係を復元します。
  • 専用のアノテーションや設定を使う。
    Jackson では @JsonIdentityInfo@JsonBackReference/@JsonManagedReference の組み合わせで循環のシリアライズを制御できます。
  • シリアライズ前に循環を取り除く。
    循環を作るフィールドを一時的に null にしたり、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!
        }
    }
}

結果:
— 循環を含む構造が復元される: 友人を 3 回たどると再び Alice に戻る。
— Java は混乱せず、無限ループにもならない。

5. 循環参照でよくあるミス

エラー1: 循環対応なしで JSON にシリアライズする。 JacksonGson で設定なしに循環を含むオブジェクトをシリアライズすると、高い確率で StackOverflowError になります。たとえば各ノードが親と子への参照を持つ Node クラスの木を JSON にシリアライズすると、無限の入れ子になります。

エラー2: クラス構造の非互換変更。 シリアライズ後にクラス構造を変更(例: フィールドの追加)すると、古いファイルのデシリアライズ時に互換性エラーが発生することがあります。特に循環に関わる複雑なグラフでは致命的です。

エラー3: 循環を考慮しない独自シリアライズ。 writeObject/readObject を自前で実装し、defaultWriteObject を呼ばない場合、Java は循環を追跡できず、シリアライズがループしたり、デシリアライズ時に参照構造が壊れたりします。

エラー4: 誤ってコレクション自身を追加してしまう。 ときどき経験の浅い開発者が(要素のコピー中などに)コレクション自身を誤って追加し、循環を作ってしまうことがあります。その結果、シリアライズ自体は動作しても、プログラムのロジックが奇妙で予測不能になることがあります。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION