1. 程式人生 > >Java集合篇:fail-fast機制 與 fail-safe

Java集合篇:fail-fast機制 與 fail-safe

在JDK的Collection中我們時常會看到類似於這樣的話:

例如,ArrayList:

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

   HashMap中:

注意,迭代器的快速失敗行為不能得到保證,一般來說,存在非同步的併發修改時,不可能作出任何堅決的保證。快速失敗迭代器盡最大努力丟擲 ConcurrentModificationException。因此,編寫依賴於此異常的程式的做法是錯誤的,正確做法是:迭代器的快速失敗行為應該僅用於檢測程式錯誤。

  在這兩段話中反覆地提到”快速失敗”。那麼何為”快速失敗”機制呢?

“快速失敗”也就是 fail-fast,它是Java集合的一種錯誤檢查機制。當多個執行緒對集合進行結構上的改變的操作時,有可能會產生 fail-fast 機制。記住是有可能,而不是一定,例如,假設存線上程(執行緒1、執行緒2),執行緒1通過Iterator在遍歷集合A中的元素,在某個時候執行緒2修改了集合A的結構(是結構上面的修改,而不是簡單的修改集合元素的內容),那麼這個時候程式就會丟擲 ConcurrentModificationException 異常,從而產生fail-fast機制。

 

一、fail-fast 示例:

public class FailFastTest {
    private static List<Integer> list = new ArrayList<>();
    
    /**
     * @desc:執行緒one迭代list
     */
    private static class threadOne extends Thread{
        public void run() {
            Iterator<Integer> iterator = list.iterator();
            while(iterator.hasNext()){
                int i = iterator.next();
                System.out.println("ThreadOne 遍歷:" + i);
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    /**
     * @desc:當i == 3時,修改list
     */
    private static class threadTwo extends Thread{
        public void run(){
            int i = 0 ; 
            while(i < 6){
                System.out.println("ThreadTwo run:" + i);
                if(i == 3){
                    list.remove(i);
                }
                i++;
            }
        }
    }
    
    public static void main(String[] args) {
        for(int i = 0 ; i < 10;i++){
            list.add(i);
        }
        new threadOne().start();
        new threadTwo().start();
    }
}

執行結構:

ThreadOne 遍歷:0
ThreadTwo run:0
ThreadTwo run:1
ThreadTwo run:2
ThreadTwo run:3
ThreadTwo run:4
ThreadTwo 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)

 

二、fail-fast 產生的原因:

通過上面的示例和講解,我初步知道fail-fast產生的原因就在於程式在對 collection 進行迭代時,某個執行緒對該 collection 在結構上對其做了修改,這時迭代器就會丟擲 ConcurrentModificationException 異常資訊,從而產生 fail-fast。

要了解fail-fast機制,我們首先要對ConcurrentModificationException 異常有所瞭解。當方法檢測到物件的併發修改,但不允許這種修改時就丟擲該異常。同時需要注意的是,該異常不會始終指出物件已經由不同執行緒併發修改,如果單執行緒違反了規則,同樣也有可能會丟擲改異常。

誠然,迭代器的快速失敗行為無法得到保證,它不能保證一定會出現該錯誤,但是快速失敗操作會盡最大努力丟擲ConcurrentModificationException異常,所以因此,為提高此類操作的正確性而編寫一個依賴於此異常的程式是錯誤的做法,正確做法是:ConcurrentModificationException 應該僅用於檢測 bug。下面我將以ArrayList為例進一步分析fail-fast產生的原因。

從前面我們知道fail-fast是在操作迭代器時產生的。現在我們來看看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();
        }
    }

 從上面的原始碼我們可以看出,迭代器在呼叫next()、remove()方法時都是呼叫checkForComodification()方法,該方法主要就是檢測modCount == expectedModCount ? 若不等則丟擲ConcurrentModificationException 異常,從而產生fail-fast機制。所以要弄清楚為什麼會產生fail-fast機制我們就必須要用弄明白為什麼modCount != expectedModCount ,他們的值在什麼時候發生改變的。

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

protected transient int modCount = 0;

那麼他什麼時候因為什麼原因而發生改變呢?請看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);
    }
    
    private void ensureExplicitCapacity(int paramInt) {
        this.modCount += 1;    //修改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;
    }
 
    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產生的根本原因了,我們可以有如下場景:

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

        所以,直到這裡我們已經完全瞭解了fail-fast產生的根本原因了。知道了原因就好找解決辦法了。
 

三、fail-fast 解決方法:

通過前面的例項、原始碼分析,我想各位已經基本瞭解了fail-fast的機制,下面我就產生的原因提出解決方案。這裡有兩種解決方案:

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

方案二:使用CopyOnWriteArrayList 替換 ArrayLIst,推薦使用該方案(即fail-safe)。

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也是相當有損耗的。
 

四、fail-safe 機制:

1、fail-safe 任何對集合結構的修改都會在一個複製的集合上進行,因此不會丟擲ConcurrentModificationException。

2、fail-safe 機制有兩個問題:

(1)需要複製集合,產生大量的無效物件,開銷大。

(2)無法保證讀取到的資料是目前原始結構中的資料。

3、fail-safe例子:

import java.util.concurrent.ConcurrentHashMap;
import java.util.Iterator;
 
public class FailSafeExample
{
    public static void main(String[] args)
    {
        ConcurrentHashMap<String,String> premiumPhone = 
                               new ConcurrentHashMap<String,String>();
        premiumPhone.put("Apple", "iPhone");
        premiumPhone.put("HTC", "HTC one");
        premiumPhone.put("Samsung","S5");
        
        Iterator iterator = premiumPhone.keySet().iterator();
        
        while (iterator.hasNext())
        {
            System.out.println(premiumPhone.get(iterator.next()));
            premiumPhone.put("Sony", "Xperia Z");
        }   
    }
}

執行結果:

S5
HTC one
iPhone

 

五、fail-fast 和 fail-safe 的區別:

  Fail Fast Iterator Fail Safe Iterator
Throw ConcurrentModification Exception Yes No
Clone object No Yes
Memory Overhead No Yes
Examples HashMap,Vector,ArrayList,HashSet
CopyOnWriteArrayList,
ConcurrentHashMap

 

原文轉自:

https://blog.csdn.net/chenssy/article/details/38151189

https://blog.csdn.net/ch717828/article/details/46892051?utm_source=blogxgwz0