一不小心就讓Java開發踩坑的fail-fast是個什麼鬼?

HollisChuang發表於2019-04-17

GitHub 2.1k Star 的Java工程師成神之路 ,不來了解一下嗎?

GitHub 2.1k Star 的Java工程師成神之路 ,真的不來了解一下嗎?

GitHub 2.1k Star 的Java工程師成神之路 ,真的確定不來了解一下嗎?

我在《為什麼阿里巴巴禁止在 foreach 迴圈裡進行元素的 remove/add 操作》一文中曾經介紹過Java中的fail-fast機制,但是並沒有深入介紹,本文,就來深入介紹一下fail-fast。

什麼是fail-fast

首先我們看下維基百科中關於fail-fast的解釋:

In systems design, a fail-fast system is one which immediately reports at its interface any condition that is likely to indicate a failure. Fail-fast systems are usually designed to stop normal operation rather than attempt to continue a possibly flawed process. Such designs often check the system's state at several points in an operation, so any failures can be detected early. The responsibility of a fail-fast module is detecting errors, then letting the next-highest level of the system handle them.

大概意思是:在系統設計中,快速失效系統一種可以立即報告任何可能表明故障的情況的系統。快速失效系統通常設計用於停止正常操作,而不是試圖繼續可能存在缺陷的過程。這種設計通常會在操作中的多個點檢查系統的狀態,因此可以及早檢測到任何故障。快速失敗模組的職責是檢測錯誤,然後讓系統的下一個最高階別處理錯誤。

其實,這是一種理念,說白了就是在做系統設計的時候先考慮異常情況,一旦發生異常,直接停止並上報。

舉一個最簡單的fail-fast的例子:

public int divide(int divisor,int dividend){
    if(dividend == 0){
        throw new RuntimeException("dividend can't be null");
    }
    return divisor/dividend;
}
複製程式碼

上面的程式碼是一個對兩個整數做除法的方法,在divide方法中,我們對被除數做了個簡單的檢查,如果其值為0,那麼就直接丟擲一個異常,並明確提示異常原因。這其實就是fail-fast理念的實際應用。

這樣做的好處就是可以預先識別出一些錯誤情況,一方面可以避免執行復雜的其他程式碼,另外一方面,這種異常情況被識別之後也可以針對性的做一些單獨處理。

怎麼樣,現在你知道fail-fast了吧,其實他並不神祕,你日常的程式碼中可能經常會在使用的。

既然,fail-fast是一種比較好的機制,為什麼文章標題說fail-fast會有坑呢?

原因是Java的集合類中運用了fail-fast機制進行設計,一旦使用不當,觸發fail-fast機制設計的程式碼,就會發生非預期情況。

集合類中的fail-fast

我們通常說的Java中的fail-fast機制,預設指的是Java集合的一種錯誤檢測機制。當多個執行緒對部分集合進行結構上的改變的操作時,有可能會產生fail-fast機制,這個時候就會丟擲ConcurrentModificationException(後文用CME代替)。

CMException,當方法檢測到物件的併發修改,但不允許這種修改時就丟擲該異常。

很多時候正是因為程式碼中丟擲了CMException,很多程式設計師就會很困惑,明明自己的程式碼並沒有在多執行緒環境中執行,為什麼會丟擲這種併發有關的異常呢?這種情況在什麼情況下才會丟擲呢?我們就來深入分析一下。

異常復現

在Java中, 如果在foreach 迴圈裡對某些集合元素進行元素的 remove/add 操作的時候,就會觸發fail-fast機制,進而丟擲CMException。

如以下程式碼:

List<String> userNames = new ArrayList<String>() {{
    add("Hollis");
    add("hollis");
    add("HollisChuang");
    add("H");
}};

for (String userName : userNames) {
    if (userName.equals("Hollis")) {
        userNames.remove(userName);
    }
}

System.out.println(userNames);
複製程式碼

以上程式碼,使用增強for迴圈遍歷元素,並嘗試刪除其中的Hollis字串元素。執行以上程式碼,會丟擲以下異常:

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.hollis.ForEach.main(ForEach.java:22)
複製程式碼

同樣的,讀者可以嘗試下在增強for迴圈中使用add方法新增元素,結果也會同樣丟擲該異常。

在深入原理之前,我們先嚐試把foreach進行解語法糖,看一下foreach具體如何實現的。

我們使用jad工具,對編譯後的class進行反編譯,得到以下程式碼:

public static void main(String[] args) {
    // 使用ImmutableList初始化一個List
    List<String> userNames = new ArrayList<String>() {{
        add("Hollis");
        add("hollis");
        add("HollisChuang");
        add("H");
    }};

    Iterator iterator = userNames.iterator();
    do
    {
        if(!iterator.hasNext())
            break;
        String userName = (String)iterator.next();
        if(userName.equals("Hollis"))
            userNames.remove(userName);
    } while(true);
    System.out.println(userNames);
}
複製程式碼

可以發現,foreach其實是依賴了while迴圈和Iterator實現的。

異常原理

通過以上程式碼的異常堆疊,我們可以跟蹤到真正丟擲異常的程式碼是:

java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
複製程式碼

該方法是在iterator.next()方法中呼叫的。我們看下該方法的實現:

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
複製程式碼

如上,在該方法中對modCount和expectedModCount進行了比較,如果二者不想等,則丟擲CMException。

那麼,modCount和expectedModCount是什麼?是什麼原因導致他們的值不想等的呢?

modCount是ArrayList中的一個成員變數。它表示該集合實際被修改的次數。

List<String> userNames = new ArrayList<String>() {{
    add("Hollis");
    add("hollis");
    add("HollisChuang");
    add("H");
}};
複製程式碼

當使用以上程式碼初始化集合之後該變數就有了。初始值為0。

expectedModCount 是 ArrayList中的一個內部類——Itr中的成員變數。

Iterator iterator = userNames.iterator();
複製程式碼

以上程式碼,即可得到一個 Itr類,該類實現了Iterator介面。

expectedModCount表示這個迭代器預期該集合被修改的次數。其值隨著Itr被建立而初始化。只有通過迭代器對集合進行操作,該值才會改變。

那麼,接著我們看下userNames.remove(userName);方法裡面做了什麼事情,為什麼會導致expectedModCount和modCount的值不一樣。

通過翻閱程式碼,我們也可以發現,remove方法核心邏輯如下:

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
}
複製程式碼

可以看到,它只修改了modCount,並沒有對expectedModCount做任何操作。

簡單畫一張圖描述下以上場景:

一不小心就讓Java開發踩坑的fail-fast是個什麼鬼?

簡單總結一下,之所以會丟擲CMException異常,是因為我們的程式碼中使用了增強for迴圈,而在增強for迴圈中,集合遍歷是通過iterator進行的,但是元素的add/remove卻是直接使用的集合類自己的方法。這就導致iterator在遍歷的時候,會發現有一個元素在自己不知不覺的情況下就被刪除/新增了,就會丟擲一個異常,用來提示使用者,可能發生了併發修改!

所以,在使用Java的集合類的時候,如果發生CMException,優先考慮fail-fast有關的情況,實際上這裡並沒有真的發生併發,只是Iterator使用了fail-fast的保護機制,只要他發現有某一次修改是未經過自己進行的,那麼就會丟擲異常。

關於如何解決這種問題,我們在《為什麼阿里巴巴禁止在 foreach 迴圈裡進行元素的 remove/add 操作》中介紹過,這裡不再贅述了。

fail-safe

為了避免觸發fail-fast機制,導致異常,我們可以使用Java中提供的一些採用了fail-safe機制的集合類。

這樣的集合容器在遍歷時不是直接在集合內容上訪問的,而是先複製原有集合內容,在拷貝的集合上進行遍歷。

java.util.concurrent包下的容器都是fail-safe的,可以在多執行緒下併發使用,併發修改。同時也可以在foreach中進行add/remove 。

我們拿CopyOnWriteArrayList這個fail-safe的集合類來簡單分析一下。

public static void main(String[] args) {
    List<String> userNames = new CopyOnWriteArrayList<String>() {{
        add("Hollis");
        add("hollis");
        add("HollisChuang");
        add("H");
    }};

    userNames.iterator();

    for (String userName : userNames) {
        if (userName.equals("Hollis")) {
            userNames.remove(userName);
        }
    }

    System.out.println(userNames);
}
複製程式碼

以上程式碼,使用CopyOnWriteArrayList代替了ArrayList,就不會發生異常。

fail-safe集合的所有對集合的修改都是先拷貝一份副本,然後在副本集合上進行的,並不是直接對原集合進行修改。並且這些修改方法,如add/remove都是通過加鎖來控制併發的。

所以,CopyOnWriteArrayList中的迭代器在迭代的過程中不需要做fail-fast的併發檢測。(因為fail-fast的主要目的就是識別併發,然後通過異常的方式通知使用者)

但是,雖然基於拷貝內容的優點是避免了ConcurrentModificationException,但同樣地,迭代器並不能訪問到修改後的內容。如以下程式碼:

public static void main(String[] args) {
    List<String> userNames = new CopyOnWriteArrayList<String>() {{
        add("Hollis");
        add("hollis");
        add("HollisChuang");
        add("H");
    }};

    Iterator it = userNames.iterator();

    for (String userName : userNames) {
        if (userName.equals("Hollis")) {
            userNames.remove(userName);
        }
    }

    System.out.println(userNames);

    while(it.hasNext()){
        System.out.println(it.next());
    }
}
複製程式碼

我們得到CopyOnWriteArrayList的Iterator之後,通過for迴圈直接刪除原陣列中的值,最後在結尾處輸出Iterator,結果發現內容如下:

[hollis, HollisChuang, H]
Hollis
hollis
HollisChuang
H
複製程式碼

迭代器遍歷的是開始遍歷那一刻拿到的集合拷貝,在遍歷期間原集合發生的修改迭代器是不知道的。

Copy-On-Write

在瞭解了CopyOnWriteArrayList之後,不知道大家會不會有這樣的疑問:他的add/remove等方法都已經加鎖了,還要copy一份再修改幹嘛?多此一舉?同樣是執行緒安全的集合,這玩意和Vector有啥區別呢?

Copy-On-Write簡稱COW,是一種用於程式設計中的優化策略。其基本思路是,從一開始大家都在共享同一個內容,當某個人想要修改這個內容的時候,才會真正把內容Copy出去形成一個新的內容然後再改,這是一種延時懶惰策略。

CopyOnWrite容器即寫時複製的容器。通俗的理解是當我們往一個容器新增元素的時候,不直接往當前容器新增,而是先將當前容器進行Copy,複製出一個新的容器,然後新的容器裡新增元素,新增完元素之後,再將原容器的引用指向新的容器。

CopyOnWriteArrayList中add/remove等寫方法是需要加鎖的,目的是為了避免Copy出N個副本出來,導致併發寫。

但是,CopyOnWriteArrayList中的讀方法是沒有加鎖的。

public E get(int index) {
    return get(getArray(), index);
}
複製程式碼

這樣做的好處是我們可以對CopyOnWrite容器進行併發的讀,當然,這裡讀到的資料可能不是最新的。因為寫時複製的思想是通過延時更新的策略來實現資料的最終一致性的,並非強一致性。

**所以CopyOnWrite容器是一種讀寫分離的思想,讀和寫不同的容器。**而Vector在讀寫的時候使用同一個容器,讀寫互斥,同時只能做一件事兒。

一不小心就讓Java開發踩坑的fail-fast是個什麼鬼?

相關文章