java.util.ConcurrentModificationException 異常問題詳解


環境:JDK 1.8.0_111

在Java開發過程中,使用iterator遍歷集合的同時對集合進行修改就會出現java.util.ConcurrentModificationException異常,本文就以ArrayList為例去理解和解決這種異常。

一、單線程情況下問題分析及解決方案

1.1 問題復現

先上一段拋異常的代碼。

 1     public void test1()  {
 2         ArrayList<Integer> arrayList = new ArrayList<>();
 3         for (int i = 0; i < 20; i++) {
 4             arrayList.add(Integer.valueOf(i));
 5         }
 6 
 7         // 復現方法一
 8         Iterator<Integer> iterator = arrayList.iterator();
 9         while (iterator.hasNext()) {
10             Integer integer = iterator.next();
11             if (integer.intValue() == 5) {
12                 arrayList.remove(integer);
13             }
14         }
15 
16         // 復現方法二
17         iterator = arrayList.iterator();
18         for (Integer value : arrayList) {
19             Integer integer = iterator.next();
20             if (integer.intValue() == 5) {
21                 arrayList.remove(integer);
22             }
23         }
24     }

在這個代碼中展示了兩種能拋異常的實現方式。

1.2、問題原因分析

先來看實現方法一,方法一中使用Iterator遍歷ArrayList, 拋出異常的是iterator.next()。看下Iterator next方法實現源碼

 1         public E next() {
 2             checkForComodification();
 3             int i = cursor;
 4             if (i >= size)
 5                 throw new NoSuchElementException();
 6             Object[] elementData = ArrayList.this.elementData;
 7             if (i >= elementData.length)
 8                 throw new ConcurrentModificationException();
 9             cursor = i + 1;
10             return (E) elementData[lastRet = i];
11         }
12 
13         final void checkForComodification() {
14             if (modCount != expectedModCount)
15                 throw new ConcurrentModificationException();
16         }

在next方法中首先調用了checkForComodification方法,該方法會判斷modCount是否等於expectedModCount,不等於就會拋出java.util.ConcurrentModificationExcepiton異常。

我們接下來跟蹤看一下modCount和expectedModCount的賦值和修改。

modCount是ArrayList的一個屬性,繼承自抽象類AbstractList,用於表示ArrayList對象被修改次數。

1 protected transient int modCount = 0;

整個ArrayList中修改modCount的方法比較多,有add、remove、clear、ensureCapacityInternal等,凡是設計到ArrayList對象修改的都會自增modCount屬性。

在創建Iterator的時候會將modCount賦值給expectedModCount,在遍歷ArrayList過程中,沒有其他地方可以設置expectedModCount了,因此遍歷過程中expectedModCount會一直保持初始值20(調用add方法添加了20個元素,修改了20次)。

1 int expectedModCount = modCount; // 創建對象時初始化

遍歷的時候是不會觸發modCount自增的,但是遍歷到integer.intValue() == 5的時候,執行了一次arrayList.remove(integer),這行代碼執行后modCount++變為了21,但此時的expectedModCount仍然為20。

1         final void checkForComodification() {
2             if (modCount != expectedModCount)
3                 throw new ConcurrentModificationException();
4         }

在執行next方法時,遇到modCount != expectedModCount方法,導致拋出異常java.util.ConcurrentModificationException。

明白了拋出異常的過程,但是為什么要這么做呢?很明顯這么做是為了阻止程序員在不允許修改的時候修改對象,起到保護作用,避免出現未知異常。引用網上的一段解釋,點擊查看解釋來源

Iterator 是工作在一個獨立的線程中,並且擁有一個 mutex 鎖。 
Iterator 被創建之后會建立一個指向原來對象的單鏈索引表,當原來的對象數量發生變化時,這個索引表的內容不會同步改變。
當索引指針往后移動的時候就找不到要迭代的對象,所以按照 fail-fast 原則 Iterator 會馬上拋出 java.util.ConcurrentModificationException 異常。
所以 Iterator 在工作的時候是不允許被迭代的對象被改變的。但你可以使用 Iterator 本身的方法 remove() 來刪除對象, Iterator.remove() 方法會在刪除當前迭代對象的同時維護索引的一致性。

再來分析下第二種for循環拋異常的原因:

 1     public void forEach(Consumer<? super E> action) {
 2         Objects.requireNonNull(action);
 3         final int expectedModCount = modCount;
 4         @SuppressWarnings("unchecked")
 5         final E[] elementData = (E[]) this.elementData;
 6         final int size = this.size;
 7         for (int i=0; modCount == expectedModCount && i < size; i++) {
 8             action.accept(elementData[i]);
 9         }
10         if (modCount != expectedModCount) {
11             throw new ConcurrentModificationException();
12         }
13     }

在for循環中一開始也是對expectedModCount采用modCount進行賦值。在進行for循環時每次都會有判定條件modCount == expectedModCount,當執行完arrayList.remove(integer)之后,該判定條件返回false退出循環,然后執行if語句,結果同樣拋出java.util.ConcurrentModificationException異常。

這兩種復現方法實際上都是同一個原因導致的。

1.3 問題解決方案

上述的兩種復現方法都是在單線程運行的,先來說明單線程中的解決方案:

 1     public void test2() {
 2         ArrayList<Integer> arrayList = new ArrayList<>();
 3         for (int i = 0; i < 20; i++) {
 4             arrayList.add(Integer.valueOf(i));
 5         }
 6 
 7         Iterator<Integer> iterator = arrayList.iterator();
 8         while (iterator.hasNext()) {
 9             Integer integer = iterator.next();
10             if (integer.intValue() == 5) {
11  iterator.remove(); 12             }
13         }
14     }

這種解決方案最核心的就是調用iterator.remove()方法。我們看看該方法源碼為什么這個方法能避免拋出異常

 1         public void remove() {
 2             if (lastRet < 0)
 3                 throw new IllegalStateException();
 4             checkForComodification();
 5 
 6             try {
 7                 ArrayList.this.remove(lastRet);
 8                 cursor = lastRet;
 9                 lastRet = -1;
10                 expectedModCount = modCount; 11             } catch (IndexOutOfBoundsException ex) {
12                 throw new ConcurrentModificationException();
13             }
14         }

在iterator.remove()方法中,同樣調用了ArrayList自身的remove方法,但是調用完之后並非就return了,而是expectedModCount = modCount重置了expectedModCount值,使二者的值繼續保持相等。

針對forEach循環並沒有修復方案,因此在遍歷過程中同時需要修改ArrayList對象,則需要采用iterator遍歷。

上面提出的解決方案調用的是iterator.remove()方法,如果不僅僅是想調用remove方法移除元素,還想增加元素,或者替換元素,是否可以呢?瀏覽Iterator源碼可以發現這是不行的,Iterator只提供了remove方法。

但是ArrayList實現了ListIterator接口,ListIterator類繼承了Iter,這些操作都是可以實現的,使用示例如下:

 1     public void test3() {
 2         ArrayList<Integer> arrayList = new ArrayList<>();
 3         for (int i = 0; i < 20; i++) {
 4             arrayList.add(Integer.valueOf(i));
 5         }
 6 
 7         ListIterator<Integer> iterator = arrayList.listIterator();
 8         while (iterator.hasNext()) {
 9             Integer integer = iterator.next();
10             if (integer.intValue() == 5) {
11                 iterator.set(Integer.valueOf(6));
12                 iterator.remove();
13                 iterator.add(integer);
14             }
15         }
16     }

二、 多線程情況下的問題分析及解決方案

單線程問題解決了,再來看看多線程情況。

2.1 問題復現

 1     public void test4() {
 2         ArrayList<Integer> arrayList = new ArrayList<>();
 3         for (int i = 0; i < 20; i++) {
 4             arrayList.add(Integer.valueOf(i));
 5         }
 6 
 7         Thread thread1 = new Thread(new Runnable() {
 8             @Override
 9             public void run() {
10                 ListIterator<Integer> iterator = arrayList.listIterator();
11                 while (iterator.hasNext()) {
12                     System.out.println("thread1 " + iterator.next().intValue());
13                     try {
14                         Thread.sleep(1000);
15                     } catch (InterruptedException e) {
16                         e.printStackTrace();
17                     }
18                 }
19             }
20         });
21 
22         Thread thread2 = new Thread(new Runnable() {
23             @Override
24             public void run() {
25                 ListIterator<Integer> iterator = arrayList.listIterator();
26                 while (iterator.hasNext()) {
27                     System.out.println("thread2 " + iterator.next().intValue());
28                     iterator.remove();
29                 }
30             }
31         });
32         thread1.start();
33         thread2.start();
34     }

在個測試代碼中,開啟兩個線程,一個線程遍歷,另外一個線程遍歷加修改。程序輸出結果如下

thread1 0
thread2 0
thread2 1
thread2 2
thread2 3
thread2 4
thread2 5
thread2 6
thread2 7
thread2 8
thread2 9
thread2 10
thread2 11
thread2 12
thread2 13
thread2 14
thread2 15
thread2 16
thread2 17
thread2 18
thread2 19
Exception in thread "Thread-0" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
	at java.util.ArrayList$Itr.next(ArrayList.java:851)
	at com.snow.ExceptionTest$1.run(ExceptionTest.java:74)
	at java.lang.Thread.run(Thread.java:745)

Process finished with exit code 0

2.2 問題分析

從上面代碼執行結果可以看出thread2 遍歷結束后,thread1 sleep完1000ms准備遍歷第二個元素,next的時候拋出異常了。我們從時間點分析一下拋異常的原因

時間點 arrayList.modCount thread1 iterator.expectedModCount thread2 iterator.expectedModCount
thread start,初始化iterator 20 20 20
thread2.remove()調用之后 21 20 21

 

 

 

兩個thread都是使用的同一個arrayList,thread2修改完后modCount = 21,此時thread2的expectedModCount = 21 可以一直遍歷到結束;thread1的expectedModCount仍然為20,因為thread1的expectedModCount只是在初始化的時候賦值,其后並未被修改過。因此當arrayList的modCount被thread2修改為21之后,thread1想繼續遍歷必定會拋出異常了。

在這個示例代碼里面,兩個thread,每個thread都有自己的iterator,當thread2通過iterator方法修改expectedModCount必定不會被thread1感知到。這個跟ArrayList非線程安全是無關的,即使這里面的ArrayList換成Vector也是一樣的結果,不信上測試代碼:

 1     public void test5() {
 2         Vector<Integer> vector = new Vector<>();
 3         for (int i = 0; i < 20; i++) {
 4             vector.add(Integer.valueOf(i));
 5         }
 6 
 7         Thread thread1 = new Thread(new Runnable() {
 8             @Override
 9             public void run() {
10                 ListIterator<Integer> iterator = vector.listIterator();
11                 while (iterator.hasNext()) {
12                     System.out.println("thread1 " + iterator.next().intValue());
13                     try {
14                         Thread.sleep(1000);
15                     } catch (InterruptedException e) {
16                         e.printStackTrace();
17                     }
18                 }
19             }
20         });
21 
22         Thread thread2 = new Thread(new Runnable() {
23             @Override
24             public void run() {
25                 ListIterator<Integer> iterator = vector.listIterator();
26                 while (iterator.hasNext()) {
27                     Integer integer = iterator.next();
28                     System.out.println("thread2 " + integer.intValue());
29                     if (integer.intValue() == 5) {
30                         iterator.remove();
31                     }
32                 }
33             }
34         });
35         thread1.start();
36         thread2.start();
37     }

執行后輸出結果為:

thread1 0
thread2 0
thread2 1
thread2 2
thread2 3
thread2 4
thread2 5
thread2 6
thread2 7
thread2 8
thread2 9
thread2 10
thread2 11
thread2 12
thread2 13
thread2 14
thread2 15
thread2 16
thread2 17
thread2 18
thread2 19
Exception in thread "Thread-0" java.util.ConcurrentModificationException
	at java.util.Vector$Itr.checkForComodification(Vector.java:1184)
	at java.util.Vector$Itr.next(Vector.java:1137)
	at com.snow.ExceptionTest$3.run(ExceptionTest.java:112)
	at java.lang.Thread.run(Thread.java:745)

Process finished with exit code 0

test5()方法執行結果和test4()是相同的,那如何解決這個問題呢?

2.3 多線程下的解決方案

2.3.1 方案一:iterator遍歷過程加同步鎖,鎖住整個arrayList

 1     public static void test5() {
 2         ArrayList<Integer> arrayList = new ArrayList<>();
 3         for (int i = 0; i < 20; i++) {
 4             arrayList.add(Integer.valueOf(i));
 5         }
 6 
 7         Thread thread1 = new Thread(new Runnable() {
 8             @Override
 9             public void run() {
10                 synchronized (arrayList) {
11                     ListIterator<Integer> iterator = arrayList.listIterator();
12                     while (iterator.hasNext()) {
13                         System.out.println("thread1 " + iterator.next().intValue());
14                         try {
15                             Thread.sleep(100);
16                         } catch (InterruptedException e) {
17                             e.printStackTrace();
18                         }
19                     }
20                 }
21             }
22         });
23 
24         Thread thread2 = new Thread(new Runnable() {
25             @Override
26             public void run() {
27                 synchronized (arrayList) {
28                     ListIterator<Integer> iterator = arrayList.listIterator();
29                     while (iterator.hasNext()) {
30                         Integer integer = iterator.next();
31                         System.out.println("thread2 " + integer.intValue());
32                         if (integer.intValue() == 5) {
33                             iterator.remove();
34                         }
35                     }
36                 }
37             }
38         });
39         thread1.start();
40         thread2.start();
41     }

這種方案本質上是將多線程通過加鎖來轉變為單線程操作,確保同一時間內只有一個線程去使用iterator遍歷arrayList,其它線程等待,效率顯然是只有單線程的效率。

2.3.2 方案二:使用CopyOnWriteArrayList,有坑!要明白原理再用,否則你就呆坑里吧。

我們先來看代碼,很有意思咯

 1     public void test6() {
 2         List<Integer> list = new CopyOnWriteArrayList<>();
 3         for (int i = 0; i < 20; i++) {
 4             list.add(Integer.valueOf(i));
 5         }
 6 
 7         Thread thread1 = new Thread(new Runnable() {
 8             @Override
 9             public void run() {
10                 ListIterator<Integer> iterator = list.listIterator();
11                 while (iterator.hasNext()) {
12                     System.out.println("thread1 " + iterator.next().intValue());
13                     try {
14                         Thread.sleep(1000);
15                     } catch (InterruptedException e) {
16                         e.printStackTrace();
17                     }
18                 }
19             }
20         });
21 
22         Thread thread2 = new Thread(new Runnable() {
23             @Override
24             public void run() {
25                 for (Integer integer : list) {
26                     System.out.println("thread2 " + integer.intValue());
27                     if (integer.intValue() == 5) {
28                         list.remove(integer);
29                     }
30                 }
31                 for (Integer integer : list) {
32                     System.out.println("thread2 again " + integer.intValue());
33                 }
34 //                ListIterator<Integer> iterator = list.listIterator();
35 //                while (iterator.hasNext()) {
36 //                    Integer integer = iterator.next();
37 //                    System.out.println("thread2 " + integer.intValue());
38 //                    if (integer.intValue() == 5) {
39 //                        iterator.remove();
40 //                    }
41 //                }
42             }
43         });
44         thread1.start();
45         thread2.start();
46     }

先不分析,看執行結果,這個執行結果重點關注字體加粗部分。

thread1 0
thread2 0
thread2 1
thread2 2
thread2 3
thread2 4
thread2 5
thread2 6
thread2 7
thread2 8
thread2 9
thread2 10
thread2 11
thread2 12
thread2 13
thread2 14
thread2 15
thread2 16
thread2 17
thread2 18
thread2 19
thread2 again 0
thread2 again 1
thread2 again 2
thread2 again 3
thread2 again 4
thread2 again 6
thread2 again 7
thread2 again 8
thread2 again 9
thread2 again 10
thread2 again 11
thread2 again 12
thread2 again 13
thread2 again 14
thread2 again 15
thread2 again 16
thread2 again 17
thread2 again 18
thread2 again 19
thread1 1
thread1 2
thread1 3
thread1 4
thread1 5
thread1 6
thread1 7
thread1 8
thread1 9
thread1 10
thread1 11
thread1 12
thread1 13
thread1 14
thread1 15
thread1 16
thread1 17
thread1 18
thread1 19

Process finished with exit code 0

我們先分析thread2的輸出結果,第一次遍歷將4 5 6都輸出,情理之中;第一次遍歷后刪除掉了一個元素,第二次遍歷輸出4 6,符合我們的預期。

再來看下thread1的輸出結果,有意思的事情來了,thread1 仍然輸出了4 5 6,什么鬼?thread1和thread2都是遍歷list,list在thread1遍歷第二個元素的時候就已經刪除了一個元素了,為啥還能輸出5?

為了了解這個問題,需要了解CopyOnWriteArrayList是如何做到一邊遍歷的同時還能一邊修改並且還不拋異常的。

在這里不想再深入分析CopyOnWriteArrayList代碼,后續會專門出一篇博客來解釋這個類的源碼的。

這里說一下CopyOnWriteArrayList的解決思路,其實很簡單:

1 private transient volatile Object[] array;

CopyOnWriteArrayList本質上是對array數組的一個封裝,一旦CopyOnWriteArrayList對象發生任何的修改都會new一個新的Object[]數組newElement,在newElement數組上執行修改操作,修改完成后將newElement賦值給array數組(array=newElement)。

因為array是volatile的,因此它的修改對所有線程都可見。

了解了CopyOnWriteArrayList的實現思路之后,我們再來分析上面代碼test6為什么會出現那樣的輸出結果。先來看下thread1和thread2中用到的兩種遍歷方式的源碼:

 1     public void forEach(Consumer<? super E> action) {
 2         if (action == null) throw new NullPointerException();
 3         // 在遍歷開始前獲取當前數組
 4         Object[] elements = getArray();
 5         int len = elements.length;
 6         for (int i = 0; i < len; ++i) {
 7             @SuppressWarnings("unchecked") E e = (E) elements[i];
 8             action.accept(e);
 9         }
10     }

 

 1 public ListIterator<E> listIterator() {
 2         return new COWIterator<E>(getArray(), 0);
 3     }
 4     static final class COWIterator<E> implements ListIterator<E> {
 5         /** Snapshot of the array */
 6         private final Object[] snapshot;
 7         /** Index of element to be returned by subsequent call to next.  */
 8         private int cursor;
 9 
10         private COWIterator(Object[] elements, int initialCursor) {
11             cursor = initialCursor;
12             // 初始化為當前數組
13             snapshot = elements;
14         }
15 
16         public void remove() {
17             // 已經不支持Iterator remove操作了!!
18             throw new UnsupportedOperationException();
19         }
20 
21         public boolean hasNext() {
22             return cursor < snapshot.length;
23         }
24 
25         @SuppressWarnings("unchecked")
26         public E next() {
27             if (! hasNext())
28                 throw new NoSuchElementException();
29             return (E) snapshot[cursor++];
30         }
31 
32         // 此處省略其他無關代碼
33     }

這兩種遍歷方式有個共同的特點:都在初始化的時候將當前數組保存下來了,之后的遍歷都將會遍歷這個數組,而不管array如何變化。

時間點 CopyOnWriteArrayList的array thread1 iterator 初始化的Object數組 thread2 第一次遍歷forEach初始化的Object數組 thread2 第二次遍歷forEach初始化的Object數組
thread start 假設為A A A /
thread2 調用remove方法之后 假設為B A A B

 

 

 

有了這個時間節點表就很清楚了,thread1和thread2 start的時候都會將A數組初始化給自己的臨時變量,之后遍歷的也都是這個A數組,而不管CopyOnWriteArrayList中的array發生了什么變化。因此也就解釋了thread1在thread2 remove掉一個元素之后為什么還會輸出5了。在thread2中,第二次遍歷初始化數組變成了當前的array,也就是修改后的B,因此不會有Integer.valueOf(5)這個元素了。

從test6執行結果來看,CopyOnWriteArrayList確實能解決一邊遍歷一邊修改並且還不會拋異常,但是這也是有代價的:

(1) thread2對array數組的修改thread1並不能被動感知到,只能通過hashCode()方法去主動感知,否則就會一直使用修改前的數據

(2) 每次修改都需要重新new一個數組,並且將array數組數據拷貝到new出來的數組中,效率會大幅下降

此外CopyOnWriteArrayList中的ListIterator實現是不支持remove、add和set操作的,一旦調用就會拋出UnsupportedOperationException異常,因此test6注釋代碼34-41行中如果運行是會拋異常的。

參考文獻: 

http://lz12366.iteye.com/blog/675016 

http://www.cnblogs.com/dolphin0520/p/3933551.html

http://blog.csdn.net/androiddevelop/article/details/21509345 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2024 CODEPRJ.COM