概要

「コピーしたはずのリストの中身が変わっていた」「unmodifiableList を使ったのに内容が書き換わった」――Java のコピーに関するバグは、問題の発生箇所と原因箇所が離れているため、デバッグに時間がかかることで知られています。原因はほぼ3パターンに集約されます。参照のコピーと値のコピーの混同、浅いコピーの要素共有、不変コレクション API の挙動の誤解です。この記事では、それぞれの落とし穴を再現するコードを示したうえで、record と List.copyOf を組み合わせた防御コピーの実装パターンを紹介します。特に Java 8 の Collections.unmodifiableList と Java 17 の List.copyOf の決定的な違いは、保守案件で引き継いだコードを読むときにも役立ちます。

使いどころ

CSV 取込で構築したマスタデータ一覧を複数の処理に渡す際、意図しない変更を防ぐ防御コピーを入れる

DTO のコレクションフィールドに外部から渡されたリストをそのまま代入せず、コンストラクタで防御コピーする

テストで期待値と実測値を比較する前にコピーを取り、アサーション失敗時に元データが汚染されていないことを保証する

コード例

CopyPitfallDemo.java
import java.util.ArrayList;
import java.util.List;

public class CopyPitfallDemo {

    // record + 防御コピー
    record ImmutablePerson(String name, List<String> hobbies) {
        ImmutablePerson {
            hobbies = List.copyOf(hobbies); // 防御コピー
        }
    }

    // 落とし穴の説明用に可変クラスも用意
    static class MutablePerson {
        String name;
        List<String> hobbies;

        MutablePerson(String name, List<String> hobbies) {
            this.name = name;
            this.hobbies = hobbies;
        }

        @Override
        public String toString() {
            return "MutablePerson{name='" + name + "', hobbies=" + hobbies + "}";
        }
    }

    public static void main(String[] args) {
        // 落とし穴 1: 参照コピー(同じリストを指す)
        System.out.println("=== 落とし穴 1: 参照コピー ===");
        var original = new ArrayList<>(List.of("Java", "Python"));
        var ref = original; // 同じリストを参照
        ref.add("Kotlin");
        System.out.println("original: " + original); // Kotlin も入っている

        // 落とし穴 2: 浅いコピー(要素は共有)
        System.out.println("\n=== 落とし穴 2: 浅いコピー ===");
        var people = new ArrayList<MutablePerson>();
        people.add(new MutablePerson("田中",
                new ArrayList<>(List.of("サッカー"))));
        var shallowCopy = new ArrayList<>(people);
        shallowCopy.get(0).hobbies.add("テニス"); // 元も変わる
        System.out.println("original[0]: " + people.get(0));

        // 落とし穴 3: List.copyOf はスナップショット
        System.out.println("\n=== List.copyOf は独立したコピー ===");
        var mutable = new ArrayList<>(List.of("A", "B"));
        var snapshot = List.copyOf(mutable);
        mutable.add("C");
        System.out.println("mutable:  " + mutable);   // [A, B, C]
        System.out.println("snapshot: " + snapshot);   // [A, B]

        // 対策: record + List.copyOf で安全なコピー
        System.out.println("\n=== 対策: record + List.copyOf ===");
        var person = new ImmutablePerson("田中",
                List.of("サッカー", "テニス"));
        System.out.println("person: " + person);
        // person.hobbies().add("野球");
        // → UnsupportedOperationException(防御コピー済み)
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

List.copyOf で元リストから独立した不変コピーを安全に作れる。record との組み合わせで防御コピーが簡潔に書ける。

Java 17
// Java 17: List.copyOf は独立したスナップショットを作る
var mutable = new ArrayList<>(List.of("A", "B"));
var snapshot = List.copyOf(mutable);
mutable.add("C");
System.out.println(snapshot); // [A, B] ← 変わらない

Library Comparison

標準 API(List.copyOf / record)Java 10 以上であれば最も簡潔で安全な防御コピー手段。外部依存なしで保守コストが低い。Java 8 では使えないため、保守案件では Collections.unmodifiableList + new ArrayList のパターンが残る。
Guava ImmutableListJava 8 環境で不変リストを使いたいとき。null 拒否の挙動が明確。Guava の依存を入れることになる。Java 10 以上なら List.copyOf で十分代替できる。
Vavr (io.vavr)永続データ構造を使い、構造共有による効率的な不変コレクションを求めるとき。学習コストが高く、チームへの導入障壁がある。業務コードの防御コピー用途には過剰。

注意点

Collections.unmodifiableList は元リストの参照を持つだけなので、元リストが変更されると不変リスト側にも反映される。完全に独立したコピーには List.copyOf を使う

List.of() が返すリストに null を渡すと NullPointerException になる。null を含む可能性があるデータには Collections.unmodifiableList を使う

Stream.toList()(Java 16+)は不変リストを返すが、Collectors.toList() は可変リストを返す。名前が似ているため混同しやすい

record のフィールドに可変コレクションを渡すと、record 外部からリストの中身を変更できてしまう。コンパクトコンストラクタで List.copyOf を使って防御コピーすること

浅いコピーで要素が共有されるバグは、単体テストでは再現しにくい。複数スレッドやバッチの並列実行で初めて顕在化するケースがある

FAQ

List.copyOf と Collections.unmodifiableList の違いは何ですか。

List.copyOf は元リストから独立した不変コピーを作ります。unmodifiableList は元リストへの参照を持つだけなので、元が変わると不変リスト側も変わります。

Stream.toList() と Collectors.toList() はどちらを使うべきですか。

Java 16 以上なら Stream.toList() が簡潔で不変リストを返します。ただし返却後に変更が必要な場合は Collectors.toCollection(ArrayList::new) を使います。

record のコンパクトコンストラクタで防御コピーは必須ですか。

record のフィールドが不変型(String, int, LocalDate 等)のみなら不要です。List や配列など可変型を含む場合は List.copyOf で防御コピーしないと外部から書き換えられます。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます