從原始碼分析非執行緒安全集合類的不安全迭代器

monkeysayhi發表於2017-10-11

非執行緒安全集合類(這裡的集合指容器Collection,非Set)的迭代器結合了及時失敗機制,但仍然是不安全的。這種不安全表現在許多方面:

  1. 併發修改“通常”導致及時失敗
  2. 單執行緒修改也可能導致及時失敗的“誤報”
  3. 迭代器會“丟失”某些併發修改行為,讓及時失敗失效

如果不瞭解其不安全之處就隨意使用,就像給程式埋下了地雷,隨時可能引爆,卻不可預知。
ArrayList是一個常用的非執行緒安全集合,下面以基於ArrayList講解幾種代表情況。

及時失敗

及時失敗也叫快速失敗,fast-fail。
“及時失敗”的迭代器並不是一種完備的處理機制,而只是“善意地”捕獲併發錯誤,因此只能作為併發問題的預警指示器。它們採用的實現方式是,將計數器的變化與容器關聯起來:如果在迭代期間計數器被修改,那麼hasNext或next將丟擲ConcurrentModificationException。然而,這種檢查是在沒有同步的情況下進行的,因此可能會看到失效的計數器,而迭代器可能並沒有意識到已經發生了修改。這是一種設計上的權衡,從而降低併發修改操作的檢測程式碼對程式效能帶來的影響。

然而,及時失敗機制十分簡潔(簡單&清晰),同時對集合的效能影響十分小,所以大部分非執行緒安全的集合類仍然使用這種機制來進行“善意”的提醒。

幾種非執行緒安全的代表情況

併發修改“通常”導致及時失敗

“通常”是因為及時失敗的“善意”性質,它很多時候會給我們提醒,但有時候也不會給出提醒,有時候甚至給出某種意義上的錯誤提醒。這一小節針對正常的情況,這是我們考察一個機制是否值得“採納並完善”的根本屬性。

構造下列程式:

private Collection users = new ArrayList(); // 所以應使用CopyOnWriteArrayList
…
users.add(new User("張三",28));
users.add(new User("李四",25));
users.add(new User("王五",31));
…
public void run() {
    Iterator itrUsers = users.iterator();
    while(itrUsers.hasNext()){
        System.out.println("aaaa");
        User user = (User)itrUsers.next();
        if(“張三”.equals(user.getName())){ // 在迭代過程中修改集合
            itrUsers.remove();
        } else { // 正常輸出
            System.out.println(user);
        }
    }
}
…複製程式碼

忽略細節,假設有多個執行緒在同時執行run方法,操作users集合。這時,“通常”會導致及時失敗。這裡的異常可能從next或remove方法中丟擲(當然這裡是從next,因為next先執行):

private class Itr implements Iterator<E> {
…
    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];
    }
    public void remove() { // 迭代器的remove方法
        if (lastRet < 0)
            throw new IllegalStateException();
        checkForComodification();

        try {
            ArrayList.this.remove(lastRet); // 集合的remove方法
            cursor = lastRet;
            lastRet = -1;
            expectedModCount = modCount;
        } catch (IndexOutOfBoundsException ex) {
            throw new ConcurrentModificationException();
        }
    }
…
}複製程式碼

實際檢查並丟擲異常的是checkForComodification方法:

private class Itr implements Iterator<E> {
…
int expectedModCount = modCount;
    …
    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
    …
}複製程式碼

modCount是當前集合的版本號,每次修改(增刪改)集合都會加 1;expectedModCount是當前迭代器的版本號,在迭代器例項化時初始化為modCount,只有remove方法正常執行(不丟擲異常)才可以修改這個值,與modCount保持同步。

因此,如果線上程A正常迭代的過程中,執行緒B修改了users集合,modCount就會發生變化,這時,執行緒B的expectedModCount能夠與modCount保持同步,執行緒A的expectedModCount卻發現自己與modCount不再同步,從而丟擲ConcurrentModificationException異常。

扯遠些:
對於執行緒安全的集合類而言,我們不希望任何失敗。但對於非執行緒安全的類,有人認為“應該在假設執行緒安全的情況下使用”,所以及時失敗機制完全沒有必要;有人認為“集合類的狀態太多(所有非執行緒安全域的狀態數量的乘積),併發使用時應該給出錯誤提醒,否則很難排查併發問題”,所以及時失敗機制很有必要。這個問題見仁見智,個人支援後者觀點。

所以,這種及時失敗的檢查是不完備的。

單執行緒修改也可能導致及時失敗的“誤報”

多執行緒併發修改集合時,丟擲ConcurrentModificationException異常作為及時失敗的提醒,往往是我們期望的結果。然而,如果在單執行緒遍歷迭代器的過程中修改了集合,也會丟擲ConcurrentModificationException異常,看起來發生了及時失敗。這不是我們期望的結果,是一種及時失敗的誤報。

我們改用集合的remove方法移除user“張三”:

public void run() {
    …
        if(“張三”.equals(user.getName())){ // 在迭代過程中修改集合
            users.remove(user); // itrUsers.remove();
        } else { // 正常輸出
            System.out.println(user);
        }
    …
}
…複製程式碼

假設只有一個執行緒執行run方法,在”張三”被刪除之後,下一次執行next方法時,仍舊會丟擲ConcurrentModificationException異常,也就是導致了及時失敗。

這時因為集合的remove方法並沒有維護集合修改的狀態(如對modCount&expectedModCount組合的修改和檢查):

public class ArrayList<E> extends AbstractList<E>
…
    public boolean remove(Object o) { // 集合的remove方法
        if (o == null) {
            for (int index = 0; index < size; index++)
                if (elementData[index] == null) {
                    fastRemove(index);
                    return true;
                }
        } else {
            for (int index = 0; index < size; index++)
                if (o.equals(elementData[index])) {
                    fastRemove(index);
                    return true;
                }
        }
        return false;
    }
…
}複製程式碼

這也讓我們更容易理解及時失敗的本質——依託於對集合修改狀態的維護。這裡的主要原因看起來是“集合的remove方法破壞了正常維護的集合修改狀態”,但對於使用者而言,集合在單執行緒環境下卻丟擲了ConcurrentModificationException異常,這是由於及時失敗機制沒有區分單執行緒與多執行緒的情況,統一給出同樣的提醒(丟擲ConcurrentModificationException異常),因而是及時失敗的誤報。

迭代器會“丟失”某些併發修改行為,讓及時失敗失效

除了誤報,及時失敗之僅限於“善意”(有提醒就是“善意”的,沒有也不是“惡意”的)還體現在其可能“丟失”某些併發修改行為。在這裡,“丟失”意味著不提醒——某些執行緒併發修改了當前集合,但沒有丟擲ConcurrentModificationException異常,及時失敗機制失效了。

主動避過及時失敗的檢查

利用hasNext方法提前結束執行緒,可以主動避過及時失敗的檢查,從而導致修改行為的丟失:

private class Itr implements Iterator<E> {
…
    public boolean hasNext() {
        return cursor != size; // 思考:如果刪除了集合的倒數第二個元素,會發生什麼?
    }
…
}複製程式碼

還是單執行緒的場景下,假設我們刪除了集合的倒數第二個元素。這時next方法導致cursor=oldSize-1,同時remove方法導致newSize=oldSize-1(oldSize是集合修改之前的size值,newSize集合修改之後的);所以hasNext方法會返回false,讓使用者誤以為集合迭代已經結束(實際上還有最後一個元素),從而迴圈終止(在我們的程式裡用hasNext判斷是否結束),無法丟擲ConcurrentModificationException異常,及時失敗失效了。

推廣到多執行緒的情景是一樣的,因為size是共享的。

及時失敗的實現是非執行緒安全的

很容易忽略的一點是,上述集合修改狀態的維護本身就是在沒有同步的情況下進行的,因此可能看到更多(遠比上述要多)失效的集合修改狀態,使迭代器意識不到集合發生了修改,這是一種競態條件(Race Condition)。

假設執行緒A進入迭代器的remove方法,執行緒B進入迭代器的next方法,現線上程A執行集合的remove方法:

private class Itr implements Iterator<E> {
…
    public void remove() {
        …
            ArrayList.this.remove(lastRet);
        …
    }
…
}複製程式碼

首先,假設沒有其他執行緒併發修改,則兩個執行緒都可以通過checkForComodification()的檢查;然後執行緒A快速的執行集合的remove方法;待執行緒A執行完集合的remove方法,由於執行緒B之前已經通過了檢查,現在就無法意識到“users集合線上程A中已經發生了變化”。另外,因為幾乎完全不存在同步措施,modCount的修改也存在競態條件,其他狀態也無法保證是否有效。

總結

上面看到了非執行緒安全集合類的迭代器是不安全的,但在單執行緒的環境下,這些集合類在效能、維護難度等方面仍然具有不可替代的優勢。那麼該如何在兼具一定程度執行緒安全的前提下,更好的發揮內建集合類的優勢呢?總結起來無非兩點:

  1. 使用非執行緒安全的集合時(實際上對於某些“執行緒安全”的集合類,其迭代器也是執行緒不安全的),迭代過程中需要使用者自覺維護,不修改該集合。
  2. 應儘可能明確執行緒安全的需求等級,做好一致性、活躍性、效能等方面的平衡,再針對性的使用相應的集合類。

參考:

  • 傳智播客_張孝祥_Java多執行緒與併發庫高階應用視訊教程/19_傳智播客_張孝祥_java5同步集合類的應用.avi

本文連結:原始碼|從原始碼分析非執行緒安全集合類的不安全迭代器
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章