Javaのオブジェクトコピーには、参照先を共有したままの「シャローコピー(浅いコピー)」と、参照先までたどって複製する「ディープコピー(深いコピー)」があります。 clone() を super.clone() だけで実装した場合はシャローコピーで、フィールドが指すリストや配列は元のオブジェクトと共有されます。ディープコピーにするには、変更できるフィールドを1つずつ複製する処理を自分で書きます。

この記事では、代入・clone・コピーコンストラクタ・new ArrayList<>(list)・List.copyOf・配列のclone・シリアライズの7方式について、「コピー後に元を書き換えると、コピー側も変わるか」を同じ条件で実行して比べます。Cloneableを付けていないときの例外、ネストしたリストが共有されたままになる例と直し方、コピーそのものが不要になるrecordと不変オブジェクトの設計、ディープコピーのテンプレートも実行結果つきで紹介します。

確認環境:Ubuntu 24.04/OpenJDK 21.0.10。仕様に関する記述は、Java SE 21 のAPIドキュメント(Object、List)を開いて確認した内容です。

コピー方法7方式の早見表

元のオブジェクトに対して (A) フィールドに別の値を代入、(B) リストに要素を追加、(C) 要素の中身を書き換え、の3つを行い、コピー側に変化が見えるかを実行して確かめた結果です。

方式 書き方 (A) フィールドの付け替え (B) リストへの追加 (C) 要素の中身の変更 分類
1 代入 b = a; 変わる 変わる 変わる コピーではない(同じオブジェクト)
2 clone(super.cloneのみ) a.clone() 変わらない 変わる 変わる 浅い
3 コピーコンストラクタ(中身も複製) new Article(a) 変わらない 変わらない 変わらない 深い
4 new ArrayList new ArrayList<>(list) ― 変わらない 変わる 浅い(リスト1段分)
5 List.copyOf List.copyOf(list) ― 変わらない 変わる 浅い+変更不可
6 配列のclone array.clone() 変わらない(要素の付け替え) ― 変わる 浅い(配列1段分)
7 シリアライズ 書き出して読み戻す 変わらない 変わらない 変わらない 深い

用語の整理です。

用語 意味 コピー後に元を変更すると
代入(参照のコピー) 同じオブジェクトを指す変数を増やすだけ すべての変更がもう一方から見える
シャローコピー(浅いコピー) 新しいオブジェクトを作り、フィールドの値(参照型なら参照)をそのまま写す 参照先(リスト・配列・ほかのオブジェクト)の変更は見える
ディープコピー(深いコピー) 参照先のオブジェクトもたどって複製する どの変更も見えない
不変(イミュータブル) 作った後に状態を変えられない 変更そのものが起きないので、共有しても影響がない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java オブジェクトのコピー|cloneとディープコピーの違い」

7方式を同じ条件で実行して比べる

記事(Article)がタグ(Tag)のリストを持つ、という構造で確かめます。Tagは label を後から書き換えられるクラスです。

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

public class SevenWays {
    // 変更できる要素(タグ)と、それを持つ記事
    static class Tag implements Serializable {
        String label;
        Tag(String label) { this.label = label; }
        Tag(Tag other) { this.label = other.label; }          // コピーコンストラクタ
        @Override public String toString() { return label; }
    }

    static class Article implements Cloneable, Serializable {
        String title;
        List<Tag> tags;

        Article(String title, List<Tag> tags) {
            this.title = title;
            this.tags = tags;
        }

        // コピーコンストラクタ(ディープコピー)
        Article(Article other) {
            this.title = other.title;
            this.tags = new ArrayList<>();
            for (Tag t : other.tags) {
                this.tags.add(new Tag(t));
            }
        }

        // super.clone() だけの clone(シャローコピー)
        @Override
        public Article clone() {
            try {
                return (Article) super.clone();
            } catch (CloneNotSupportedException e) {
                throw new AssertionError(e);
            }
        }

        @Override public String toString() { return title + tags; }
    }

    static Article newArticle() {
        List<Tag> tags = new ArrayList<>();
        tags.add(new Tag("java"));
        return new Article("元の記事", tags);
    }

    @SuppressWarnings("unchecked")
    static <T extends Serializable> T serialCopy(T src) throws Exception {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
            out.writeObject(src);
        }
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
            return (T) in.readObject();
        }
    }

    // 元を3通りに書き換えて、コピー側がどう見えるかを表示する
    static void report(String way, Article original, Article copy) {
        original.title = "変更後";                 // (A) フィールドの付け替え
        original.tags.add(new Tag("added"));       // (B) リストに要素を追加
        original.tags.get(0).label = "JAVA";       // (C) 要素の中身を書き換え
        System.out.printf("%-22s コピー側 = %s%n", way, copy);
    }

    public static void main(String[] args) throws Exception {
        Article o1 = newArticle();
        report("1 代入", o1, o1);

        Article o2 = newArticle();
        report("2 clone(super.cloneのみ)", o2, o2.clone());

        Article o3 = newArticle();
        report("3 コピーコンストラクタ", o3, new Article(o3));

        Article o7 = newArticle();
        report("7 シリアライズ", o7, serialCopy(o7));

        System.out.println("--- リストと配列(要素は Tag)---");
        List<Tag> src = new ArrayList<>(List.of(new Tag("java")));
        List<Tag> viaNew = new ArrayList<>(src);
        List<Tag> viaCopyOf = List.copyOf(src);
        Tag[] array = {new Tag("java")};
        Tag[] viaArrayClone = array.clone();

        src.add(new Tag("added"));        // (B) 追加
        src.get(0).label = "JAVA";        // (C) 要素の書き換え
        array[0].label = "JAVA";          // (C)
        array[0] = new Tag("replaced");   // (A) 要素の付け替え

        System.out.println("4 new ArrayList<>(list) コピー側 = " + viaNew);
        System.out.println("5 List.copyOf(list)     コピー側 = " + viaCopyOf);
        System.out.println("6 配列.clone()          コピー側 = " + java.util.Arrays.toString(viaArrayClone));
    }
}
1 代入                   コピー側 = 変更後[JAVA, added]
2 clone(super.cloneのみ) コピー側 = 元の記事[JAVA, added]
3 コピーコンストラクタ           コピー側 = 元の記事[java]
7 シリアライズ               コピー側 = 元の記事[java]
--- リストと配列(要素は Tag)---
4 new ArrayList<>(list) コピー側 = [JAVA]
5 List.copyOf(list)     コピー側 = [JAVA]
6 配列.clone()          コピー側 = [JAVA]

コピーを取った時点の内容は、どの方式も 元の記事[java] です。そこからの違いを読み解きます。

方式 コピー側の表示 読み取れること
1 代入 変更後[JAVA, added] タイトルもリストも要素も、すべて元と同じ。オブジェクトは1つしかない
2 clone 元の記事[JAVA, added] タイトルは別々に持つが、tagsは同じリストを共有。追加も要素の変更も見える
3 コピーコンストラクタ 元の記事[java] リストもTagも複製したので、元の変更は一切見えない
7 シリアライズ 元の記事[java] オブジェクトのつながり全体が複製される
4 new ArrayList [JAVA] リストは別物(addedは増えていない)。中のTagは共有(labelの変更が見える)
5 List.copyOf [JAVA] 4と同じく要素は共有。加えて、コピー側は変更できないリストになる
6 配列のclone [JAVA] 配列は別物(replacedに置き換わっていない)。中のTagは共有

「新しい入れ物を作ったか」と「中身まで複製したか」は別の話です。方式2・4・5・6はどれも入れ物だけを新しくするので、中に入っているオブジェクトが変更できる型なら、その変更は両方から見えます。

flowchart LR
    subgraph S["シャローコピー"]
        A1["元 Article"] --> L1["tags(リスト)"]
        A2["コピー Article"] --> L1
        L1 --> T1["Tag: java"]
    end
    subgraph D["ディープコピー"]
        B1["元 Article"] --> M1["tags(リスト)"] --> U1["Tag: java"]
        B2["コピー Article"] --> M2["tags(リスト)"] --> U2["Tag: java"]
    end

clone()の仕組みと実装の手順

ObjectクラスのAPIドキュメント(clone)には、次のことが書かれています。

ドキュメントの記載 意味
x.clone() != x が true、x.clone().getClass() == x.getClass() が true になる。ただし絶対の要件ではない cloneは同じクラスの別オブジェクトを返すのが一般的
クラスが Cloneable を実装していなければ CloneNotSupportedException がスローされる Cloneableは「cloneしてよい」という目印
このメソッドが行うのは「シャローコピー」であり「ディープコピー」ではない フィールドの内容はそのまま写される
慣例として、返すオブジェクトは super.clone を呼んで取得する new ではなく super.clone() を使う
変更可能なオブジェクトで構成される内部の「深い構造」は、コピーしたうえで参照を差し替える必要がある場合がある ディープコピーにしたいときは自分で複製する
すべての配列は Cloneable を実装していると見なされ、配列型 T[] の clone の戻り値の型は T[] 配列はキャストなしで array.clone() と書ける

clone()を使えるようにする手順は4つです。

  1. クラスに implements Cloneable を付ける。
  2. clone() を public でオーバーライドする(Objectのcloneは protected)。
  3. 中で super.clone() を呼び、自分のクラスにキャストする。
  4. 変更できる参照型のフィールドは、複製して差し替える。

Cloneableを付けていないときの CloneNotSupportedException

手順1を省いた例です。

public class NoCloneable {
    static class Memo {                 // implements Cloneable を付けていない
        String text = "memo";

        @Override
        public Memo clone() throws CloneNotSupportedException {
            return (Memo) super.clone();
        }
    }

    public static void main(String[] args) throws Exception {
        Memo copy = new Memo().clone();
        System.out.println(copy.text);
    }
}
Exception in thread "main" java.lang.CloneNotSupportedException: NoCloneable$Memo
    at java.base/java.lang.Object.clone(Native Method)
    at NoCloneable$Memo.clone(NoCloneable.java:7)
    at NoCloneable.main(NoCloneable.java:12)

コンパイルは通り、実行時に止まります。メッセージにはクラス名(NoCloneable$Memo)が入るので、そのクラスの宣言に implements Cloneable を足せば解消します。

反対に、Cloneableは付けたものの手順2(オーバーライド)を省いた場合は、コンパイルの時点でわかります。

public class NoOverride {
    static class Memo implements Cloneable {
        String text = "memo";
    }
    public static void main(String[] args) throws Exception {
        Memo copy = (Memo) new Memo().clone();
    }
}
NoOverride.java:6: error: clone() has protected access in Object
        Memo copy = (Memo) new Memo().clone();
                                     ^
1 error
症状 原因 対処
実行時に CloneNotSupportedException: クラス名 implements Cloneable がない クラス宣言に implements Cloneable を足す
コンパイル時に clone() has protected access in Object cloneをpublicでオーバーライドしていない public のcloneメソッドを書く
cloneしたのに、片方の変更がもう片方に出る super.clone() だけでシャローコピーになっている 参照型フィールドを複製して差し替える(次の節)

ネストしたListが共有されたままになる例と直し方

super.clone() だけのcloneで、リストを持つオブジェクトをコピーします。

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

public class NestedShared {
    static class Order implements Cloneable {
        String customer;
        List<String> items = new ArrayList<>();

        @Override
        public Order clone() {
            try {
                return (Order) super.clone();       // items は同じリストを指したまま
            } catch (CloneNotSupportedException e) {
                throw new AssertionError(e);
            }
        }
    }

    static class OrderFixed implements Cloneable {
        String customer;
        List<String> items = new ArrayList<>();

        @Override
        public OrderFixed clone() {
            try {
                OrderFixed copy = (OrderFixed) super.clone();
                copy.items = new ArrayList<>(this.items);   // リストを作り直す
                return copy;
            } catch (CloneNotSupportedException e) {
                throw new AssertionError(e);
            }
        }
    }

    public static void main(String[] args) {
        Order a = new Order();
        a.customer = "Sato";
        a.items.add("ノート");
        Order b = a.clone();
        b.customer = "Abe";
        b.items.add("ペン");
        System.out.println("直す前 a: " + a.customer + " " + a.items);
        System.out.println("直す前 b: " + b.customer + " " + b.items);
        System.out.println("同じリストか: " + (a.items == b.items));

        OrderFixed c = new OrderFixed();
        c.customer = "Sato";
        c.items.add("ノート");
        OrderFixed d = c.clone();
        d.customer = "Abe";
        d.items.add("ペン");
        System.out.println("直した後 c: " + c.customer + " " + c.items);
        System.out.println("直した後 d: " + d.customer + " " + d.items);
        System.out.println("同じリストか: " + (c.items == d.items));

        // 入れ子のリスト
        List<List<String>> table = new ArrayList<>();
        table.add(new ArrayList<>(List.of("A1", "B1")));
        List<List<String>> shallow = new ArrayList<>(table);
        List<List<String>> deep = new ArrayList<>();
        for (List<String> row : table) {
            deep.add(new ArrayList<>(row));
        }
        table.get(0).set(0, "変更");
        System.out.println("元     : " + table);
        System.out.println("shallow: " + shallow);
        System.out.println("deep   : " + deep);

        // 2次元配列
        int[][] grid = {{1, 2}, {3, 4}};
        int[][] g1 = grid.clone();
        int[][] g2 = new int[grid.length][];
        for (int i = 0; i < grid.length; i++) {
            g2[i] = grid[i].clone();
        }
        grid[0][0] = 99;
        System.out.println("grid.clone()   : " + java.util.Arrays.deepToString(g1));
        System.out.println("行ごとにclone  : " + java.util.Arrays.deepToString(g2));
    }
}
直す前 a: Sato [ノート, ペン]
直す前 b: Abe [ノート, ペン]
同じリストか: true
直した後 c: Sato [ノート]
直した後 d: Abe [ノート, ペン]
同じリストか: false
元     : [[変更, B1]]
shallow: [[変更, B1]]
deep   : [[A1, B1]]
grid.clone()   : [[99, 2], [3, 4]]
行ごとにclone  : [[1, 2], [3, 4]]

直す前は、Abeさんの注文に「ペン」を足しただけなのに、Satoさんの注文にも「ペン」が入っています。customer は別々になっているため一見コピーできているように見え、リストだけが共有されていることに気づきにくいのがこの形の特徴です。a.items == b.items が true になることで、同じリストを指していると確認できます。

対象 1段だけのコピー(共有が残る) 中までコピーする書き方
リストを持つオブジェクト super.clone() のみ cloneの中で copy.items = new ArrayList<>(this.items);
リストのリスト new ArrayList<>(table) 行ごとに new ArrayList<>(row) して詰め直す
2次元配列 grid.clone() 行ごとに grid[i].clone()

new ArrayList<>(this.items) で足りるのは、要素がStringのように不変な型の場合です。要素が変更できるオブジェクト(先ほどのTagなど)なら、要素も1つずつ複製します。

コピーコンストラクタで書く方法

コピーコンストラクタは、同じクラスのオブジェクトを引数に取り、その内容で新しいオブジェクトを作るコンストラクタです。先ほどの Article(Article other) と Tag(Tag other) がそれに当たります。

Article(Article other) では、Stringの title は不変なのでそのまま代入し、リストは新しく作り、要素も new Tag(t) で1つずつ複製しています。

cloneとコピーコンストラクタを比べます。

観点 clone() コピーコンストラクタ
準備 implements Cloneable、publicでのオーバーライド、例外の処理、キャスト コンストラクタを1つ書くだけ
既定の動き super.clone() が全フィールドを自動で写す(シャロー) 書いたフィールドだけが写る
finalフィールド super.cloneの後から差し替えるとコンパイルエラー(cannot assign a value to final variable) コンストラクタ内なので代入できる
付け忘れたときの気づき方 実行時の CloneNotSupportedException、またはコンパイルエラー フィールドを書き忘れると初期値のままになる
コピーの深さ 見た目ではわかりにくい(中を読む必要がある) コンストラクタの中身にそのまま書いてある
配列 array.clone() が手軽 ―

自分で作るクラスでは、コピーコンストラクタ(または copy() のようなメソッド)のほうが、何をどこまで複製しているかがコードに現れて読みやすくなります。 cloneは、配列の複製と、すでにcloneを公開しているクラスを使う場面で活躍します。

リストと配列のコピー方法の違い

リストのコピーは、「変更できるコピー」「変更できないコピー」「元を映すビュー」の3種類を区別します。

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class ListCopies {
    public static void main(String[] args) {
        List<String> src = new ArrayList<>(List.of("a", "b"));

        List<String> assigned = src;                               // 代入
        List<String> copied = new ArrayList<>(src);                // 変更できるコピー
        List<String> frozen = List.copyOf(src);                    // 変更できないコピー
        List<String> view = Collections.unmodifiableList(src);     // 変更できないビュー
        List<String> viaStream = src.stream().toList();            // 変更できないコピー

        src.add("c");
        System.out.println("src                      : " + src);
        System.out.println("代入                     : " + assigned);
        System.out.println("new ArrayList<>(src)     : " + copied);
        System.out.println("List.copyOf(src)         : " + frozen);
        System.out.println("unmodifiableList(src)    : " + view);
        System.out.println("src.stream().toList()    : " + viaStream);

        try {
            frozen.add("x");
        } catch (UnsupportedOperationException e) {
            System.out.println("frozen.add -> " + e);
        }
        try {
            List.copyOf(Arrays.asList("a", null));
        } catch (NullPointerException e) {
            System.out.println("nullを含むリストのcopyOf -> " + e.getClass().getSimpleName());
        }
        List<String> again = List.copyOf(frozen);
        System.out.println("List.copyOf(不変リスト) は同じインスタンスか: " + (again == frozen));

        int[] nums = {1, 2, 3};
        int[] c1 = nums.clone();
        int[] c2 = Arrays.copyOf(nums, nums.length);
        int[] c3 = Arrays.copyOfRange(nums, 1, 3);
        int[] c4 = new int[3];
        System.arraycopy(nums, 0, c4, 0, 3);
        nums[0] = 99;
        System.out.println("nums=" + Arrays.toString(nums) + " clone=" + Arrays.toString(c1)
                + " copyOf=" + Arrays.toString(c2) + " copyOfRange=" + Arrays.toString(c3)
                + " arraycopy=" + Arrays.toString(c4));
    }
}
src                      : [a, b, c]
代入                     : [a, b, c]
new ArrayList<>(src)     : [a, b]
List.copyOf(src)         : [a, b]
unmodifiableList(src)    : [a, b, c]
src.stream().toList()    : [a, b]
frozen.add -> java.lang.UnsupportedOperationException
nullを含むリストのcopyOf -> NullPointerException
List.copyOf(不変リスト) は同じインスタンスか: true
nums=[99, 2, 3] clone=[1, 2, 3] copyOf=[1, 2, 3] copyOfRange=[2, 3] arraycopy=[1, 2, 3]
書き方 元に追加した "c" が見えるか コピー側を変更できるか 補足
List<String> b = src; 見える できる(元も変わる) 同じリスト
new ArrayList<>(src) 見えない できる 変更できるコピー
List.copyOf(src) 見えない できない(UnsupportedOperationException) nullを含むとNullPointerException
Collections.unmodifiableList(src) 見える できない 元を映すビュー。コピーではない
src.stream().toList() 見えない できない 変更できないコピー

Collections.unmodifiableList は名前から「固定されたコピー」に見えますが、元のリストへの変更がそのまま映ります。元と切り離したいときは List.copyOf を使います。

ListのAPIドキュメントでは、List.copyOf について「渡したコレクションが後から変更されても、返されたリストには反映されない」「nullであってはならず、null要素を含んでもならない」と説明されています。また実装上の注記として、渡したものがすでに変更不可のリストなら、一般にコピーを作らないとあります。実行結果でも、不変リストを List.copyOf に渡すと同じインスタンスが返りました。

同じドキュメントには、変更不可のリストでも「含まれる要素自体が変更可能なら、リストの内容が変わったように見えることがある」とも書かれています。7方式の比較で、List.copyOf のコピー側が [JAVA] に変わったのはこのためです。「変更できないリスト」が守るのは要素の追加・削除・置き換えまでで、要素の中身までは守りません。

配列は、clone()・Arrays.copyOf・Arrays.copyOfRange・System.arraycopy のどれでも、元の nums[0] = 99 の影響を受けない別の配列になりました。プリミティブの配列はこれで十分です。オブジェクトの配列は、7方式の比較(方式6)で見たとおり要素が共有されます。

シリアライズでディープコピーする方法と注意点

シリアライズは、オブジェクトをバイト列に書き出して読み戻すことで、つながっているオブジェクト全体を複製する方法です。冒頭の serialCopy メソッドがその実装で、方式7として動作を確認しました。

使うには、たどり着くすべてのクラスが Serializable を実装している必要があります。1つでも欠けていると、次の例外になります。

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class SerialNo {
    static class Tag { String label = "java"; }            // Serializable ではない
    static class Article implements Serializable {
        Tag tag = new Tag();
    }
    public static void main(String[] args) throws Exception {
        try (ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream())) {
            out.writeObject(new Article());
        }
    }
}
Exception in thread "main" java.io.NotSerializableException: SerialNo$Tag
    at java.base/java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1200)
    at java.base/java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1585)
    (以下略)

処理時間も比べました。1,000個のTagを持つリストを1,000回ディープコピーした結果です(数値は環境と実行ごとに変わります)。

1回目: コピーコンストラクタ 123 ms / シリアライズ 3509 ms
2回目: コピーコンストラクタ 63 ms / シリアライズ 2006 ms
3回目: コピーコンストラクタ 9 ms / シリアライズ 1095 ms
観点 コピーコンストラクタ シリアライズ
書く量 フィールドごとに複製を書く 汎用メソッド1つで済む
前提 なし 関係する全クラスが Serializable
処理時間(今回の計測、3回目) 9 ms 1095 ms
フィールドを足したとき コピー処理にも足す必要がある 自動で含まれる

今回の計測では、シリアライズはコピーコンストラクタより大幅に時間がかかりました。構造が深く、手で書くと量が多い場合の選択肢として覚えておき、回数の多い処理ではコピーコンストラクタを選ぶ、という使い分けが目安になります。

recordと不変オブジェクトならコピーが不要になる

ここまでの共有の問題は、どれも「コピーした後で中身を変更する」ことから生まれています。作った後に変更できないオブジェクトなら、共有しても影響が出ないので、コピー自体が不要になります。 String、Integer、LocalDate を代入で受け渡しても困らないのは、これらが不変だからです。

recordはフィールドがすべてfinalになるので、不変オブジェクトを短く書けます。ただし、リストのような変更できる型を持たせる場合は、コンストラクタで List.copyOf して受け取ります(防御的コピー)。

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

public class RecordDemo {
    // 不変: フィールドはすべて final、リストは List.copyOf で受け取る
    record Profile(String name, int level, List<String> skills) {
        Profile {
            skills = List.copyOf(skills);     // 防御的コピー
        }

        Profile withLevel(int newLevel) {     // 変更したいときは新しいインスタンスを返す
            return new Profile(name, newLevel, skills);
        }
    }

    // 防御的コピーをしない record
    record Loose(String name, List<String> skills) { }

    public static void main(String[] args) {
        List<String> input = new ArrayList<>(List.of("Java"));
        Profile p1 = new Profile("Sato", 1, input);
        Profile p2 = p1;                      // 代入で共有してよい
        Profile p3 = p1.withLevel(2);

        input.add("SQL");                     // 渡した後で元のリストを変更
        System.out.println("p1: " + p1);
        System.out.println("p2: " + p2);
        System.out.println("p3: " + p3);
        System.out.println("p1.equals(p2): " + p1.equals(p2) + " / p1 == p2: " + (p1 == p2));
        System.out.println("skills は共有か: " + (p1.skills() == p3.skills()));
        try {
            p1.skills().add("Go");
        } catch (UnsupportedOperationException e) {
            System.out.println("p1.skills().add -> " + e.getClass().getSimpleName());
        }

        List<String> input2 = new ArrayList<>(List.of("Java"));
        Loose loose = new Loose("Abe", input2);
        input2.add("SQL");
        System.out.println("防御的コピーなし: " + loose);
    }
}
p1: Profile[name=Sato, level=1, skills=[Java]]
p2: Profile[name=Sato, level=1, skills=[Java]]
p3: Profile[name=Sato, level=2, skills=[Java]]
p1.equals(p2): true / p1 == p2: true
skills は共有か: true
p1.skills().add -> UnsupportedOperationException
防御的コピーなし: Loose[name=Abe, skills=[Java, SQL]]
結果 意味
inputに "SQL" を足しても p1 は [Java] のまま コンストラクタの List.copyOf で、渡された側のリストと切り離せている
p3 は level だけが 2 「変更」は新しいインスタンスを作って表す。p1 は元のまま
p1.skills() == p3.skills() が true 変更できないリストは共有してよいので、複製の手間もメモリも節約できる
Looseは [Java, SQL] に変わった recordでも、防御的コピーがないと外のリストの変更が入り込む

recordにするだけでは「浅い不変」で、中のリストまで固定するには List.copyOf を足す、という点がポイントです。要素もStringやrecordのような不変の型でそろえると、全体が不変になり、コピーを考える必要がなくなります。

ディープコピーのテンプレート

変更できるクラスをディープコピーしたいときの、フィールドの型ごとの書き方です。nullの場合も考慮しています。

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class DeepCopyTemplate {
    static class Address {
        String city;
        Address(String city) { this.city = city; }
        Address(Address other) { this.city = other.city; }
    }

    static class Member {
        int id;                         // プリミティブ: そのまま代入
        String name;                    // 不変オブジェクト: そのまま代入
        LocalDate joined;               // 不変オブジェクト: そのまま代入
        Address address;                // 変更できるオブジェクト: コピーを作る
        int[] scores;                   // プリミティブの配列: clone()
        List<String> tags;              // 不変な要素のリスト: new ArrayList<>(...)
        List<Address> branches;         // 変更できる要素のリスト: 要素ごとにコピー
        Map<String, List<String>> notes; // 入れ子: 値のリストも作り直す

        Member() { }

        // ディープコピー用のコピーコンストラクタ
        Member(Member other) {
            this.id = other.id;
            this.name = other.name;
            this.joined = other.joined;
            this.address = other.address == null ? null : new Address(other.address);
            this.scores = other.scores == null ? null : other.scores.clone();
            this.tags = other.tags == null ? null : new ArrayList<>(other.tags);
            if (other.branches != null) {
                this.branches = new ArrayList<>();
                for (Address a : other.branches) {
                    this.branches.add(new Address(a));
                }
            }
            if (other.notes != null) {
                this.notes = new HashMap<>();
                other.notes.forEach((k, v) -> this.notes.put(k, new ArrayList<>(v)));
            }
        }

        @Override
        public String toString() {
            return id + " " + name + " " + joined + " " + address.city + " " + Arrays.toString(scores)
                    + " " + tags + " " + branches.stream().map(a -> a.city).toList() + " " + notes;
        }
    }

    public static void main(String[] args) {
        Member m = new Member();
        m.id = 1;
        m.name = "Sato";
        m.joined = LocalDate.of(2026, 4, 1);
        m.address = new Address("Tokyo");
        m.scores = new int[] {80, 90};
        m.tags = new ArrayList<>(List.of("java"));
        m.branches = new ArrayList<>(List.of(new Address("Osaka")));
        m.notes = new HashMap<>(Map.of("memo", new ArrayList<>(List.of("first"))));

        Member copy = new Member(m);

        m.id = 2;
        m.name = "Abe";
        m.address.city = "Nagoya";
        m.scores[0] = 0;
        m.tags.add("sql");
        m.branches.get(0).city = "Fukuoka";
        m.notes.get("memo").add("second");

        System.out.println("元    : " + m);
        System.out.println("コピー: " + copy);
    }
}
元    : 2 Abe 2026-04-01 Nagoya [0, 90] [java, sql] [Fukuoka] {memo=[first, second]}
コピー: 1 Sato 2026-04-01 Tokyo [80, 90] [java] [Osaka] {memo=[first]}

元の7か所を書き換えても、コピー側はすべてコピー時点の値のままです。

フィールドの型 ディープコピーでの書き方 理由
プリミティブ(int、booleanなど) this.id = other.id; 値そのものが写る
不変オブジェクト(String、LocalDate、Integer、recordなど) this.name = other.name; 変更されないので共有してよい
変更できるオブジェクト new Address(other.address) コピーコンストラクタで複製
プリミティブの配列 other.scores.clone() 配列1段の複製で足りる
不変な要素のリスト new ArrayList<>(other.tags) リスト1段の複製で足りる
変更できる要素のリスト ループで要素ごとに複製して追加 要素も複製が必要
値がリストのMap new HashMap<>() に、値を new ArrayList<>(v) して入れ直す Mapと値の両方を作り直す

どの方法を選ぶかの判断表

状況 おすすめ 理由
これから新しくクラスを設計する recordや不変クラスにする コピーが不要になり、共有による影響が起きない
変更できるクラスを複製したい コピーコンストラクタ 何を複製しているかがコードに現れ、finalフィールドにも対応できる
リストを受け取って保持する/返す List.copyOf(list) 渡した側・受け取った側のどちらの変更も入り込まない
リストを複製して、後で追加・削除したい new ArrayList<>(list) 変更できるコピーになる(要素が変更できる型なら要素も複製)
配列を複製したい array.clone()(2次元以上は行ごと) キャスト不要で1行で書ける
構造が深く、関係するクラスがすべてSerializable シリアライズ 汎用メソッド1つでたどって複製できる(処理時間は長め)
同じオブジェクトを別名で扱いたいだけ 代入 コピーではないことを理解したうえで使う

コピーを書いたときのチェックリスト

  • [ ] コピー後に元のリストへ要素を追加して、コピー側が変わらないことを確かめた。
  • [ ] コピー後に元の要素の中身を書き換えて、コピー側が変わらないことを確かめた。
  • [ ] 元.フィールド == コピー.フィールド が false になることを確かめた(不変な型は true でよい)。
  • [ ] cloneを使う場合、implements Cloneable と public のオーバーライドの両方がある。
  • [ ] 「変更できないリスト」と「変更できないビュー」を取り違えていない。
  • [ ] フィールドを追加したとき、コピーコンストラクタにも追加した。

よくある質問

Q. clone()はディープコピーですか? A. super.clone() だけの実装はシャローコピーです。ObjectクラスのAPIドキュメントにも、このメソッドが行うのはシャローコピーでありディープコピーではない、と書かれています。ディープコピーにするには、cloneの中で参照型のフィールドを複製して差し替えます。

Q. Stringのフィールドもコピーし直す必要がありますか? A. 不要です。Stringは不変なので、同じ文字列を共有しても片方の変更がもう片方に出ることはありません。今回の比較でも、元の title に別の文字列を代入したとき、clone側は「元の記事」のままでした。

Q. List.copyOfとnew ArrayList<>(list)はどう使い分けますか? A. コピー後に追加・削除をするなら new ArrayList<>(list)、変更させたくないなら List.copyOf(list) です。どちらもリスト1段分のコピーで、要素が変更できるオブジェクトなら要素は共有されます。List.copyOf はnull要素を含むリストを渡すとNullPointerExceptionになる点も違いです。

Q. CloneNotSupportedExceptionが出たらどうすればよいですか? A. メッセージに出ているクラスの宣言に implements Cloneable を足します。そのクラスを自分で変更できない場合は、コピーコンストラクタや、フィールドを1つずつ写すメソッドを別に用意します。

まとめ

  • 代入はコピーではない。 同じオブジェクトを指す変数が増えるだけで、すべての変更が両方から見える。
  • super.clone() だけのclone、new ArrayList<>(list)、List.copyOf、配列のcloneはシャローコピー。 入れ物は別になるが、中のオブジェクトは共有される。
  • ディープコピーは、コピーコンストラクタで参照先まで1つずつ複製するのがわかりやすい。シリアライズでも可能だが、全クラスにSerializableが必要で処理時間も長め。
  • cloneを使うには implements Cloneable と public のオーバーライドが必要。 片方が欠けると CloneNotSupportedException か、protectedアクセスのコンパイルエラーになる。
  • リストを持つオブジェクトは、リストの共有に気づきにくい。 元.items == コピー.items で確かめ、cloneやコピーコンストラクタの中でリストを作り直す。
  • Collections.unmodifiableList はビュー、List.copyOf はコピー。 元と切り離したいときは List.copyOf。
  • recordと不変オブジェクトにすると、コピー自体が不要になる。 リストを持つrecordは、コンストラクタで List.copyOf して受け取る。

参考・出典

  • Java SE 21 API ドキュメント Object(clone) https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Object.html
  • Java SE 21 API ドキュメント List(copyOf、Unmodifiable Lists) https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/List.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java オブジェクトのコピー|cloneとディープコピーの違い」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?