ConcurrentModificationException をまず感覚でつかむ
ConcurrentModificationException は、
Java のコレクションを使っているときに、かなり高い確率で一度はぶつかる例外です。
意味をざっくり言うと、
「イテレーション(ループ)してる最中に、そのコレクションの中身を“予期しないやり方で”いじったから、もう追いかけられないよ」
という怒られ方です。
特に多いのが、
for-each で List を回している途中で list.remove(...) するIterator で回しているのに、別の場所から同じコレクションを変更する
といったパターンです。
この例外は、「マルチスレッド専用の難しい話」ではありません。
シングルスレッド(1 本の処理)でも普通に出ます。
どんなコードで発生するのか(典型パターン)
一番ありがちな NG コード例
まず、初心者がよく書いてしまうコードから見てみましょう。
import java.util.ArrayList;
import java.util.List;
public class CMEExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add("Andy");
for (String name : list) {
if (name.startsWith("A")) {
list.remove(name); // ここで ConcurrentModificationException の可能性
}
}
}
}
Java実行すると、たいていこうなります。
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:xxx)
...
Java「何で? ただ remove してるだけじゃん」と思いますよね。
ここが ConcurrentModificationException の本質部分です。
for-each と remove の組み合わせが危ない。
for-each が裏でやっていることを思い出す
ここで、以前の「for-each の内部動作」の話を思い出します。
この for-each:
for (String name : list) {
...
}
Javaは、コンパイラがだいたい次のように書き換えています。
for (var it = list.iterator(); it.hasNext(); ) {
String name = it.next();
...
}
Javaつまり、
list.iterator()で Iterator を取得hasNext()/next()で順番に要素をなめる
というコードに変換されています。
そのループの中で、list.remove(name) を呼ぶとどうなるか。
Iterator が「自分の知らないところで List が書き換えられた」と判断し、
「もう安全に走査を続けられない」と言って投げる例外がConcurrentModificationException です。
Java コレクションの「fail-fast」な仕組み
何が起きているのか(ざっくり内部動作)
多くのコレクション(ArrayList, HashSet, HashMap など)は、内部に
「何回構造が変わったか」を表すカウンタ(たとえば modCount)
を持っています。
おおざっぱなイメージはこうです。
イテレータ(Iterator)が生成された時点で、その modCount の値を覚えるnext() や hasNext() を呼ぶたびに、「今の modCount と覚えている値が同じか」をチェックする
もし違っていたら、「他から勝手に変更された」とみなして ConcurrentModificationException を投げる
つまり、Iterator は
「自分が知らないルートで中身をいじられたら危険なので、すぐ落ちる」
という安全装置を持っている、ということです。
なぜそんなものがあるのか
もしこのチェックがなかったらどうなるでしょう。
Iterator が「次はこのインデックスだろう」と思って進んでいる最中に、
別のコードが List に要素を足したり消したりする。
その結果、
読み飛ばしが起きる
同じ要素を二重に見る
存在しないインデックスを参照しようとして、もっと別の例外になる
など、非常に分かりにくいバグになりかねません。
そこで、「おかしな状態になったらすぐに例外を出して止まる」
= fail-fast という設計になっているわけです。
正しい解決策:Iterator の remove を使う
ループ中に安全に削除するためのパターン
ループしながら削除したい、というニーズ自体はよくあります。
その場合は、Iterator 自身の remove() を使います。
先ほどの NG コードを、Iterator を使う形に直してみます。
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class CMEFixed {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add("Andy");
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String name = it.next();
if (name.startsWith("A")) {
it.remove(); // 直前に返した要素を安全に削除
}
}
System.out.println(list); // [Bob]
}
}
Javaここでは、
list.remove(name) ではなく it.remove() を呼んでいます。
Iterator が、自分の内部状態とコレクションの状態を
ちゃんと整合させながら削除してくれるので、ConcurrentModificationException は起きません。
重要なのは、
- for-each の中では
list.removeを呼ばない(そもそも Iterator を意識できない) - 構造変更を伴うループが必要なら、for-each ではなく Iterator を明示的に使う
というスタンスです。
よくある「ダメな直し方」と「安全な直し方」
ダメな直し方:for-each をインデックス for に雑に変える
NG パターンを見て、こう直す人もいます。
for (int i = 0; i < list.size(); i++) {
String name = list.get(i);
if (name.startsWith("A")) {
list.remove(i); // 一見動くが、ロジックがややこしくなりがち
}
}
Java小さい例では動いてしまうこともありますが、
削除によってインデックスがずれて、「見落とし」が起きやすくなります。
「i を進めるのか」「削除したら i をデクリメントするのか」など、
余計なことを大量に考えないといけなくなるので、
おすすめできません。
安全な直し方 1:Iterator.remove を素直に使う
さっきのように、
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String name = it.next();
if (条件) {
it.remove();
}
}
Javaと書くのが、一番シンプルで安全です。
安全な直し方 2:あとでまとめて削除する
別のパターンとして、「削除対象を一旦別コレクションに集めて、あとでまとめて削除」
という方法もあります。
List<String> toRemove = new ArrayList<>();
for (String name : list) {
if (name.startsWith("A")) {
toRemove.add(name);
}
}
list.removeAll(toRemove);
Javaこの場合、for-each 中には remove が登場しないので、ConcurrentModificationException の心配はありません。
- 削除対象が少ない
- コードの意図を分かりやすくしたい
といったときに向いています。
マルチスレッドでの ConcurrentModificationException にも触れておく
別スレッドからいじっても同じように起こる
ここまでの説明は、すべて「1 本のスレッドの中」の話でした。
実は、複数のスレッドから同じコレクションを触っている場合も、
似たようなことが起こります。
片方のスレッドが Iterator でループしている
その最中に、別スレッドが同じコレクションに add や remove をする
このような場合も、
Iterator から見ると「自分の知らないところで変更されている」ので、
やはり ConcurrentModificationException が出ます。
本格的な対策は「スレッドセーフなコレクション」を使う
マルチスレッド環境では、
Collections.synchronizedListConcurrentHashMapCopyOnWriteArrayList
など、スレッドセーフなコレクションを使うのが基本です。
ただ、これは初心者向けとしては一段階進んだ話なので、
まずは「単一スレッドでも普通に起こる例外なんだ」と理解しておけば大丈夫です。
まとめ:ConcurrentModificationException をどう頭に置いておくか
初心者向けに、この例外を一言でまとめると、
「コレクションをイテレータ(for-each 含む)で回している最中に、
想定外の方法で中身を変えたときに、『もう追いかけられない』と怒られる例外」
です。
特に意識しておきたいポイントは、次のあたりです。
- for-each は裏で Iterator を使っている
- ループ中に
list.remove(...)などでコレクションを直接いじると高確率で発生する - 削除したいなら Iterator を明示的に取り出して
it.remove()を使う - もしくは、削除対象を別コレクションに溜めておいて、あとで
removeAllする - マルチスレッドでも同じ例外が出るが、まずは単一スレッドでのパターンをしっかり押さえる
