[Java]新人向けにjava.util.ArrayListで発生するConcurrentModificationExceptionについて解説


はじめに

シングルスレッドでも発生するConcurrentModificationException

  • アンスレッドセーフなjava.util.ArrayListインスタンスが、別スレッドから変更されるとjava.util.ConcurrentModificationExceptionが発生するのは割りと知られた話ですがシングルスレッドな処理でも発生します。
  • その手の問題が模擬試験に諸に出題されるということで質問を受けました。
  • ということで、シングルスレッドでも発生するConcurrentModificationExceptionについてです。
  • 内容としては、以下の場合はExceptionスローされないけど。
DenaiConcurrentModificationException.java
public static void main(String[] args) {
    List<String> list = new ArrayList<String>();

    list.add("A");
    list.add("B");
    list.add("C");
    list.add("D");

    for (String str : list) {
        if ("C".equals(str)) {
            list.remove(str);
        } else {
            System.out.println(str);
        }
    }
}

  • この場合はConcurrentModificationExceptionがスローされるという内容。
DeruConcurrentModificationException.java
public static void main(String[] args) {
    List<String> list = new ArrayList<String>();

    list.add("A");
    list.add("B");
    list.add("C");
    list.add("D");
    list.add("E");

    for (String str : list) {
        if ("C".equals(str)) {
            list.remove(str);
        } else {
            System.out.println(str);
        }
    }
}

どういう時にConcurrentModificationExceptionが発生しますか?

  • 出題のされ方としては、上記どちらかのコードが記載され、その結果はどうなるか?といった内容になるとのこと。
  • 観点はConcurrentModificationExceptionがスローされるか、されないか。になって、ざっくりとした試験対策的にはlistの最後から2番目 or 最後の要素をremoveする場合はスロー「されない」し、それ以外は「される」となります。
  • 上記だけだとあれなので、一応docとコードを見ておきましょう。

Java 8 SE ConcurrentModificationExceptionドキュメント

この例外は、オブジェクトの並行変更を検出したメソッドによって、そのような変更が許可されていない場合にスローされます。
たとえば、あるスレッドがCollectionで反復処理を行っている間に、別のスレッドがそのCollectionを変更することは一般に許可されません。
通常、そのような環境では、反復処理の結果は保証されません。
いくつかのイテレータ(Iterator)の実装(JREが提供するすべての一般的な目的のコレクションの実装の、イテレータの実装を含む)は、
その動作が検出された場合にこの例外をスローすることを選択できます。
この例外をスローするイテレータは、フェイルファスト・イテレータと呼ばれます。
イテレータは、将来の予測できない時点において予測できない動作が発生する危険を回避するために、ただちにかつ手際よく例外をスローします。
  • 要するに反復処理中に別のスレッドからの変更を検知したらスローするといった内容です。
  • 文中にある「フェイルファスト・イテレータ」は、早く失敗しろという経営学的?な意味ではなくて、想定外の変更の可能性があるならば即座に例外を発生して処理を中断するイテレータといった意味です。
  • シングルスレッド時のExceptionについては、ここでは触れてないのでもう少し読み進めます。
この例外は、オブジェクトが別のスレッドによって並行して更新されていないことを必ずしも示しているわけではありません。
単一のスレッドが、オブジェクトの規約に違反する一連のメソッドを発行した場合、オブジェクトはこの例外をスローします。
たとえば、フェイルファスト・イテレータを持つコレクションの反復処理を行いながら、スレッドがコレクションを直接修正する場合、
イテレータはこの例外をスローします。
  • まず、java.util.ArrayListはフェイルファスト・イテレータです。
  • で、反復処理を行いながら、スレッドがコレクションを直接修正する場合、必ずConcurrentModificationExceptionがスローされるされるらしいです。
  • でも、発生しない場合もある...みたいなので、実装も見てみましょう。

java.util.ArrayListの実装を見てみる

  • まず、拡張forループは内部でイテレータを使っているのが分かります。
java
/**
 * An optimized version of AbstractList.Itr
 */
private class Itr implements Iterator<E> {}
  • で、カーソルが移動する度にexpectedModCountとmodCountに差異があればConcurrentModificationExceptionをスローしてます。 プロパティの役割は名前から察してください。
java
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
  • 更に読み進めると、removeでmodCountをインクリメントしていて、お陰でConcurrentModificationExceptionが発生していることが分かります。
java
/*
 * Private remove method that skips bounds checking and does not
 * return the value removed.
 */
private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
}
  • System.arraycopy(elementData, index+1, elementData, index, numMoved);に着目すると、C要素を消す場合以下のように後ろの要素を繰り上げてることが分かります。


  • 上記の図の通り、要素が繰り上がってカーソルは変わらないので、list.remove("C")の次は要素3が処理されます。前段は3が存在しないので、処理はそのまま終わって、後段は処理が継続されcheckForComodificationが実行され、ConcurrentModificationExceptionがスローされるわけです。

まとめ

  • コピペでつらつら書きましたが、実装見てデバッグすれば分かるのです。
  • 資格講座中も実際にデバッグして、回答を導き出したのでした。。