Java 逆引き集 | CopyOnWriteArrayList(読み取り多い並列環境) — 読み取り優先

Java Java
スポンサーリンク

CopyOnWriteArrayList(読み取り多い並列環境) — 読み取り優先

書き込み時に「配列のコピー」を作って更新し、読み取りや反復を常に安全にするのが CopyOnWriteArrayList。並列環境で読み取りが圧倒的に多く、変更が稀なときに威力を発揮します。反復中の変更でも例外が出ず、イテレータは作成時点のスナップショットを読みます。


特性と向き不向き

  • 読み取り優先: 読み取りやイテレーションはロック待ちなく高速・安全。イテレータは作成時点の内容のスナップショットを参照し、反復中に add/remove しても ConcurrentModificationException は起きない。
  • 書き込みコスト増: add/remove/set などの変更操作は、内部配列のコピーを作るためコストとメモリを消費する(変更頻度が高い場合は非効率)。
  • スレッドセーフ: 変更時は内部的に安全にコピーして置き換えるため、複数スレッドでの読み書きが安全に両立できる。
  • 要素: null を含むすべての要素が許可される(List の一般仕様に準拠)。

基本コード例(読み取りが多い場面)

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

public class COWABasics {
    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);
            list.add("X"); // 反復中の追加でも例外なし(ただしこの反復には反映されない)
        }
    }
}
Java
  • ポイント: 反復中の変更でも例外が出ない代わりに、反復は「作成時点のスナップショット」を読むため、直後の変更は反映されない。

例題で理解する

例題1: 監視用の購読者リスト(購読者が稀に増減、通知は多い)

import java.util.concurrent.CopyOnWriteArrayList;

class Notifier {
    private final CopyOnWriteArrayList<Runnable> listeners = new CopyOnWriteArrayList<>();

    public void addListener(Runnable l) { listeners.add(l); }
    public void removeListener(Runnable l) { listeners.remove(l); }

    public void notifyAllListeners() {
        // スナップショットを安全に走査
        for (Runnable l : listeners) {
            l.run();
        }
    }
}
Java
  • ねらい: 通知(読み取り)が多く、購読者の増減(書き込み)が稀。反復中の変更でも安全に通知できる。

例題2: 設定の読み取りが多いサービス(更新は管理者のみ)

import java.util.concurrent.CopyOnWriteArrayList;

class ConfigService {
    private final CopyOnWriteArrayList<String> endpoints = new CopyOnWriteArrayList<>();

    public ConfigService() { endpoints.add("https://api1"); endpoints.add("https://api2"); }

    public String pickFirst() { return endpoints.get(0); } // 読み取りは速い

    public void addEndpoint(String url) { endpoints.add(url); } // 稀な更新
}
Java
  • ねらい: 読み取り多数・更新稀の典型。スナップショット反復で通知ループも安全。

よくある落とし穴と回避策

  • 落とし穴: 書き込みが多いと極端に遅い
    • 理由: 変更のたびに内部配列をコピーするため高コスト。
    • 回避: 書き込み頻度が高い場合は Collections.synchronizedList(new ArrayList<>())(外部同期+走査は同期)、または ConcurrentLinkedQueueArrayDeque など用途適合の構造に切り替える。
  • 落とし穴: 反復中の変更が反映される前提で設計する
    • 理由: イテレータはスナップショットなので、反復開始後の追加・削除はその反復には見えない。
    • 回避: 反復後に最新を再取得する、または変更通知の別経路を用意する。
  • 落とし穴: メモリ使用量の過小評価
    • 理由: 大きなリストで頻繁に更新するとコピーが増え GC 負荷・メモリ使用量が増える。
    • 回避: 更新をバッチ化する、データ量が多いなら他構造に変更。
  • 落とし穴: ArrayList と同じつもりで並列更新
    • 対比: ArrayList は反復中に変更すると ConcurrentModificationException が起きるが、COWA は起きない。性質の違いを理解して選定する。

操作テンプレート集

  • 宣言
CopyOnWriteArrayList<Type> list = new CopyOnWriteArrayList<>();
Java
  • 追加・削除(変更はコピー発生)
list.add(x);
list.remove(x);
list.set(i, x);
Java
  • 参照・反復(スナップショット)
Type v = list.get(i);
for (Type t : list) { /* 反復中の変更はこの反復に反映されない */ }
Java
  • 安全な通知ループ
for (Listener l : listeners) { l.onEvent(ev); } // 途中で add/remove されても安全
Java

選定の目安(他構造との比較)

  • CopyOnWriteArrayList を選ぶ場面
    • 読み取り・反復が圧倒的に多い。
    • 更新は稀で小さい。
    • 反復中の変更許容(例外なし)とスナップショット挙動が欲しい。
  • 代替の検討
    • LinkedBlockingQueue / ConcurrentLinkedQueue: キュー用途や高頻度投入・排出。
    • Collections.synchronizedList: 変更も頻繁で直列化を許容できる場合。
    • Immutable リストの配布(List.of): 変更しない共有データに最適。

まとめ

  • CopyOnWriteArrayList は「読み取りが多く、変更が稀」な並列環境で強い。反復はスナップショットで安全、反復中の変更でも例外なし。一方で変更操作は配列コピーゆえ重く、頻繁な更新には不向き。用途に応じて他の並行コレクションとの使い分けが鍵です。
タイトルとURLをコピーしました