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 でループしている
その最中に、別スレッドが同じコレクションに addremove をする

このような場合も、
Iterator から見ると「自分の知らないところで変更されている」ので、
やはり ConcurrentModificationException が出ます。

本格的な対策は「スレッドセーフなコレクション」を使う

マルチスレッド環境では、

Collections.synchronizedList
ConcurrentHashMap
CopyOnWriteArrayList

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

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


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

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

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

です。

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

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

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