Java Tips | コレクション:CopyOnWrite利用

Java Java
スポンサーリンク

CopyOnWrite は「書き込みのたびに“丸ごとコピー”して安全を買う」仕組み

CopyOnWrite 系コレクション(CopyOnWriteArrayList / CopyOnWriteArraySet)は、
「読み取りが圧倒的に多く、書き込みはたまにしかない」 場面で使う、ちょっと変わったスレッドセーフコレクションです。

名前の通り、
「書き込み(Write)が起きたときに、内部配列をコピー(Copy)してから反映する」
という動きをします。

その代わり、読み取り(for-each や get)はロックなしで高速・安全に行えます。
この“トレードオフ”を理解することが、CopyOnWrite を使いこなす第一歩です。


基本イメージ:読み取りは速く、書き込みは重く

どういうときに向いているのか

CopyOnWrite の設計思想を一言で言うと、
「読み取りの快適さのために、書き込みのコストを犠牲にする」 です。

向いている場面の典型は、こんな感じです。

  • リスナー一覧(イベント購読者のリスト)
  • 設定値の一覧(めったに変わらない)
  • 参照専用のデータ(起動時に読み込んで、あとは読むだけ)

逆に、
「毎秒何百回も add/remove するようなリスト」には向きません。
書き込みのたびに内部配列をコピーするので、すぐに重くなります。


CopyOnWriteArrayList の基本的な使い方

まずは普通の List と同じ感覚で触ってみる

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteBasic {

    public static void main(String[] args) {
        List<String> list = new CopyOnWriteArrayList<>();

        list.add("A");
        list.add("B");
        list.add("C");

        for (String s : list) {
            System.out.println(s);
        }
    }
}
Java

見た目はほぼ ArrayList と同じです。
add / remove / get / for-each など、基本操作はそのまま使えます。

ここで重要なのは、
「for-each 中に別スレッドが add/remove しても、ConcurrentModificationException が出ない」
という点です。

イテレーションは“スナップショット”を見ているので、
ループ開始時点の配列を最後まで安全に走り切れます。


重要ポイント1:イテレーションが“スナップショット”であること

「ループ中に追加された要素は見えない」ことを理解する

CopyOnWrite のイテレーションは、
ループ開始時点の配列をコピーして、それを読み続けるイメージです。

List<String> list = new CopyOnWriteArrayList<>();
list.add("A");
list.add("B");

for (String s : list) {
    System.out.println(s);
    list.add("X"); // ループ中に追加
}
Java

このコードは例外になりません。
ただし、ループ中に追加した "X" は、そのループでは一切見えません。

出力は AB だけです。

ここでの重要ポイントは二つです。

一つ目は、「CopyOnWrite の for-each は“開始時点の状態”だけを見る」ということ。
ループ中の変更は反映されません。

二つ目は、「それでいい場面でだけ使うべき」ということ。
「ループ中に追加された要素もすぐ処理したい」なら、CopyOnWrite は向きません。


重要ポイント2:書き込みのたびに配列コピーが走る

add/remove が重くなる理由

CopyOnWriteArrayList の内部は、ざっくり言うと「不変の配列」を持っています。
add するときは、

  1. 現在の配列をコピーして新しい配列を作る
  2. 新しい配列に要素を追加する
  3. 参照を新しい配列に差し替える

という流れになります。

要素数が 10 件なら大したことはありませんが、
要素数が 10,000 件のリストに対して頻繁に add すると、
毎回 10,000 件分のコピーが走ることになります。

だからこそ、
「書き込みはたまにしか起きない」
という前提がとても大事です。


具体例1:イベントリスナー一覧に CopyOnWriteArrayList を使う

「リスナーの登録はたまに、イベント発火は頻繁」なケース

イベント駆動のコードでは、
「リスナーの一覧」を複数スレッドから読む・書くことがよくあります。

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class EventBus {

    public interface Listener {
        void onEvent(String message);
    }

    private final List<Listener> listeners = new CopyOnWriteArrayList<>();

    public void addListener(Listener l) {
        listeners.add(l); // たまにしか呼ばれない想定
    }

    public void removeListener(Listener l) {
        listeners.remove(l);
    }

    public void fire(String message) {
        for (Listener l : listeners) { // 頻繁に呼ばれる
            l.onEvent(message);
        }
    }
}
Java

ここでの重要ポイントは三つです。

一つ目は、「イベント発火(fire)が頻繁に呼ばれても、for-each がロックなしで安全に回る」こと。
リスナー一覧のスナップショットを見ているので、ConcurrentModificationException も出ません。

二つ目は、「リスナーの追加・削除はたまにしか起きない」という前提で設計していること。
ここが崩れると、CopyOnWrite のメリットよりデメリットが勝ちます。

三つ目は、「リスナー追加・削除とイベント発火が別スレッドでも、コードがシンプルに書ける」こと。
同期ブロックを自分で書かなくてよいのは、実務でかなり嬉しいポイントです。


具体例2:設定値の一覧を CopyOnWriteArrayList で持つ

「起動時に読み込んで、あとはほぼ読むだけ」のデータ

例えば、「許可されたIPアドレスの一覧」をメモリに持っておき、
リクエストごとにチェックするようなケース。

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class AllowedIpList {

    private final List<String> ips = new CopyOnWriteArrayList<>();

    public void loadFromConfig(List<String> loaded) {
        ips.clear();
        ips.addAll(loaded);
    }

    public boolean isAllowed(String ip) {
        return ips.contains(ip);
    }
}
Java

ここでのポイントは二つです。

一つ目は、「isAllowed が高頻度で呼ばれても、ロックなしで安全に動く」こと。
contains は内部的に配列を読むだけなので、読み取りが軽いです。

二つ目は、「設定の再読み込み(loadFromConfig)はたまにしか起きない」という前提です。
ここでも「読み取り多・書き込み少」のパターンにハマっています。


CopyOnWrite を選ぶかどうかの判断軸

3つの質問でざっくり決める

CopyOnWrite を使うか迷ったら、次の3つを自分に聞いてみてください。

  1. 読み取りと書き込み、どちらが圧倒的に多いか
  2. イテレーション中に変更されても「そのループでは見えなくてよい」か
  3. 要素数はそこまで大きくならないか(数百〜数千程度までか)

全部「はい」なら、CopyOnWrite はかなり有力な候補です。
一つでも「いいえ」が混ざるなら、ConcurrentHashMapCollections.synchronizedList など、別の選択肢を検討した方が安全です。


まとめ:CopyOnWrite利用で身につけてほしい感覚

CopyOnWrite 系コレクションは、
単に「スレッドセーフな List/Set」ではなく、
「読み取りの快適さのために、書き込みコストを意図的に高くしている特殊な道具」 です。

読み取りが圧倒的に多く、書き込みはたまにしかない場面を選ぶ。
イテレーションは“スナップショット”であり、ループ中の変更は見えないことを理解する。
書き込みのたびに配列コピーが走るので、頻繁な更新や巨大なリストには使わない。
イベントリスナー一覧や設定値一覧など、「読み取り中心の共有データ」に当てはめてみる。

あなたのコードのどこかに、
「リスナー一覧」「設定値一覧」「参照専用のデータ」を複数スレッドから読んでいる箇所があれば、
そこを一度「CopyOnWrite で書けないか?」という目で眺めてみてください。

その小さな選択が、
「アクセスパターンを意識してコレクションを選べるエンジニア」への、
いい一歩になります。

タイトルとURLをコピーしました