Java | Java 標準ライブラリ:ConcurrentModificationException

Java Java
スポンサーリンク
スポンサーリンク

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.synchronizedList
ConcurrentHashMap
CopyOnWriteArrayList

など、スレッドセーフなコレクションを使うのが基本です。

ただ、これは初心者向けとしては一段階進んだ話なので、
まずは「単一スレッドでも普通に起こる例外なんだ」と理解しておけば大丈夫です。


まとめ:ConcurrentModificationException をどう頭に置いておくか

初心者向けに、この例外を一言でまとめると、

「コレクションをイテレータ(for-each 含む)で回している最中に、
 想定外の方法で中身を変えたときに、『もう追いかけられない』と怒られる例外」

です。

特に意識しておきたいポイントは、次のあたりです。

  • for-each は裏で Iterator を使っている
  • ループ中に list.remove(...) などでコレクションを直接いじると高確率で発生する
  • 削除したいなら Iterator を明示的に取り出して it.remove() を使う
  • もしくは、削除対象を別コレクションに溜めておいて、あとで removeAll する
  • マルチスレッドでも同じ例外が出るが、まずは単一スレッドでのパターンをしっかり押さえる

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