基於原始碼去理解Iterator迭代器的Fail-Fast與Fail-Safe機制

朱季謙發表於2023-12-28

image

原創/朱季謙

在Java程式設計當中,Iterator迭代器是一種用於遍歷如List、Set、Map等集合的工具。這類集合部分存線上程安全的問題,例如ArrayList,若在多執行緒環境下,迭代遍歷過程中存在其他執行緒對這類集合進行修改的話,就可能導致不一致或者修改異常問題,因此,針對這種情況,迭代器提供了兩種處理策略:Fail-Fast(快速失敗)和Fail-Safe(安全失敗)。

先簡單介紹下這兩種策略——

1. Fail-Fast(快速失敗)機制
快速失敗機制是指集合在迭代遍歷過程中,其他多執行緒或者當前執行緒對該集合進行增加或者刪除元素等操作,當前執行緒迭代器讀取集合時會立馬丟擲一個ConcurrentModificationException異常,避免資料不一致。實現原理是迭代器在建立時,會獲取集合的計數變數當作一個標記,迭代過程中,若發現該標記大小與計數變數不一致了,就以為集合做了新增或者刪除等操作,就會丟擲快速失敗的異常。在ArrayList預設啟用該機制。

2. Fail-Safe(安全失敗)機制
安全失敗機制是指集合在迭代遍歷過程中,若其他多執行緒或者當前執行緒對該集合進行修改(增加、刪除等元素)操作,當前執行緒迭代器仍然可以正常繼續讀取集合遍歷,而不會丟擲異常。該機制的實現,是透過迭代器在建立時,對集合進行了快照操作,即迭代器遍歷的是原集合的陣列快照副本,若在這個過程,集合進行修改操作,會將原有的陣列內容複製到新陣列上,並在新陣列上進行修改,修改完成後,再將集合陣列的引用指向新陣列,,而讀取操作仍然是訪問舊的快照副本,故而實現讀寫分離,保證讀取操作的執行緒安全性。在CopyOnWriteArrayList預設啟用該機制。

基於這兩個策略,分別寫一個案例來說明。

一、迭代器的Fail-Fast(快速失敗)機制原理

Fail-Fast(快速失敗)機制案例,用集合ArrayList來說明,這裡用一個執行緒就能模擬出該機制——

  public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        list.add("張三");
        list.add("李四");
        list.add("王五");
        Iterator iterator = list.iterator();
        while(iterator.hasNext()) {
            //第一次遍歷到這裡,能正常列印,第二次遍歷到這裡,因上一次遍歷做了list.add("李華")操作,集合已經改變,故而出現Fail-Fast(快速失敗)異常
            String item = (String)iterator.next();
            list.add("李華");
            System.out.println(item);
        }
        System.out.println(list);
    }

執行這段程式碼,列印日誌出現異常ConcurrentModificationException,說明在遍歷過程當中,操作 list.add("李華")對集合做新增操作後,就會出現Fail-Fast(快速失敗)機制,丟擲異常,阻止繼續進行遍歷——

張三
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 ListExample.IteratorTest.main(IteratorTest.java:23)

這裡面是怎麼實現該Fail-Fast(快速失敗)機制的呢?

先來看案例裡建立迭代器的這行程式碼Iterator iterator = list.iterator(),底層是這樣的——

 public Iterator<E> iterator() {
        return new Itr();
    }

Itr類是ArrayList內部類,實現了Iterator 介面,說明它本質是ArrayList內部一個迭代器。這裡省略部分暫時無關緊要的程式碼,只需關注hasNext()和next()即可——

  private class Itr implements Iterator<E> {
        int cursor;       // 迭代計數器
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;
        //判斷是否已經迭代到最後一位
        public boolean hasNext() {
            return cursor != size;
        }
			
    		//取出當前遍歷到集合元素
			  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];
        }
    		......
        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
}

再進入案例裡的這行程式碼 String item = (String)iterator.next()底層,也就是Itr類的public E next() {......}方法。

注意next()裡的這個方法 checkForComodification(),進入到方法裡,可以看到,ConcurrentModificationException異常正是在這個方法裡丟擲來的,它做了一個判斷,判斷modCount是否等於expectedModCount,若不等於,就丟擲快速失敗異常。

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

那麼,問題就簡單了,研究ArrayList快速失敗機制,本質只需要看modCount和expectedModCount是什麼,就知道ArrayList的Fail-Fast(快速失敗)機制是怎麼處理的了。

在內部類Itr中,定義int expectedModCount = modCount,說明expectedModCount是在迭代器new Itr()建立時,就將此時的modCount數值賦值給變數expectedModCount,意味著,在整個迭代器生命週期內,這個expectedModCount是固定的了,從變數名就可以看出,它表示集合預期修改的次數,而modCount應該就是表示列表修改次數。假如迭代器建立時,modCount修改次數是5,那麼整個迭代器生命週期內,預期的修改次數expectedModCount就只能等於5。

請注意最為關鍵的一個地方,modCount是可以變的。

先看一下在ArrayList裡,這個modCount是什麼?

這個modCount是定義在ArrayList的父類AbstractList裡的——

/**
 *這個列表在結構上被修改的次數。結構修改是指改變列表,或者以其他方式擾亂它,使其迭代進步可能產生不正確的結果。
 *
 *該欄位由迭代器和列表迭代器實現使用,由{@code迭代器}和{@code listtiterator}方法返回。
 *如果該欄位的值發生了意外變化,迭代器(或列表)將返回該欄位迭代器)將丟擲{@code ConcurrentModificationException} 
 *在響應{@code next}, {@code remove}, {@code previous},{@code set}或{@code add}操作。這提供了快速故障行為。
 *
 */
protected transient int modCount = 0;

根據註釋,可以得知,這是一個專門記錄列表被修改的次數,在ArrayList當中,涉及到add新增、remove刪除、fastRemove、clear等涉及列表結構改動的操作,,都會透過modCount++形式,增加列表在結構上被修改的次數。

modCount表示列表被修改的次數。

我們在案例程式碼裡,做了add操作——

while(iterator.hasNext()) {
    String item = (String)iterator.next();
    list.add("李華");
    System.out.println(item);
}

進入到ArrayList的add方法原始碼裡,可以看到,在add新增過程中,按照ensureCapacityInternal =》ensureExplicitCapacity執行順序,最後透過modCount++修改了變數modCount——

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}


 private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
 }

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;

// overflow-conscious code
	if (minCapacity - elementData.length > 0)
    grow(minCapacity);
}

總結一下,迭代器建立時,變數expectedModCount是被modCount賦值,在整個迭代器等生命週期中,變數expectedModCount值是固定的了,但在第一輪遍歷過程中,透過list.add("李華")操作,導致modCount++,最終就會出現expectedModCount != modCount。因此,在迭代器進行第二輪遍歷時,執行到 String item = (String)iterator.next(),在next()裡呼叫checkForComodification() 判斷expectedModCount是否還等於modCount,這時已經不等於,故而就會丟擲ConcurrentModificationException異常,立刻結束迭代器遍歷,避免資料不一致。

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

以上,就是集合迭代器的Fail-Fast機制原理。
image


二、迭代器的Fail-Safe(安全失敗)機制原理

Fail-Fast(快速失敗)機制案例,用集合CopyOnWriteArrayList來說明,這裡用一個執行緒就能模擬出該機制——

   public static void main(String[] args) {
        CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
        list.add("張三");
        list.add("李四");
        list.add("王五");
        Iterator iterator = list.iterator();
        while(iterator.hasNext()) {
            String item = (String)iterator.next();
            list.add("李華");
            System.out.println(item);
        }
        System.out.println("最後全部列印集合結果:" + list);
    }

執行這段程式碼,正常列印結果,說明在迭代器遍歷過程中,對集合做了新增元素操作,並不影響迭代器遍歷,新增的元素不會出現在迭代器遍歷當中,但是,在迭代器遍歷完成後,再一次列印集合,可以看到新增的元素已經在集合裡了——

張三
李四
王五
最後全部列印集合結果:[張三, 李四, 王五, 李華, 李華, 李華]

Fail-Safe(安全失敗)機制在CopyOnWriteArrayList體現,可以理解成,這是一種讀寫分離的機制。

下面就看一下CopyOnWriteArrayList是如何實現讀寫分離的。

先來看迭代器的建立Iterator iterator = list.iterator(),進入到list.iterator()底層原始碼——

public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}

這裡的COWIterator是一個迭代器,關鍵有一個地方,在建立迭代器物件,呼叫其構造器時傳入兩個引數,分別是getArray()和0。

這裡的getArray()方法,獲取到一個array陣列,它是CopyOnWriteArrayList集合真正儲存資料的地方。

final Object[] getArray() {
    return array;
}

另一個引數0,表示迭代器遍歷的索引值,剛開始,肯定是從陣列下標0開始。

明白getArray()和0這兩個引數後,看一下迭代器建立new COWIterator(getArray(), 0)的情況,只需關注與本文有關的程式碼即可,其他暫時省略——

static final class COWIterator<E> implements ListIterator<E> {
    //列表快照
    private final Object[] snapshot;
    //呼叫next返回的元素的索引
    private int cursor;

    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }

    public boolean hasNext() {
        return cursor < snapshot.length;
    }

    ......

    @SuppressWarnings("unchecked")
    public E next() {
        if (! hasNext())
            throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }
}

在程式碼案例中,迭代器遍歷過程時,透過hasNext()判斷集合是否遍歷完成,若還有沒遍歷的元素,就會呼叫 String item = (String)iterator.next()取出集合對應索引的元素。

從COWIterator類的next()方法中,可以看到,其元素是根據索引cursor從陣列snapshot中取出來的。

這個snapshot就相當一個快照副本,在建立迭代器時,即new COWIterator(getArray(), 0),透過getArray()將此時CopyOnWriteArrayList集合的array陣列引用複製給COWIterator的陣列snapshot,那麼snapshot引用和array引用都將指向同一個陣列地址了。

只需保證snapshot指向的陣列地址元素不變,那麼整個迭代器讀取集合陣列就不會受影響。
image

如何做到snapshot指向的陣列地址元素不變,但是又需要同時能滿足CopyOnWriteArrayList集合的新增或者刪除操作呢?

先來看一下CopyOnWriteArrayList的 list.add("李華")操作,具體實現能夠在這塊原始碼裡看到,主要以下步驟:

1、add方法用到了ReentrantLock鎖,在進行新增過程中,透過lock鎖保證執行緒安全。

2、Object[] elements = getArray()這裡的getArray()方法,和建立迭代器傳的引數getArray()是同一個,都是獲取到CopyOnWriteArrayList的array陣列。取出array陣列以及計算其長度後,建立一個比array陣列長度大1的新陣列,透過Arrays.copyOf(elements, len + 1)將array陣列元素全部複製到新陣列newElements。

3、在新陣列newElements進行新增元素操作。

4、將CopyOnWriteArrayList的array陣列引用指向新陣列newElements,這樣array=newElements,完成新增操作。

  public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
          	//獲取到CopyOnWriteArrayList的array陣列
            Object[] elements = getArray();
          	//獲取array陣列長度
            int len = elements.length;
            //將array陣列資料,全部複製到一個長度比舊陣列多1的新陣列裡
            Object[] newElements = Arrays.copyOf(elements, len + 1);
          	//在新陣列裡,新增一個元素
            newElements[len] = e;
          	//將CopyOnWriteArrayList的array陣列引用指向新陣列newElements
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

可見,CopyOnWriteArrayList實現讀寫分離的原理,就是在COWIterator迭代器建立時,將此時的array陣列指向的地址複製給snapshot,相當做了一次快照,迭代器遍歷該快照陣列地址元素。

後續涉及到列表修改相關的操作,會將原始array陣列全部元素複製到一個新陣列上,在新陣列裡面進行修改操作,這樣就不會影響到迭代器遍歷原來的陣列地址裡的資料了。(這也表明,這種讀寫分離只適合讀多寫少,在寫多情況下,會出現效能問題)

新陣列修改完畢後,只需將array陣列引用指向新陣列地址,就能完成修改操作了。
image

整個過程就能完成讀寫分離機制,即迭代器的Fail-Safe(安全失敗)機制。

相關文章