1. 程式人生 > 其它 >併發修改異常探究

併發修改異常探究

ConcurrentModificationException

併發修改異常

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