概要

Java でオブジェクトをコピーする場面は、DTO の変換、画面表示用データの加工、テスト用のデータ準備など業務コードの随所に現れます。ところが「コピーしたはずなのに元データが変わった」というバグは、経験者でも年に何度かは踏むものです。原因のほとんどは、浅いコピー(shallow copy)と深いコピー(deep copy)の違いを正しく使い分けていないことにあります。この記事では、参照コピー・浅いコピー・深いコピーの3段階を図解的にコードで示し、List や配列のコピーで何が共有され何が独立するのかを整理します。Java 17 の record と var を活用した記述の簡潔化、Java 21 の sealed interface によるコピー戦略の型安全な切り替えにも触れます。

使いどころ

取引明細の一覧を画面表示用に加工する際、元データに影響を与えないディープコピーを行う

テストデータのテンプレートを1つ用意し、テストケースごとに独立したコピーを作って値を差し替える

バッチ処理の途中結果をスナップショットとして保存し、後続処理で元データが変更されても影響を受けないようにする

コード例

CopyPatternDemo.java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class CopyPatternDemo {

    static class MutablePerson {
        private String name;
        private int age;

        public MutablePerson(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
        public int getAge() { return age; }

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

    public static void main(String[] args) {

        var original = new ArrayList<>(List.of(
            new MutablePerson("田中太郎", 25),
            new MutablePerson("山田花子", 30)
        ));

        var shallowCopy = new ArrayList<>(original);
        shallowCopy.get(0).setName("変更された名前");
        System.out.println("元データも変わる: " + original.get(0));

        var original2 = new ArrayList<>(List.of(
            new MutablePerson("田中太郎", 25),
            new MutablePerson("山田花子", 30)
        ));

        var deepCopy = original2.stream()
            .map(p -> new MutablePerson(p.getName(), p.getAge()))
            .collect(Collectors.toCollection(ArrayList::new));

        deepCopy.get(0).setName("変更しても");
        System.out.println("元データは変わらない: " + original2.get(0));

        var arr = new int[]{1, 2, 3, 4, 5};
        var copiedArr = Arrays.copyOf(arr, arr.length);
        arr[0] = 99;
        System.out.println("copiedArr[0]: " + copiedArr[0]); // 1
    }
}

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

Version Coverage

var で型推論、Stream + map + collect で宣言的なディープコピーが書ける。record を活用すればそもそもコピー問題を回避できる。

Java 17
// Java 17: Stream + map で宣言的にディープコピー
var deepCopy = original.stream()
    .map(p -> new MutablePerson(p.getName(), p.getAge()))
    .collect(Collectors.toCollection(ArrayList::new));

Library Comparison

標準 API(ArrayList + Stream)コピー対象の構造が単純で、自前のマッピングで十分なとき。依存ゼロで保守しやすい。ネストが深い場合はコピーコードが増える。フィールド追加時にコピー漏れが起きやすい。
Apache Commons Lang (SerializationUtils)ネストが深く手動コピーが煩雑なとき。Serializable 実装済みなら1行でディープコピーできる。Serializable が前提。性能面でオーバーヘッドがあり、大量データには不向き。依存追加も必要。
MapStructDTO 変換が頻繁で、フィールドマッピングのコード生成に任せたいとき。コンパイル時コード生成のため導入コストがある。単純コピーだけの用途にはやや大きい。

注意点

new ArrayList<>(original) はリストの浅いコピーであり、要素オブジェクトの参照は共有される。要素が可変クラスの場合、コピー先での変更が元にも伝播する

Arrays.copyOf はプリミティブ配列では値コピーになるが、オブジェクト配列では参照のコピーになる。int[] と Object[] で挙動が異なる点を見落としやすい

Stream + map でディープコピーする際、コピー対象クラスにネストしたオブジェクトがあれば再帰的にコピーしないと浅いコピーに留まる

record は immutable なのでコピーの問題が発生しにくい。ただし record のフィールドが List などの可変オブジェクトを持つ場合、その List 自体は変更可能な点に注意

FAQ

String は参照型なのに浅いコピーで問題にならないのはなぜですか。

String は不変(immutable)なので、参照が共有されても変更が伝播しません。同じ理由で Integer や LocalDate なども浅いコピーで安全です。

clone() メソッドを使ったコピーは推奨されますか。

clone() は Cloneable の契約が曖昧で、浅いコピーしか行わないため推奨されません。コピーコンストラクタかファクトリメソッドのほうが意図が明確です。

record を使えばコピーの問題は完全に解消しますか。

record 自体は不変ですが、フィールドに可変コレクション(ArrayList など)を持つ場合は要注意です。コンパクトコンストラクタで List.copyOf を使い防御コピーすると安全です。

関連書籍

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

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