Help us understand the problem. What is going on with this article?

シングルスレッドでConcurrentModificationExceptionがスローされる、されないのはどのような場合か?

Collectionインターフェイスの実装クラスはスレッドセーフでないものが多い

 Collectionインターフェイスの実装クラスにはスレッドセーフでないものが多いです。例えはArrayListでは要素を参照中に、別スレッドで要素変更を禁止するためにConcurrentModificationExceptionがスローされます。
またシングルスレッドのループ中でArrayListの要素を変更するとこの例外がスローされる場合とされない場合があり、Javaの資格試験でもよく出題されます。この理解が曖昧だったので、どのような場合にどう判断されこの例外がスローされるのか調べてみました。
 結論から言えば例外がスローされないのはArrayListの最後から2番目の要素を削除した場合のみで、後は全て例外がスローされます。

ArrayListの要素をループ中で削除するとConcurrentModificationExceptionがスローされる例

ArrayListに"A", "B", "C"3つの要素を加え、ループで参照中に要素"A"を削除するとjava.util.ConcurrentModificationExceptionがスローされ以下のようなスタックトレースが出力されます。

RemoveAFromList.java
package hoge;

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

public class RemoveAFromList {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("A");
        list.add("B");
        list.add("C");
        for (String str : list) {
            System.out.println(str);
            if ("A".equals(str)) {
                list.remove(str);
            }
        }
    }
}
A
Exception in thread "main" java.util.ConcurrentModificationException
    at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1009)
    at java.base/java.util.ArrayList$Itr.next(ArrayList.java:963)
    at hoge.RemoveAFromList.main(RemoveAFromList.java:25)

 ここで意外だったのがConcurrentModificationExceptionがスローされる箇所です。list.remove(str)で"A"を削除した際ではなく、イテレータArrayList$Itr.next()で次要素の取得を試みた時に例外が投げられていることが分かります。
ConcurrentModificationExceptionのJavaDocを見るとこの動作の説明が書かれています。

単一のスレッドが、オブジェクトの規約に違反する一連のメソッドを発行した場合、オブジェクトはこの例外をスローします。
たとえば、フェイルファスト・イテレータを持つコレクションの反復処理を行いながら、スレッドがコレクションを直接修正する場合、
イテレータはこの例外をスローします。

ではArrayList$Itr.next()はどのような判断を行いConcurrentModificationExceptionをスローしているのかArrayList.javaのコードを見てみました。

ArrayList.java
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    // 省略
    protected transient int modCount = 0;
    // 省略
    private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;
        // 省略
        public boolean hasNext() {
            return cursor != size;
        }

        public E next() {
            checkForComodification();
            // 省略
            return (E) elementData[lastRet = i];
        }

        public void remove() {
            if (lastRet < 0)
                throw new IllegalStateException();
            checkForComodification();
            try {
                ArrayList.this.remove(lastRet);
                cursor = lastRet;
                lastRet = -1;
                expectedModCount = modCount;
            } catch (IndexOutOfBoundsException ex) {
                throw new ConcurrentModificationException();
            }
        }

        // 省略
        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }
    // 省略
}

ArrayList$Itr.next()から実行されるArrayList$Itr.checkForComodification()modCountexpectedModCountが一致しない場合にConcurrentModificationExceptionをスローしていることが分かります。
ArrayListmodCountフィールドはこのインスタンスが生成されてからの変更回数を示しadd(Object)remove(Object)が呼び出される度に1ずつ増えて行きます。よってArryListに"A", "B", "C"を加えた後のmodCountは3になります。
またArrayList$ItrexpectedModCountフィールドの値はインスタンス生成時にmodCountにセットさるので初期値は3です。
ArrayListからremove(str)で"A"を削除するとmodCountは3から4へ増えますがexpectedModCountは3のままなので2つの値は一致しなくなり、次要素の取得時にConcurrentModificationExceptionがスローされます。

ArrayListの要素をループ中で削除する場合にはイテレータを使えと言われますが、
ArrayList$Itr.remove()にて要素を削除するとmodCountを1増加させた後にexpectedModCount = modCountを行い2つの値を揃えています。よって問題なく次要素を取得できます。

 for (String str : list) と書くとIteratorの文字が出てきませんが、実際にはArrayList$Itrで要素を辿っています。そのループ中でイテレータのArrayList$Itr.remove()を使用せず、直接ArrayList.remove(Object)で要素の削除を行なったのが間違いの元ということですね。 

ArrayListの最後から2番目の要素を削除してもConcurrentModificationExceptionはスローされない

上記のコードでArrayListに加えた要素の中で最後から2番目の"B"を削除してもConcurrentModificationExceptionはスローされず正常に終了します。
"B"削除後はArrayList.modCount=4ArrayList.Itr$expectedModeCount=3で一致しないのに何故でしょうか?

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

list.remove("B")を行った際に行われていてる処理を下記の図に示します。2番目の要素"B"を削除すると3番目にある要素"C"が2番目にコピーされ、全体のサイズが2に縮小されます。この間、次要素取得位置を示すカーソルArrayList$Itr.cursorの値に変化はなく、サイズ縮小前の"C"の位置(=2)を指しています。 
ArrayListRemove.png

for (String str : list)では次要素の取得を試みるのですが、実際にはArrayLit$Itr.hasNext()にて次要素の有無を確認してからArrayList$Itr.next()にて要素を取得しています。

        public boolean hasNext() {
            return cursor != size;
        }

最後から2番目の要素"B"を削除するとArrayList.size=2ArrayList$Itr.cursor=2なのでArrayLit$Itr.hasNext()はfalseを返します。つまりイテレータのカーソルが末尾にありこれ以上要素はないと判断され次要素の取得は行いません。ArrayList$Itr.next()が行われずに終了するのでConcurrentModificationExceptionがスローされません。

削除したのはArrayListの最後の要素なのにConcurrentModificationExceptionがスローされる

上記コードのループ中でArrayList最後の要素"C"を削除するとConcurrentModificationExceptionがスローされます。一番最後の要素を削除してもArrayList$Itrのカーソルは末尾なので次要素の取得はできない。よって例外は発生しないはずだと考えると一番腑に落ちないケースです。

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

ArrayList最後の要素"C"を削除した場合の動作を以下の図に示します。
ArrayList$Itr.next()にて"C"を取得した直後はItr.cursor=3でありカーソルは"C"の隣にあるnullの位置を示します。ArrayListObject[]のフィールドを持ち、ここに要素を保持していますがその長さは要素数より大きめに取られています。最後尾の"C"を削除するにはその位置の要素にnullをセットしArrayList.size=2とするだけです。

RemoveC.png

"C"を削除後は次要素の有無をArrayLit$Itr.hasNext()で確認するのですが、もう一度コードを見てみると...

        public boolean hasNext() {
            return cursor != size;
        }

ここでItr.cursor=3, ArrayList.size=2なのでこのメソッドはtrueを返します。つまり次要素があると判断し、次要素の取得時に例外が投げられます。
ArrayList最後の要素をループ中で削除した場合に例外が投げられる仕組みは分かりましたが、次要素がないのにtrueを返してしまう
ArrayLit$Itr.hasNext()のコードはこれでいいのでしょうか? 
ここら辺のところを調べて次のネタにしたいと思います。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした