走進 JDK 之 ArrayList(二)

秉心說發表於2019-04-28

上篇文章 走進 JDK 之 ArrayList(一) 簡單分析了 ArrayList 的原始碼,文末留下了一個問題,modCount 是幹啥用的?下面我們通過一個小例子來引出今天的內容。

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

    for (String s:list){
        if (s.equals("dart"))
            list.remove(s);
    }
}
複製程式碼

大多數人應該都這麼幹過,然後得到一個鮮紅的 ConcurrentModificationException,具體錯誤堆疊資訊如下:

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 collection.ArrayListTest.main(ArrayListTest.java:15)
複製程式碼

報錯位置是 ArrayList 的內部類 Itr 中的 checkForComodification() 方法。至於如何呼叫到這個方法的,我們首先得知道上面的程式碼中發生了什麼。看位元組碼的話太麻煩了又不容易理解,推薦一個反編譯神器 jad,javac 編譯得到 class 檔案之後執行如下命令:

./jad ArrayListTest.class
複製程式碼

得到 ArrayListTest.jad 檔案,直接用文字編輯器開啟即可:

public class ArrayListTest {

    public ArrayListTest() { }

    public static void main(String args[]) {
        ArrayList arraylist = new ArrayList();
        arraylist.add("java");
        arraylist.add("kotlin");
        arraylist.add("dart");
        Iterator iterator = arraylist.iterator(); // 1
        do {
            if(!iterator.hasNext()) // 2
                break;
            String s = (String)iterator.next(); // 3
            if(s.equals("dart"))
                arraylist.remove(s);
        } while(true);
    }
}
複製程式碼

從反編譯得到的程式碼我們可以發現,增強型 for 迴圈只是一個語法糖而已,編譯器幫我們進行了處理,其實是呼叫了迭代器來進行迴圈。著重看一下上面標註的三句程式碼,是整個迭代過程的核心。

第一句,獲取 ArrayList 的迭代器。

public Iterator<E> iterator() {
    return new Itr();
}
複製程式碼

AbstractList 中定義了一個迭代器 Itr,但是它的子類 ArrayList 並沒有直接使用父類的迭代器,而是自己定義了一個優化版本的 Itr。迴圈體中第二句程式碼首先會判斷是否 hasNext(),存在的話呼叫 next 獲取元素,不存在的話跳出迴圈。增強型 for 迴圈的基本實現就是這樣的。hasNext()next() 方法原始碼如下:

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;
    }

    @SuppressWarnings("unchecked")
    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];
    }

    ......
    // 省略其他程式碼
    }
複製程式碼

cursur 表示當前遊標位置,hasNext() 方法就是根據 cursor 是否等於集合大小 size 判斷是否還有下一個元素。成員變數中有個 expectedModCount,定義如下:

int expectedModCount = modCount;
複製程式碼

終於發現了 modCount 的蹤影,它被賦值給了 expectedModCount 變數,字面意思就是 期望的修改次數。具體它有什麼用,接著看 next() 方法中的第一行程式碼,呼叫了 checkForComodification() 方法,這是用來做併發檢測的:

final void checkForComodification() {
    if (modCount != expectedModCount) // 在迭代的過程中 modCount 發生了改變
        throw new ConcurrentModificationException();
}
複製程式碼

異常就是這樣丟擲來的,modCountexpectedModCount 不相等,即實際的修改次數與期望的修改次數不相等。expectedModCount 是在迭代器初始化的過程中賦值的,其值等於 modCount。在迭代過程中又不相等了,那就只可能是在迭代過程中修改了集合,造成了 modCount 變化。那麼,哪些操作會導致 modCount 發生變化呢?JDK 原始碼註釋中做了以下說明(modCount 在 AbstractList 中宣告):

The number of times this list has been structurally modified. Structural modifications are those that change the size of the list, or otherwise perturb it in such a fashion that iterations in progress may yield incorrect results.

集合的結構修改次數。結構修改指的是集合大小的變化。所以只要是涉及到增加或者刪除元素的方法,都要改變 modCount。以 ArrayList 的 remove() 方法為例:

public E remove(int index) {
    rangeCheck(index); // 邊界檢測

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0) // 移動 index 之後的所有元素
        System.arraycopy(elementData, index+1, elementData, index,
                        numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}
複製程式碼

通過 modCount++ 使其自增 1。

由於 ArrayList 並不是執行緒安全的,一邊迭代一邊改變集合,的確可能導致多執行緒下程式碼表現不一致。可能有人會有這樣的疑問,文章開頭的測試程式碼並沒有涉及到併發操作啊,為什麼還是丟擲了異常?這就是集合的 fail-fast(快速失敗) 機制。

fail-fast 錯誤機制並不保證錯誤一定會發生,但是當錯誤發生的時候一定可以丟擲異常。它不管你是不是真的併發操作,只要可能是併發操作,就給你提前丟擲異常。針對非執行緒安全的集合類,這是一種健壯的處理方式。但是你如果真的想在單執行緒中這樣操作應該怎麼辦?沒關係,讓 modCountexpectedModCount 相等就完事了,ArrayList 的迭代器為我們提供了這樣的 add()remove() 方法:

public void add(E e) {
    checkForComodification();

    try {
        int i = cursor;
        ArrayList.this.add(i, e); // add 之後要修改 modCount
        cursor = i + 1;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
        ArrayList.this.remove(lastRet); // remove 之後要修改 modCount
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}
複製程式碼

上面的程式碼實現在修改了集合結構之後都會給 expectedModCount 重新賦值,使其與 modCount 相等。修改一下文章開頭的測試程式碼:

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

//  for (String s:list){
//      if (s.equals("dart"))
//          list.remove(s);
//  }

    Iterator<String> iterator=list.iterator();
    while (iterator.hasNext()){
        String s= iterator.next();
        if (s.equals("dart"))
            iterator.remove();
    }
}
複製程式碼

這樣就不會再報錯了。

最後最後再給你出一道題,仔細看一下:

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

    for (String s : list) {
        if (s.equals("kotlin"))
            list.remove(s);
    }
}
複製程式碼

如果沒看出來和文章開頭那道題的區別,那就再翻上去仔細觀察一下。之前我們要刪的是 dart,集合中的最後一個元素。現在要刪的是 kotlin,集合中的第二個元素。執行結果會怎麼樣?你要是精通腦筋急轉彎的話,肯定能給出正確答案。沒錯,這次成功刪除了元素並且沒有任何異常。這是為什麼呢?刪除 dart 就報異常,刪除 kotlin 就沒問題,這是歧視 dart 嗎。再把迭代器的程式碼掏出來:

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

@SuppressWarnings("unchecked")
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];
}
複製程式碼

集合中新增了 3 個元素,所以初始化迭代器之後,expectedModCount = modCount = 3cursor 此時為 0 。先來分析文章開頭的程式碼,刪除集合中最後一個元素的情況:

  • 執行完第一次迴圈,cursor 為 1,未產生刪除操作,modCount 為 3,expectedModCount 為 3,size 為 3。cursor != sizehasNext() 判斷還有元素。
  • 執行完第二次迴圈,cursor 為 2,仍未產生刪除操作,modCount 為 3,expectedModCount 為 3,size 為 3。cursor != sizehasNext() 判斷還有元素。
  • 執行完第三次迴圈,cursor 為 3,由於產生刪除了操作,modCount 為 4,expectedModCount 仍為 3,size 變為 2。cursor != sizehasNext() 判斷還有元素,繼續迭代,其實已經沒有元素了。
  • 繼續迭代,呼叫 next() 方法,此時 expectedModCount != modCount,直接丟擲異常。
迴圈次數 cursor modCount expectedModCount size
1 1 3 3 3
2 2 3 3 3
3 3 4 3 2

再來看看刪除 kotlin 的執行流程:

  • 執行完第一次迴圈,cursor 為 1,未產生刪除操作,modCount 為 3,expectedModCount 為 3,size 為 3。cursor != sizehasNext() 判斷還有元素。
  • 執行完第二次迴圈,cursor 為 2,產生刪除操作,modCount 為 4,expectedModCount 為 3,size 為 2。cursor == sizehasNext() 判斷沒有元素了,不再呼叫 next() 方法。

並不是 fail-fast 失效了,僅僅只是恰好 cursor == sizehasNext() 方法誤以為集合中已經沒有元素了,其實還有一個元素。迴圈兩次之後就終止迴圈了,不再呼叫 next() 方法,也就不存在併發檢測了。

迴圈次數 cursor modCount expectedModCount size
1 1 3 3 3
2 2 3 3 2

本文由一個 ConcurrentModificationException 的例子,順藤摸瓜,解析了 ArrayList 迭代器的原始碼,同時說明了 Java 集合框架的 fail-fast 機制。最後也驗證了增強型 for 迴圈中刪除元素並不是百分之百會觸發 fail-fast

ArrayList 就說到這裡了,下一篇來看看 List 中同樣重要的 LinkedList

文章首發微信公眾號: 秉心說 , 專注 Java 、 Android 原創知識分享,LeetCode 題解。

更多 JDK 原始碼解析,掃碼關注我吧!

走進 JDK 之 ArrayList(二)

相關文章