1. 程式人生 > 實用技巧 >Java集合類的fail-fast機制

Java集合類的fail-fast機制

Java集合類的fail-fast機制

什麼是fail-fast機制

我們在JDK中科院經常看到類似這樣的話

例如 ArrayList

注意,迭代器的快速失敗行為無法得到保證,因為一般來說,不可能對是否出現不同步併發修改做出任何硬性保證。快速失敗迭代器會盡最大努力丟擲 ConcurrentModificationException。因此,為提高這類迭代器的正確性而編寫一個依賴於此異常的程式是錯誤的做法:迭代器的快速失敗行為應該僅用於檢測 bug。

上面這段話就很好的給我嗎解釋了什麼叫做快速失敗機制。

舉例來說就是:假如有兩個執行緒A、B。執行緒A通過迭代器在遍歷集合的元素時候,這時B執行緒修改了集合的結構

。這時程式檢測到了執行緒2對集合的修改了集合的結構,那麼這時候程式就會丟擲ConcurrentModificationException異常。

這裡我們可以寫一個簡單的demo:

public class FailFastTest {
    private static List<Integer> list = new ArrayList<>();
    public static void main(String[] args) {
        for(int i = 0 ; i < 10;i++){
            list.add(i);
        }
        //這裡執行緒去遍歷集合
        new threadA(()->{
            Iterator<Integer> iterator = list.iterator();
            while(iterator.hasNext()){
                int i = iterator.next();
                System.out.println("ThreadA 遍歷:" + i);
                //因為集合大小隻有10個,這裡睡眠10毫秒方便測試
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }).start();
        //執行緒B去修改集合的結構,當i=3 時,修改list(remove)
        new threadB(()->{
            int i = 0 ; 
            while(i < 6){
                System.out.println("ThreadTwo run:" + i);
                if(i == 3){
                    list.remove(i);
                }
                i++;
            }
        }).start();
    }
}

OUTPUT:

ThreadA 遍歷:0
ThreadB run:0
ThreadB run:1
ThreadB run:2
ThreadB run:3
ThreadB run:4
ThreadB run:5
Exception in thread "Thread-0" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(Unknown Source)
    at java.util.ArrayList$Itr.next(Unknown Source)
    at test.ArrayListTest$threadOne.run(ArrayListTest.java:23)

那麼出現ConcurrentModificationException原因是什麼。

這裡我們可以看看迭代器底層的部分原始碼

深入分析ConcurrentModificationException產生的原因

//這裡分享一個ArrayList 迭代器的原始碼
private class Itr implements Iterator<E> {
        int cursor;
        int lastRet = -1;
        int expectedModCount = ArrayList.this.modCount;
 
        public boolean hasNext() {
            return (this.cursor != ArrayList.this.size);
        }
 
        public E next() {
            checkForComodification();
            /** 省略此處程式碼 */
        }
 
        public void remove() {
            if (this.lastRet < 0)
                throw new IllegalStateException();
            checkForComodification();
            /** 省略此處程式碼 */
        }
 
        final void checkForComodification() {
            if (ArrayList.this.modCount == this.expectedModCount)
                return;
            throw new ConcurrentModificationException();
        }
    }

我們可以看到當,ArrayList在呼叫next、remove方法時都是呼叫了checkForComodification()這個方法。

這個方法很簡單。就是比較modCount是不是期望的expectedModCount,如果不是這丟出ConcurrentModificationException異常。

所以我們就可以推斷fail-fast機制產生的原因就是modCount != expectedModCount 的時候產生的。所以我們需知道他們的值在什麼時候發生改變

expectedModCount 是在Itr中定義的:int expectedModCount = ArrayList.this.modCount;所以他的值是不可能會修改的,所以會變的就是modCount。modCount是在 AbstractList 中定義的,為全域性變數。

那麼ModCount什麼時候改變了呢?

//ArrayList原始碼
public boolean add(E paramE) {
        ensureCapacityInternal(this.size + 1);
        /** 省略此處程式碼 */
    }
 
    private void ensureCapacityInternal(int paramInt) {
        if (this.elementData == EMPTY_ELEMENTDATA)
            paramInt = Math.max(10, paramInt);
        ensureExplicitCapacity(paramInt);
    }
    //修改了modCount
    private void ensureExplicitCapacity(int paramInt) {
        this.modCount += 1;    //修改modCount
        /** 省略此處程式碼 */
    }
    //修改了modCount
   public boolean remove(Object paramObject) {
        int i;
        if (paramObject == null)
            for (i = 0; i < this.size; ++i) {
                if (this.elementData[i] != null)
                    continue;
                fastRemove(i);
                return true;
            }
        else
            for (i = 0; i < this.size; ++i) {
                if (!(paramObject.equals(this.elementData[i])))
                    continue;
                fastRemove(i);
                return true;
            }
        return false;
    }
 	//修改了modCount
    private void fastRemove(int paramInt) {
        this.modCount += 1;   //修改modCount
        /** 省略此處程式碼 */
    }
 
    public void clear() {
        this.modCount += 1;    //修改modCount
        /** 省略此處程式碼 */
    }

從上面的原始碼我們可以看出,ArrayList中無論add、remove、clear方法只要是涉及了改變ArrayList元素的個數的方法都會導致modCount的改變。所以我們這裡可以初步判斷由於expectedModCount 得值與modCount的改變不同步,導致兩者之間不等從而產生fail-fast機制。知道產生fail-fast產生的根本原因了。

所以這邊我們從例項上去理解expectedModCount 與modCount不一致的過程:

有兩個執行緒(執行緒A,執行緒B),其中執行緒A負責遍歷list、執行緒B修改list。執行緒A在遍歷list過程的某個時候(此時expectedModCount = modCount=N),執行緒啟動,同時執行緒B增加一個元素,這是modCount的值發生改變(modCount + 1 = N + 1)。執行緒A繼續遍歷執行next方法時,通告checkForComodification方法發現expectedModCount = N ,而modCount = N + 1,兩者不等,這時就丟擲ConcurrentModificationException 異常,從而產生fail-fast機制。

那麼什麼是fail-safe機制

瞭解了什麼是fail-fast機制,那麼fail-safe機制就很好了解了。當我們對集合結構上做出改變的時候,fail-fast機制就會丟擲異常。但是,對於採用fail-safe機制來說,就不會丟擲異常(大家估計看到safe兩個字就知道了)。

這是因為,當集合的結構被改變的時候,fail-safe機制會在複製原集合的一份資料出來,然後在複製的那份資料遍歷。=》具體可以看CopyOnWriteArrayList

因此,雖然fail-safe不會丟擲異常,但存在以下缺點:

  1. 複製時需要額外的空間和時間上的開銷。
  2. 不能保證遍歷的是最新內容。

fail-fast的解決辦法

  • 在遍歷過程中所有涉及到改變modCount值得地方全部加上synchronized或者直接使用Collections.synchronizedList,這樣就可以解決。但是不推薦,因為增刪造成的同步鎖可能會阻塞遍歷操作。
  • 使用CopyOnWriteArrayList來替換ArrayList。推薦使用該方案。

CopyOnWriteArrayList

CopyOnWriteArrayList為何物?ArrayList 的一個執行緒安全的變體,其中所有可變操作(add、set 等等)都是通過對底層陣列進行一次新的複製來實現的。 該類產生的開銷比較大,但是在兩種情況下,它非常適合使用。1:在不能或不想進行同步遍歷,但又需要從併發執行緒中排除衝突時。2:當遍歷操作的數量大大超過可變操作的數量時。遇到這兩種情況使用CopyOnWriteArrayList來替代ArrayList再適合不過了。那麼為什麼CopyOnWriterArrayList可以替代ArrayList呢?

第一、CopyOnWriterArrayList的無論是從資料結構、定義都和ArrayList一樣。它和ArrayList一樣,同樣是實現List介面,底層使用陣列實現。在方法上也包含add、remove、clear、iterator等方法。

第二、CopyOnWriterArrayList根本就不會產生ConcurrentModificationException異常,也就是它使用迭代器完全不會產生fail-fast機制。請看:

private static class COWIterator<E> implements ListIterator<E> {
        /** 省略此處程式碼 */
        public E next() {
            if (!(hasNext()))
                throw new NoSuchElementException();
            return this.snapshot[(this.cursor++)];
        }
 
        /** 省略此處程式碼 */
    }

CopyOnWriterArrayList的方法根本就沒有像ArrayList中使用checkForComodification方法來判斷expectedModCount 與 modCount 是否相等。它為什麼會這麼做,憑什麼可以這麼做呢?我們以add方法為例:

public boolean add(E paramE) {
        ReentrantLock localReentrantLock = this.lock;
        localReentrantLock.lock();
        try {
            Object[] arrayOfObject1 = getArray();
            int i = arrayOfObject1.length;
            Object[] arrayOfObject2 = Arrays.copyOf(arrayOfObject1, i + 1);
            arrayOfObject2[i] = paramE;
            setArray(arrayOfObject2);
            int j = 1;
            return j;
        } finally {
            localReentrantLock.unlock();
        }
    }
 
    
    final void setArray(Object[] paramArrayOfObject) {
        this.array = paramArrayOfObject;
    }

​ CopyOnWriterArrayList的add方法與ArrayList的add方法有一個最大的不同點就在於,下面三句程式碼:

Object[] arrayOfObject2 = Arrays.copyOf(arrayOfObject1, i + 1);
arrayOfObject2[i] = paramE;
setArray(arrayOfObject2);

就是這三句程式碼使得CopyOnWriterArrayList不會拋ConcurrentModificationException異常。他們所展現的魅力就在於copy原來的array,再在copy陣列上進行add操作,這樣做就完全不會影響COWIterator中的array了。

​ 所以CopyOnWriterArrayList所代表的核心概念就是:任何對array在結構上有所改變的操作(add、remove、clear等),CopyOnWriterArrayList都會copy現有的資料,再在copy的資料上修改,這樣就不會影響COWIterator中的資料了,修改完成之後改變原有資料的引用即可。同時這樣造成的代價就是產生大量的物件,同時陣列的copy也是相當有損耗的。

補充

最後附上阿里巴巴開發手冊

【強制】 不要在 foreach 迴圈裡進行元素的 remove/add 操作。 remove 元素請使用 Iterator
方式,如果併發操作,需要對 Iterator 物件加鎖。
正例:

List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if (刪除元素的條件) {
iterator.remove();
}
}

反例:

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

我們可以從使用javap -c 檢視foreach的class位元組碼檔案

可以發現foreach 迴圈內部實際是通過 Iterator 實現的,以上程式碼等同於:

for (Iterator<String> i = list.iterator(); i.hasNext(); ) {
    String item = i.next();
    if ("1".equals(item)) {
		list.remove(item);
	}
} 

實際上,foreach 迴圈僅是 Java 提供的語法糖。編譯器隱藏了對 Iterator 的使用,使得 foreach 在語法上較傳統 for 迴圈更加簡潔,也不容易出錯。下面我們看下 Iterator.

我們知道呼叫 Itr 的 remove() 方法移除集合元素時,首先會呼叫 ArrayList 的 remove() 方法,再對 expectedModCount 進行更新。在下次呼叫 Itr.next() 方法獲取下個元素時,不會出現 expectedModCount != modCount 的情況。

同時這也解釋了(不要在 foreach 迴圈裡進行元素的 remove/add 操作。 remove 元素請使用 Iterator)