Java foreach 中List移除元素丟擲ConcurrentM

tony0087發表於2021-09-09

本文重點探討 foreach 迴圈中List 移除元素造成 java.util.ConcurrentModificationException 異常的原因。


先看《阿里巴巴 Java開發手冊》中的相關規定:

圖片描述


那麼思考幾個問題:

  • 反例的執行結果怎樣?

  • 造成這種現象的根本原因是什麼?

  • 有沒有更優雅地的移除元素姿勢?

本文將為你深度解讀該問題。

2.0 反例原始碼


public class ListExceptionDemo {    
   public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");        
        for (String item : list) {            
        if ("1".equals(item)) {
                list.remove(item);
            }
        }
    }
}


2.1 反例的執行結果

當 if 的判斷條件是 “1”.equals(item) 時,程式沒有丟擲任何異常。

 if ("1".equals(item)) {
        list.remove(item);
 }


而當判斷條件是 :"2".equals(item)時,執行會報 java.util.ConcurrentModificationException。


2.2 原因分析

2.2.1 錯誤提示

既然報錯,那麼好辦,直接看錯誤提示唄。

Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
    at java.util.ArrayList$Itr.next(ArrayList.java:859)
    at com.chujianyun.common.collection.list.ListExceptionDemo.main(ListExceptionDemo.java:13)

啥 ConcurrentModificationException? 併發修改異常? 一個執行緒哪來的併發呢?

對應的時序圖

圖片描述

然後我們透過錯誤提示看原始碼:我們看到錯誤的原因是執行 ArrayList的 Itr.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];
 }


modCount 和 expectedModCount不一致導致的:

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


因此可以推測出發生異常的根本原因在於:取下一個元素時,檢查 modCount,發現不一致


2.2.2 程式碼除錯法

為了驗證上面的推測,大家可以在上述兩個關鍵函式上打斷點,透過單步瞭解程式的執行步驟。

我們透過除錯可以“觀察到”,ArrayList中的 foreach 迴圈的語法糖最終迭代器Array$Itr 實現的。

透過斷點我們發現,ArrayList 構造內部類 Itr 物件時 expectedModCount 的值為 ArrayList的 modCount。 

執行 next 函式時會檢查List 中的 modCount 的值 和 構造迭代器時“備份的” expectedModCount 是否相等。

圖片描述

透過除錯我們還發現:雖然原始 list 至於兩個元素,for each 迴圈執行兩次後,滿足if 條件移除 值為“2”的元素之後, foreach 迴圈依然可以進入,此時會再次透過 next 取出 list中的元素,又會執行  checkForComodification函式檢查上述兩個值是否相等,此時不等,丟擲異常。

圖片描述


那麼這裡有存在兩個問題:

  1. 為什麼 List 為 2  , next 卻執行了 3 次呢?

  2. 如果不透過除錯我們怎麼知道 foreach 語法糖的底層如何實現的呢?

帶著這兩個問題,我們繼續深入研究下去。


2.2.3  原始碼解析

我們檢視  ArrayList$Itr 的 hasNext 函式:

 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;

        Itr(){}    
            
        public boolean hasNext() {            
          return cursor != size;
        }
       // 其他省略
}


發現ArrayList的迭代器判斷是否有下一個元素的標準是將下一個待返回的元素的索引和 size 比,不等表示還有下一個元素。

我們重新看原始碼:

 public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");        
        
        for (String item : list) {            
           if ("2".equals(item)) {
                list.remove(item);
           }
        }
    }


最初 List 中有兩個元素,expectedModCount  值為2。

遍歷第一個時沒有走到if, 遍歷第二個元素時走到if ,透過 List.remove 函式移除了元素。

   public boolean remove(Object o) {        
   if (o == null) {            
   for (int index = 0; index < size; index++)                
      if (elementData[index] == null) {
            fastRemove(index);                   
            return true;
        }
        } else {            
        for (int index = 0; index < size; index++)                
             if (o.equals(elementData[index])) {
                    fastRemove(index);                    
                    return true;
                }
        }        
        return false;
    }


而remove會呼叫 fastRemove 函式實際移除掉元素,在此函式中會將 modCount+1,即 modCount的值為3。

 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
    }


因此在次進入foreach 時,expectedModCount  值 和 modCount的值 不相等,因此認為還有下一個元素。

但是呼叫迭代器的 next 函式時需檢查兩者是相等,發現不等,丟擲ConcurrentModificationException異常。


當 if條件是  “1”.equals(item)時

 public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");        
        for (String item : list) {            
        if ("1".equals(item)) {
                list.remove(item);
            }
        }
    }


迴圈取出第一個元素後直接透過list給移除掉了,再次進入 foreach迴圈時,透過 hashNext 判斷是否有下一個元素時,由於 遊標==1(此時list的 size),因此判斷沒下一個元素。

也就是說此時迴圈只執行了一次就結束了,沒有走到可以丟擲ConcurrentModificationException異常的任何函式中,從而沒有任何錯誤。

讀到這裡對迭代器的理解是不是又深了一層呢?

看到這裡可能還有些同學對 foreach 究竟底層怎麼實現的仍然一知半解,那麼請看下一部分。


2.2.4 反彙編

話不多說,直接反彙編:

public class com.chujianyun.common.collection.list.ListExceptionDemo {
  public com.chujianyun.common.collection.list.ListExceptionDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
       7: astore_1
       8: aload_1
       9: ldc           #4                  // String 1
      11: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      16: pop
      17: aload_1
      18: ldc           #6                  // String 2
      20: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      25: pop
      26: aload_1
      27: invokeinterface #7,  1            // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
      32: astore_2
      33: aload_2
      34: invokeinterface #8,  1            // InterfaceMethod java/util/Iterator.hasNext:()Z
      39: ifeq          72
      42: aload_2
      43: invokeinterface #9,  1            // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
      48: checkcast     #10                 // class java/lang/String
      51: astore_3
      52: ldc           #6                  // String 2
      54: aload_3
      55: invokevirtual #11                 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
      58: ifeq          69
      61: aload_1
      62: aload_3
      63: invokeinterface #12,  2           // InterfaceMethod java/util/List.remove:(Ljava/lang/Object;)Z
      68: pop
      69: goto          33
      72: return
}


程式碼偏移從 0 到 25 行實現下面這部分功能:

     List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");


從 26行開始我們發現底層使用迭代器實現,我們腦補後翻譯回 Java程式碼大致如下:

 public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");

        Iterator<String> iterator = list.iterator();        
        while (iterator.hasNext()) {
            String item = iterator.next();            
            if ("2".equals(item)) {                
               //iterator.remove();
                list.remove(item);
            }
        }
    }


大家執行“翻譯”後的程式碼發信啊和原始程式碼的報錯內容完全一致:

Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
    at java.util.ArrayList$Itr.next(ArrayList.java:859)
    at com.chujianyun.common.collection.list.ListException.main(ListException.java:16)


2.2.5 繼續深挖

1、為啥透過 iterator.remove() 移除元素就沒事呢?

我們看 java.util.ArrayList.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();
     }
}


從這裡我們看到,透過迭代器移除元素後, expectedModCount 會重新賦值為 modCount。

因此使用iterator.remove() 移除元素不報錯的原因就找到了。

2、有沒有比手冊給出的程式碼更優雅的寫法?

我們開啟其函式列表,觀察List 和其父類有沒有便捷地移除元素方式:



“驚奇”地發現,Collection 介面提供了 removeIf 函式可以滿足此需求。

還等啥呢,替換下,發現程式碼如此簡潔:

 public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");        // 一行程式碼實現
        list.removeIf("2"::equals);
    }


自此是不是文章就該結束了呢? 

NO..  

removeIf 為啥能夠實現移除元素的功能呢?

我們猜測,底層應該是遍歷然後對比元素然後移除,可能也是迭代器方式,我們看原始碼:

java.util.Collection#removeIf

default boolean removeIf(Predicate<? super E> filter) {
        Objects.requireNonNull(filter);        
        boolean removed = false;        
        final Iterator<E> each = iterator();        
        while (each.hasNext()) {            
        if (filter.test(each.next())) {
                each.remove();
                removed = true;
            }
        }        
        return removed;
    }


我們發現和我們想的比較一致。


本小節對中 foreach 迴圈 List 移除元素導致併發修改異常的問題,進行了全面深入地剖析。

希望可以幫助大家,徹底搞懂這個問題。

另外也提供了研究類似問題的一般思路,即程式碼除錯、讀原始碼、反彙編等。

透過這個問題,希望大家遇到問題時,能夠養成深挖的精神,透過問題帶動知識的理解,知其所以然。

最後提醒大家,不要看書記結論,容易忘,記住不會用,要多思考原因,才能理解更深刻。

“盡信書不如無書”,不要認為作者寫的都是對的,都是最好的,要有自己的思考。


想了解更多詳解的更多內容,想學習更多開發和避坑技巧等,請關注。




來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/1747/viewspace-2824244/,如需轉載,請註明出處,否則將追究法律責任。

相關文章