天天看點

java多線程并發之旅-07-java 線程安全同步容器類問題同步容器類疊代器與ConcurrentModificationException隐藏疊代器同步容器的缺陷參考資料目錄

問題

  • 為什麼需要同步容器類?
  • 同步容器類的優點和缺點?
  • 對我們設計的啟發

同步容器類

java 中的同步容器

在Java中,同步容器主要包括2類:

1)Vector、Stack、HashTable

2)Collections類中提供的靜态工廠方法建立的類

Vector實作了List接口,Vector實際上就是一個數組,和ArrayList類似,但是Vector中的方法都是synchronized方法,即進行了同步措施。

Stack也是一個同步容器,它的方法也用synchronized進行了同步,它實際上是繼承于Vector類。

HashTable實作了Map接口,它和HashMap很相似,但是HashTable進行了同步處理,而HashMap沒有。

Collections類是一個工具提供類,注意,它和Collection不同,Collection是一個頂層的接口。

在Collections類中提供了大量的方法,比如對集合或者容器進行排序、查找等操作。

最重要的是,在它裡面提供了幾個靜态工廠方法

Collections.synchronizedXXX

來建立同步容器類,

這些類實作安全的方式是,将他們的狀态封裝起來,并對每個public方法進行同步,進而使得每次隻有一個線程能通路容器的狀态。

複合操作

同步容器類都是線程安全的,但是對于某些複合操作需要額外的加鎖來保護。常見複合操作有:疊代(反複通路元素,直到周遊所有元素)、跳轉(根據指定順序找到當期元素的下一個元素)以及條件運算(如:如沒有則添加)。

Get/Delete 線程問題

存在問題的代碼

public static Object getLast(Vector list){
    int lastIndex = list.size() - 1;
    return list.get(lastIndex)
}

public static void deleteLast(Vector list){
    int lastIndex = list.size() - 1;
    list.remove(lastIndex);
}
           

上面例子中,Vector中定義了兩個方法,它們都執行先檢查再運作操作。先擷取數組大小,再擷取或删除最後一個元素。這些方法看似沒問題,并且都是線程安全的,也不破壞Vector。但是從調用者角度來看,就有問題了。可能A線程調用getLast的過程中,B線程調用了deleteLast,Vector元素減少,導緻A線程調用失敗。

同步容器類遵守同步政策,即支援用戶端加鎖,是以隻要我們知道應該使用那個鎖,就能建立一些新的操作。這些新操作與容器與其他操作都是原子操作。同步容器通過自身的鎖來保護它的每個方法。通過擷取容器的鎖,就能使上面的方法稱為原子操作。size和get操作之間不會有其他操作。

Vector 上複合加鎖

public static Object getLast(Vector list){
    synchronized(list){
        int lastIndex = list.size() - 1;
        return list.get(lastIndex)
    }
    
}

public static void deleteLast(Vector list){
    synchronized(list){
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }
}
           

周遊的線程安全問題

問題代碼

同樣的問題也會出現在周遊上,如下面的例子:

for(int i=0; i< vector.size(); i++)
    doSomgthing(vector.get(i));
           

如果另外一個線程删除一個元素,會導緻ArrayIndexOutBoundsException異常。

修正代碼

我們可以通過加鎖來解決疊代不可靠問題,避免其他線程在周遊過程修改Vector。

但也帶來性能問題,疊代期間其他線程無法通路它。

synchronized(vector){
    for(int i=0; i< vector.size(); i++)
        doSomgthing(vector.get(i));
}
           

上面的例子在未加鎖的情況下都可能抛出異常,這并不意味着Vector不是線程安全的。Vector仍然是線程安全的,抛出的異常也與其規範保持一緻。

疊代器與ConcurrentModificationException

對容器進行疊代的标準方式是使用Iterator,使用for-each文法,也是調用Iterator。

在設計同步容器類的時候并沒有考慮并發修改問題,它們表現出的行為是“及時失敗”的,意味着在疊代過程中,如果有其他線程修改容器,會抛出ConcurrentModificationException異常。它們實作的方式是,将計數器變化與容器關聯起來,放疊代器件計數器被修改,那麼hasNext或next将抛出異常。這是設計上的一個權衡。

如果容器規模很大, 那麼後續線程将會等待較長的時間。調用 doSomething 時持有鎖,可能會産生死鎖。長時間對容器加鎖會降低程式的可伸縮性。持有鎖的時間過長,競争就可能越激烈,如果有多個線程在等待,那麼将極大降低吞吐量和CPU的使用率。

要想避免ConcurrentModificationException,就必須在疊代過程持有容器的鎖。但是如果容器規模很大,疊代過程持有鎖,将導緻嚴重的性能問題。一種替代方式就是“克隆容器”,并在副本上疊代。克隆過程仍然需要加鎖,同時存在顯著的性能開銷。克隆容器的好壞取決于過個元素,如容器大小,疊代時,每個元素執行的操作等。

隐藏疊代器

加鎖可以防止疊代抛出ConcurrentModificationException異常,但是需要在所有疊代的地方進行加鎖。實際情況通常更加複雜,有些情況下可能會忽略隐藏的疊代器。

public class HiddenIterator{
    private final Set(Integer) set = new HashSet<>();
    
    public synchronized void add(Integer i){set.add(i)}
    public synchronized void remove(Integer i){set.remove(i)}
    
    public void addTenThings(){
        Random r = new Random();
        for(int i=0;i<10;i++){
            add(r.nextInt());
        }
        System.out.println("debug" + set)
    }
}
           

addTenThings方法可能抛出ConcurrentModificationException異常,因為在列印輸出的時候進行字元串連接配接,會調用set的toString方法,toString方法會對容器進行疊代。在使用println前必須擷取HiddenIterator的鎖,但是實際應用中可能忽略。

封裝對象的狀态有助于維持不變性,封裝對象的同步機制有助有確定實施同步政策。

如果使用synchronizedSet來包裝HashSet,并且對同步代碼進行封裝,就不會發生這種錯誤。

隐式疊代情況

除了toString,hashCode和equals等方法也會間接執行疊代操作。

當容器作為另一個容器的元素和鍵值時,就會出現這種情況。

同樣,containsAll,removeAll等方法,以及把容器作為參數的構造函數都會對容器進行疊代。這些間接操作都有可能抛出ConcurrentModificationException異常。

同步容器的缺陷

  1. 并非任何場景都是線程安全的。
  2. 因為加鎖,性能比較差。

參考資料

《java 并發程式設計的藝術》

http://www.cnblogs.com/lilinwei340/p/6987008.html

https://www.cnblogs.com/dolphin0520/p/3933404.html

目錄

java多線程并發之旅-01-并發概覽

繼續閱讀