併發修改異常探究
併發修改異常
1 什麼時候會發生異常
併發就是同一時刻發生,併發修改的意思就是同一時刻發生並修改。當方法檢測到物件的併發修改,但不允許這種修改時,會丟擲此異常。
最常見的出現兵法修改異常的場景:當我們在對集合進行迭代操作的時候,如果同時對集合物件中的元素進行某些修改操作,就會導致併發修改異常的產生。
對於以下程式碼
- 在一個儲存字串的集合中,如果存在字串"java",則再新增一個"world"
public class Exercise { public static void main(String[] args) { ArrayList<String> list = new ArrayList<>(); list.add("java"); list.add("study"); list.add("hello"); Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String next = iterator.next(); if("java".equals(next)){ list.add("world"); } } } }
控制檯輸出
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
at java.util.ArrayList$Itr.next(ArrayList.java:861)
at com.study.chapter14.exceptionStudy.Exercise.main(Exercise.java:14)
- ConcurrentModificationException就是併發修改異常
2 異常產生原因
2.1 根據原始碼尋找線索
- 通過控制檯資訊追尋原始碼
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
異常出現的位置在ArrayList類內部類Itr中的checkForComodification方法
- 此處原始碼
final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); }
- 當一個名為modCount的變數值不等於 expectedModCount的變數值時,異常會被丟擲
2.2 探究這兩個變數代表什麼
- modCount
modCount是定義在AbstractList抽象類中public修飾的成員變數,ArrayList是此類的子類,從AbstractList那裡繼承到了modCount這個變數
原始碼對modCount的解釋為:這個變數其實就代表了集合在結構上被修改的次數
- expectedModCount
expectedModCount是內部類Itr中的成員變數,當ArrayList物件呼叫iterator方法時,會建立內部類Itr的物件,並給其成員變數expectedModCount賦值為ArrayList物件成員變數的值modCount
原始碼為
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;
- 深究modCount改變原因
當我們建立ArrayList物件的時候,ArrayList物件包含了此變數modCount並且初始化為0
通過原始碼可知,ArrayList中能改變modCount的方法都是新增元素的相關功能和刪除元素的相關功能
每刪除一個元素,modCount的值會自增一次
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
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
return oldValue;
}
我們每次進行對集合中元素個數變化的操作時,modCount的值就會+1。也就是說,增刪會修改modCount值,改查不會影響modCount
modCount就記錄了對集合元素個數的改變次數
2.3 分析迭代器為何會丟擲異常
2.3.1 迭代器的建立
當ArrayList物件呼叫iterator方法時,會建立內部類Itr的物件,此時迭代器物件中有兩個最關鍵的成員變數:cursor、expectedModCount
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;
探究這兩個變數的作用
- cursor
迭代器的工作就是將集合中的元素逐個取出,而cursor就是迭代器中用於指向集合中某個元素的指標
在迭代器迭代的過程中,cursor初始值為0,每次取出一個元素,cursor的值會+1,以便下一次能指向下一個元素,知道cursor值等於集合的長度為止,從而達到取出所有元素的效果
- expectedModCount
expectedModCount在迭代器物件建立時被賦值為modCount
當迭代器建立完成之後,如果我們沒有對集合進行增刪操作,expectedModCount的值是會等於modCount的值的
在迭代集合元素的過程中,迭代器通過檢查expectedModCount和modCount的值是否相同,以防出現併發修改
2.3.2 迭代器迭代過程原始碼解析
-
在我們使用迭代器的時候,一般會呼叫迭代器的hasNext方法判斷是否還有下一個元素。原始碼為
public boolean hasNext() { return cursor != size; }
- cursor初始值為0,預設指向集合中第一個元素,每次取出一個元素,cursor會自增一次
- size是集合中的成員變數,用於表示集合的元素個數
- 集合的最後一個元素的索引為size-1,只要cursor的值不等於size,就證明存在下一個元素,將其返回。如果cursor等於size,說明迭代完最後一個元素,沒有下一個元素了。
-
當我們通過迭代器的hasNext方法返回true,確定集合還有元素時,通常我們會通過迭代器的另一個方法next取出次元素。原始碼為
public E next() { checkForComodification(); int i = cursor; if (i >= size) throw new NoSuchElementException(); Object[] elementData = ArrayList.this.elementData; if (i >= elementData.length) throw new ConcurrentModificationException(); cursor = i + 1; return (E) elementData[lastRet = i]; }
- next方法的第一行就是呼叫checkForComodification方法,產生併發修改異常的地方。
- 迭代器每一次取出元素前都會檢查集合中的modCount和最初賦值給迭代器的expectedModCount是否相等,如果不等,說明產生了增刪操作,modCount的值被改變了。丟擲併發修改異常
- 如果沒有異常產生,next方法最後一行會返回cursor指向的元素
3 併發修改的意義及異常解決方案
3.1 這個異常對程式有什麼意義
- 迭代器是通過cursor指標指向對應集合元素來挨個獲取集合中元素的,每次獲取對應元素後cursor值+1,指向下一個元素,直到集合最後一個元素。
- 如果在迭代器獲取元素的過程中,集合中元素的個數突然改變,那麼下一次獲取元素時,cursor能否正常的指向集合的下一個元素就變得未知了,這種不確定性有可能導致迭代器工作出現意想不到的問題
- 為了防止在將來某個時間任意發生不確定行為的風險,我們在使用迭代器的過程中不允許修改集合元素的結構(即不允許修改元素個數),否則迭代器會丟擲異常結束程式
3.2 如果遇到需要在遍歷集合的同時修改集合結構的需求該如何處理
3.2.1 迭代器實現增刪
在迭代器迭代的過程中,我們雖然不能通過集合直接增刪元素,但是其實迭代器中是有這樣的方法可以實現增刪的
-
通過ArrayList中iterator方法返回的Itr迭代器物件包含有一個remove方法
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(); } }
-
通過Itr迭代器的子類物件ListItr中有新增元素的add方法
public void add(E e) { checkForComodification(); try { int i = cursor; ArrayList.this.add(i, e); cursor = i + 1; lastRet = -1; expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } }
以上兩個方法在增刪完元素後都對指標cursor進行了相應的處理,避免了出現迭代器獲取元素的不確定行為
3.2.2 更換遍歷集合方式
異常是迭代器丟擲的,我們除了可以使用迭代器遍歷集合,還可以使用其他方法
- 屬於List體系的集合我們可以使用普通for迴圈,通過索引獲取集合元素的方法來遍歷集合,這個時候修改集合結構是不會出現異常的
- 不屬於List體系的集合,我們可以通過單列集合頂層介面Collection中定義的toArray方法將集合轉為陣列,這個時候就不需要擔心出現併發修改異常了
4 其他相關問題
4.1 增強for迴圈和迭代器
foreach迴圈也就是我們常說的增強for迴圈,其實foreach迴圈的底層是用迭代器實現的
所以我們不能在foreach中對集合結構進行修改,否則可能會出現併發修改異常
4.2 迭代器修改集合結構的特殊情況
當迭代至集合倒數第二個元素的同時,刪除集合元素不會導致併發修改異常
public class Exercise {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
list.add("java");
list.add("study");
list.add("hello");
list.add("world");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String next = iterator.next();
if("hello".equals(next)){
list.remove("java");
}
}
}
}
上面程式碼在迭代到倒數第二個元素"hello"的時候,刪除了"java",但是並沒有出現併發修改異常。如果換成"study",會繼續出現異常
- 原因解釋
- 集合中倒數第二個元素的索引為size - 2,當迭代器取出集合倒數第二個元素的時候cursor指向的位置會向右移動一位,值會變成size - 1。
- 如果此時通過集合去刪除一個元素,集合中元素個數會減一,所以size值會變成size - 1
- 當迭代器試圖去獲取最後一個元素的時候,會先判斷是否還有元素,呼叫hasNext方法,返回cursor != size,但是此時的cursor和此時的size的值都等於刪除之前的size - 1,兩者相等,那麼hasNext方法就會返回false,迭代器就不會再呼叫next方法獲取元素了。
參考文章作者:魔數師
參考文章地址:https://blog.csdn.net/qq_29534705/article/details/80899351