計算機程式的思維邏輯 (74) - 併發容器 - ConcurrentHashMap

swiftma發表於2017-03-16

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (74) - 併發容器 - ConcurrentHashMap

本節介紹一個常用的併發容器 - ConcurrentHashMap,它是HashMap的併發版本,與HashMap相比,它有如下特點:

  • 併發安全
  • 直接支援一些原子複合操作
  • 支援高併發、讀操作完全並行、寫操作支援一定程度的並行
  • 與同步容器Collections.synchronizedMap相比,迭代不用加鎖,不會丟擲ConcurrentModificationException
  • 弱一致性

我們分別來看下。

併發安全

我們知道,HashMap不是併發安全的,在併發更新的情況下,HashMap的連結串列結構可能形成環,出現死迴圈,佔滿CPU,我們看個例子:

public static void unsafeConcurrentUpdate() {
    final Map<Integer, Integer> map = new HashMap<>();
    for (int i = 0; i < 100; i++) {
        Thread t = new Thread() {
            Random rnd = new Random();

            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    map.put(rnd.nextInt(), 1);
                }
            }
        };
        t.start();
    }
}    
複製程式碼

執行上面的程式碼,在我的機器上,每次都會出現死迴圈,佔滿CPU。

為什麼會出現死迴圈呢?死迴圈出現在多個執行緒同時擴容雜湊表的時候,不是同時更新一個連結串列的時候,那種情況可能會出現更新丟失,但不會死迴圈,具體過程比較複雜,我們就不解釋了,感興趣的讀者可以參考這篇文章,http://coolshell.cn/articles/9606.html。

使用Collections.synchronizedMap方法可以生成一個同步容器,避免該問題,替換第一行程式碼即可:

final Map<Integer, Integer> map = Collections.synchronizedMap(new HashMap<Integer, Integer>());
複製程式碼

在Java中,HashMap還有一個同步版本Hashtable,它與使用synchronizedMap生成的Map基本是一樣的,也是在每個方法呼叫上加了synchronized,我們就不贅述了。

同步容器有幾個問題:

  • 每個方法都需要同步,支援的併發度比較低
  • 對於迭代和複合操作,需要呼叫方加鎖,使用比較麻煩,且容易忘記

ConcurrentHashMap沒有這些問題,它同樣實現了Map介面,也是基於雜湊表實現的,上面的程式碼替換第一行即可:

final Map<Integer, Integer> map = new ConcurrentHashMap<>();
複製程式碼

原子複合操作

除了Map介面,ConcurrentHashMap還實現了一個介面ConcurrentMap,介面定義了一些條件更新操作,具體定義為:

public interface ConcurrentMap<K, V> extends Map<K, V> {
    //條件更新,如果Map中沒有key,設定key為value,返回原來key對應的值,如果沒有,返回null
    V putIfAbsent(K key, V value);
    //條件刪除,如果Map中有key,且對應的值為value,則刪除,如果刪除了,返回true,否則false
    boolean remove(Object key, Object value);
    //條件替換,如果Map中有key,且對應的值為oldValue,則替換為newValue,如果替換了,返回ture,否則false
    boolean replace(K key, V oldValue, V newValue);
    //條件替換,如果Map中有key,則替換值為value,返回原來key對應的值,如果原來沒有,返回null
    V replace(K key, V value);
}
複製程式碼

如果使用同步容器,呼叫方必須加鎖,而ConcurrentMap將它們實現為了原子操作。實際上,使用ConcurrentMap,呼叫方也沒有辦法進行加鎖,它沒有暴露鎖介面,也不使用synchronized。

高併發

ConcurrentHashMap是為高併發設計的,它是怎麼做的呢?具體實現比較複雜,我們簡要介紹其思路,主要有兩點:

  • 分段鎖
  • 讀不需要鎖

同步容器使用synchronized,所有方法,競爭同一個鎖,而ConcurrentHashMap採用分段鎖技術,將資料分為多個段,而每個段有一個獨立的鎖,每一個段相當於一個獨立的雜湊表,分段的依據也是雜湊值,無論是儲存鍵值對還是根據鍵查詢,都先根據鍵的雜湊值對映到段,再在段對應的雜湊表上進行操作。

採用分段鎖,可以大大提高併發度,多個段之間可以並行讀寫。預設情況下,段是16個,不過,這個數字可以通過構造方法進行設定,如下所示:

public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel)
複製程式碼

concurrencyLevel表示估計的並行更新的執行緒個數,ConcurrentHashMap會將該數轉換為2的整數次冪,比如14轉換為16,25轉換為32。

在對每個段的資料進行讀寫時,ConcurrentHashMap也不是簡單的使用鎖進行同步,內部使用了CAS、對一些寫採用原子方式,實現比較複雜,我們就不介紹了,實現的效果是,對於寫操作,需要獲取鎖,不能並行,但是讀操作可以,多個讀可以並行,寫的同時也可以讀,這使得ConcurrentHashMap的並行度遠遠大於同步容器。

迭代

我們在66節介紹過,使用同步容器,在迭代中需要加鎖,否則可能會丟擲ConcurrentModificationException。ConcurrentHashMap沒有這個問題,在迭代器建立後,在迭代過程中,如果另一個執行緒對容器進行了修改,迭代會繼續,不會丟擲異常。

問題是,迭代會反映別的執行緒的修改?還是像上節介紹的CopyOnWriteArrayList一樣,反映的是建立時的副本?答案是,都不是!我們看個例子:

public class ConcurrentHashMapIteratorDemo {
    public static void test() {
        final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
        map.put("a", "abstract");
        map.put("b", "basic");

        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (Entry<String, String> entry : map.entrySet()) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                    }
                    System.out.println(entry.getKey() + "," + entry.getValue());
                }
            }
        };
        t1.start();
        // 確保執行緒t1啟動
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
        }
        map.put("c", "call");
    }

    public static void main(String[] args) {
        test();
    }
}
複製程式碼

t1啟動後,建立迭代器,但在迭代輸出每個元素前,先睡眠1秒鐘,主執行緒啟動t1後,先睡眠一下,確保t1先執行,然後給map增加了一個元素,程式輸出為:

a,abstract
b,basic
c,call
複製程式碼

說明,迭代器反映了最新的更新,但我們將新增語句更改為:

map.put("g", "call");
複製程式碼

你會發現,程式輸出為:

a,abstract
b,basic
複製程式碼

這說明,迭代器沒有反映最新的更新,這是怎麼回事呢?我們需要理解ConcurrentHashMap的弱一致性

弱一致性

ConcurrentHashMap的迭代器建立後,就會按照雜湊表結構遍歷每個元素,但在遍歷過程中,內部元素可能會發生變化,如果變化發生在已遍歷過的部分,迭代器就不會反映出來,而如果變化發生在未遍歷過的部分,迭代器就會發現並反映出來,這就是弱一致性。

類似的情況還會出現在ConcurrentHashMap的另一個方法:

//批量新增m中的鍵值對到當前Map
public void putAll(Map<? extends K, ? extends V> m) 
複製程式碼

該方法並非原子操作,而是呼叫put方法逐個元素進行新增的,在該方法沒有結束的時候,部分修改效果就會體現出來。

小結

本節介紹了ConcurrentHashMap,它是併發版的HashMap,通過分段鎖和其他技術實現了高併發,支援原子條件更新操作,不會丟擲ConcurrentModificationException,實現了弱一致性。

Java中沒有併發版的HashSet,但可以通過Collections.newSetFromMap方法基於ConcurrentHashMap構建一個。

我們知道HashMap/HashSet基於雜湊,不能對元素排序,對應的可排序的容器類是TreeMap/TreeSet,併發包中可排序的對應版本不是基於樹,而是基於Skip List(跳躍表)的,類分別是ConcurrentSkipListMap和ConcurrentSkipListSet,它們到底是什麼呢?

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (74) - 併發容器 - ConcurrentHashMap

相關文章