Javaプログラミングにおいて、コレクションを操作している最中に突如として発生するjava.util.ConcurrentModificationExceptionは、初心者からベテランまで多くの開発者を悩ませる例外の一つです。

この例外は、リストやマップといったデータ構造を反復処理(イテレーション)している最中に、その構造が変更されたことを検知した際にスローされます。

「並行修飾例外」という名称から、マルチスレッド環境特有の問題だと思われがちですが、実は単一のスレッド内でも頻繁に発生します。

本記事では、この例外が発生する内部メカニズムを解き明かし、実務で使える具体的な解決策を網羅的に解説します。

ConcurrentModificationExceptionとは何か

Javaのコレクションフレームワークにおける多くのクラスは、「フェイルファスト (Fail-Fast)」という設計思想に基づいて実装されています。

フェイルファストとは、処理の途中で異常や不整合を検知した場合、そのまま処理を継続して予期せぬ結果を招くよりも、即座に例外を発生させて停止させるという仕組みです。

java.util.ConcurrentModificationException(以下、CMEと略記)は、まさにこのフェイルファストの代表例です。

例えば、ArrayListを拡張forループで走査している最中に、要素を削除したり追加したりすると、イテレータ(Iterator)は「反復中に元のリストが変更された」と判断し、安全のためにこの例外をスローします。

構造上の変更 (Structural Modification)

CMEが発生する直接の引き金は、コレクションに対する構造上の変更です。

これには、要素の追加(add)、削除(remove)、あるいはリスト全体をクリアする操作などが含まれます。

一方で、既存の要素の内容を書き換える(setメソッドによる値の更新など)操作は構造上の変更とは見なされないため、通常はCMEを発生させません。

modCountによる検知の仕組み

Javaのコレクション内部には、modCountというフィールドが用意されています。

これはコレクションの構造が変更されるたびにインクリメントされるカウンタです。

イテレータが生成される際、その時点のmodCountの値がイテレータ内部のexpectedModCountという変数にコピーされます。

イテレータがnext()メソッドで次の要素をフェッチするたびに、modCountexpectedModCountが一致しているかどうかがチェックされます。

もし不一致であれば、他の場所でコレクションが変更されたと見なし、即座にCMEがスローされる仕組みになっています。

例外が発生する典型的なケース

まずは、どのようなコードを書いたときにこの例外が発生するのか、具体的なサンプルプログラムで確認してみましょう。

最も多いパターンは、拡張forループ内での要素削除です。

拡張forループ内での削除操作

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

public class CmeExample {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<>();
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");

        try {
            // 拡張forループ(内部的にはIteratorを使用)で走査
            for (String fruit : fruits) {
                if (fruit.equals("Banana")) {
                    // ループの中でリストから直接削除
                    fruits.remove(fruit); 
                }
            }
        } catch (Exception e) {
            // 例外の内容を出力
            e.printStackTrace();
        }
    }
}
実行結果
java.util.ConcurrentModificationException
    at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
    at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)
    at CmeExample.main(CmeExample.java:13)

このコードでは、Bananaを削除した直後のループ継続時に例外が発生します。

拡張forループはコンパイル時にイテレータ(Iterator)を使用するコードに変換されますが、削除操作にはArrayList自身のremoveメソッドが使われています。

このため、リスト側のmodCountは増えますが、イテレータ側のexpectedModCountが更新されず、不整合が発生するのです。

CMEを回避する4つの主要な解決策

CMEを回避し、安全にコレクションの要素を操作するためには、いくつかの手法があります。

用途やJavaのバージョンに応じて最適なものを選択しましょう。

1. Iteratorのremoveメソッドを使用する

最も古典的で確実な方法は、明示的にIteratorオブジェクトを使用し、そのremove()メソッドを呼び出すことです。

Java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class IteratorSolution {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<>(List.of("Apple", "Banana", "Cherry"));

        Iterator<String> iterator = fruits.iterator();
        while (iterator.hasNext()) {
            String fruit = iterator.next();
            if (fruit.equals("Banana")) {
                // Iterator経由で削除することで、expectedModCountも適切に更新される
                iterator.remove(); 
            }
        }

        System.out.println("結果: " + fruits);
    }
}
実行結果
結果: [Apple, Cherry]

Iterator.remove()を使用すると、要素の削除と同時に内部のexpectedModCountも現在のmodCountと同期されるため、例外が発生しません。

2. Java 8以降のremoveIfを使用する(推奨)

Java 8で導入されたCollection.removeIf()メソッドは、条件に合致する要素を一括で削除するための最も簡潔な方法です。

内部的に効率的な処理が行われるため、モダンなJava開発においてはこの方法が第一選択となります。

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

public class RemoveIfSolution {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<>(List.of("Apple", "Banana", "Cherry"));

        // ラムダ式を使用して条件を指定
        fruits.removeIf(fruit -> fruit.equals("Banana"));

        System.out.println("結果: " + fruits);
    }
}
実行結果
結果: [Apple, Cherry]

このメソッドはコードが宣言的で読みやすく、かつCMEの心配が一切ないため非常に安全です。

3. スナップショット(コピー)をループさせる

元のコレクションを直接ループさせるのではなく、そのコピーを作成してループさせる手法です。

コピーに対してループを行い、削除などの変更操作は元のコレクションに対して行うため、構造変更の衝突が起こりません。

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

public class CopySolution {
    public static void main(String[] args) {
        List<String> fruits = new ArrayList<>(List.of("Apple", "Banana", "Cherry"));

        // 元のリストのコピーを作成してループを回す
        for (String fruit : new ArrayList<>(fruits)) {
            if (fruit.equals("Banana")) {
                fruits.remove(fruit);
            }
        }

        System.out.println("結果: " + fruits);
    }
}

この方法は直感的ですが、リストのサイズが大きい場合にメモリ消費量や計算コストが増大するという欠点があります。

小規模なリストであれば問題ありませんが、パフォーマンスが重視される場面では注意が必要です。

4. Stream APIを使用して新しいリストを生成する

「元のリストを変更する」のではなく、「条件に合う要素だけで新しいリストを作る」という考え方です。

関数型プログラミングに近いアプローチであり、副作用を最小限に抑えることができます。

Java
import java.util.List;
import java.util.stream.Collectors;

public class StreamSolution {
    public static void main(String[] args) {
        List<String> fruits = List.of("Apple", "Banana", "Cherry");

        // 条件に合うものだけをフィルタリングして新しいリストを作成
        List<String> filteredFruits = fruits.stream()
                .filter(fruit -> !fruit.equals("Banana"))
                .collect(Collectors.toList());

        System.out.println("元のリスト: " + fruits);
        System.out.println("新しいリスト: " + filteredFruits);
    }
}

この方法は不変性(Immutability)を保つのに適しており、マルチスレッド環境などでの安全性を高める際にも有効です。

マルチスレッド環境におけるCMEの対策

ここまでは単一スレッド内でのCMEについて説明してきましたが、CMEは「複数のスレッドが同時に1つのコレクションを操作する」際にも発生します。

一方が反復処理を行っている最中に、別のスレッドが要素を追加・削除すると、たとえコード上で適切なイテレータを使っていてもCMEがスローされます。

このような場合には、標準のArrayListHashMapではなく、java.util.concurrentパッケージが提供する「スレッドセーフなコレクション」を使用する必要があります。

CopyOnWriteArrayListの活用

CopyOnWriteArrayListは、その名の通り「書き込み時にコピーを作成する」リストです。

このリストから生成されたイテレータは、生成時点の「スナップショット」を保持します。

そのため、反復中に他のスレッド(あるいは自分自身)が要素を変更しても、イテレータが見ているデータには影響がなく、CMEが発生しません。

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

public class ConcurrentSolution {
    public static void main(String[] args) {
        // スレッドセーフなリストを使用
        List<String> fruits = new CopyOnWriteArrayList<>(List.of("Apple", "Banana", "Cherry"));

        for (String fruit : fruits) {
            if (fruit.equals("Banana")) {
                // ループ中に直接削除してもCMEは発生しない
                fruits.remove(fruit);
            }
        }

        System.out.println("結果: " + fruits);
    }
}

ただし、書き込みのたびに配列全体をコピーするため、「読み取り頻度が非常に高く、書き込み頻度が非常に低い」というシナリオに最適化されています。

ConcurrentHashMapの活用

マップ(Map)を使用している場合は、ConcurrentHashMapを利用します。

このクラスのイテレータは「弱一貫性 (Weakly Consistent)」を持っており、反復中に構造が変更されてもCMEをスローしないように設計されています。

Java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentMapSolution {
    public static void main(String[] args) {
        Map<String, Integer> stock = new ConcurrentHashMap<>();
        stock.put("Apple", 10);
        stock.put("Banana", 20);
        stock.put("Cherry", 30);

        for (String key : stock.keySet()) {
            if (key.equals("Banana")) {
                stock.remove(key); // 安全に削除可能
            }
        }
        System.out.println("在庫状況: " + stock);
    }
}

注意が必要な特殊なケース

CMEの挙動にはいくつか例外的なパターンや、間違いやすいポイントがあります。

要素の更新(set)は例外にならない

前述の通り、List.set(index, element)などのメソッドで既存の要素を置き換える操作は、構造上の変更とは見なされません。

Java
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
for (String s : list) {
    if (s.equals("B")) {
        list.set(1, "Updated B"); // これはCMEを発生させない
    }
}

最後の要素の削除

非常に稀なケースですが、特定の条件下(例えばリストの最後から2番目の要素を削除した直後にループが終了する場合など)で、偶然にもカウンタの不整合が検知されず、例外が出ないこともあります。

しかし、これは実装の詳細に依存する未定義の動作であり、決して依存してはいけません。

Collections.synchronizedListとの関係

Collections.synchronizedList()でラップされたリストは、メソッド単位では同期化されていますが、イテレータによる反復処理は同期化されません

そのため、マルチスレッド環境でこれを使用する場合、拡張forループを利用する際には開発者が明示的にリストオブジェクトをsynchronizedブロックで保護する必要があります。

これを怠るとCMEが発生します。

まとめ

java.util.ConcurrentModificationExceptionは、Javaプログラムの信頼性を守るための「警報」のような存在です。

この例外が発生したときは、コレクションの走査と変更が同じタイミングで行われていないかをまず確認しましょう。

単一スレッドでの解決策としては、以下の優先順位で検討することをお勧めします。

removeIf()

最もシンプルで効率的(Java 8以降)。

Iterator.remove()

ループ内で複雑なロジックが必要な場合。

Stream API

元のデータを変更せず、新しいリストを作りたい場合。

また、マルチスレッド環境においてはCopyOnWriteArrayListConcurrentHashMapといった並行コレクションを適切に選択することで、パフォーマンスを維持しつつ安全なコードを記述できます。

CMEの仕組みを正しく理解し、適切なAPIを選択することは、バグの少ない堅牢なJavaアプリケーションを開発するための重要な一歩です。

この記事で紹介した手法を活用して、安全なコレクション操作をマスターしてください。