計算機程式的思維邏輯 (75) - 併發容器 - 基於SkipList的Map和Set

swiftma發表於2017-03-20

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

計算機程式的思維邏輯 (75) - 併發容器 - 基於SkipList的Map和Set

上節我們介紹了ConcurrentHashMap,ConcurrentHashMap不能排序,容器類中可以排序的Map和Set是TreeMap和TreeSet,但它們不是執行緒安全的。Java併發包中與TreeMap/TreeSet對應的併發版本是ConcurrentSkipListMap和ConcurrentSkipListSet,本節,我們就來簡要探討這兩個類。

基本概念

我們知道,TreeSet是基於TreeMap實現的,與此類似,ConcurrentSkipListSet也是基於ConcurrentSkipListMap實現的,所以,我們主要來探討ConcurrentSkipListMap。

ConcurrentSkipListMap是基於SkipList實現的,SkipList稱為跳躍表或跳錶,是一種資料結構,待會我們會進一步介紹。併發版本為什麼採用跳錶而不是樹呢?原因也很簡單,因為跳錶更易於實現高效併發演算法。

ConcurrentSkipListMap有如下特點:

  • 沒有使用鎖,所有操作都是無阻塞的,所有操作都可以並行,包括寫,多個執行緒可以同時寫。
  • 與ConcurrentHashMap類似,迭代器不會丟擲ConcurrentModificationException,是弱一致的,迭代可能反映最新修改也可能不反映,一些方法如putAll, clear不是原子的。
  • 與ConcurrentHashMap類似,同樣實現了ConcurrentMap介面,直接支援一些原子複合操作。
  • 與TreeMap一樣,可排序,預設按鍵自然有序,可以傳遞比較器自定義排序,實現了SortedMap和NavigableMap介面。

看段簡單的使用程式碼:

public static void main(String[] args) {
    Map<String, String> map = new ConcurrentSkipListMap<>(
            Collections.reverseOrder());
    map.put("a", "abstract");
    map.put("c", "call");
    map.put("b", "basic");
    System.out.println(map.toString());
}
複製程式碼

程式輸出為:

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

表示是有序的。

ConcurrentSkipListMap的大部分方法,我們之前都有介紹過,有序的方法,與TreeMap是類似的,原子複合操作,與ConcurrentHashMap是類似的,所以我們就不贅述了。

需要說明一下的是它的size方法,與大多數容器實現不同,這個方法不是常量操作,它需要遍歷所有元素,複雜度為O(N),而且遍歷結束後,元素個數可能已經變了,一般而言,在併發應用中,這個方法用處不大。

下面我們主要介紹下其基本實現原理。

基本實現原理

我們先來介紹下跳錶的結構,跳錶是基於連結串列的,在連結串列的基礎上加了多層索引結構。我們通過一個簡單的例子來看下,假定容器中包含如下元素:

3, 6, 7, 9, 12, 17, 19, 21, 25, 26
複製程式碼

對Map來說,這些值可以視為鍵。ConcurrentSkipListMap會構造類似下圖所示的跳錶結構:

計算機程式的思維邏輯 (75) - 併發容器 - 基於SkipList的Map和Set
最下面一層,就是最基本的單向連結串列,這個連結串列是有序的。雖然是有序的,但我們知道,與陣列不同,連結串列不能根據索引直接定位,不能進行二分查詢。

為了快速查詢,跳錶有多層索引結構,這個例子中有兩層,第一層有5個節點,第二層有2個節點。高層的索引節點一定同時是低層的索引節點,比如9和21。

高層的索引節點少,低層的多,統計概率上,第一層索引節點是實際元素數的1/2,第二層是第一層的1/2,逐層減半,但這不是絕對的,有隨機性,只是大概如此。

對於每個索引節點,有兩個指標,一個向右,指向下一個同層的索引節點,另一個向下,指向下一層的索引節點或基本連結串列節點。

有了這個結構,就可以實現類似二分查詢了,查詢元素總是從最高層開始,將待查值與下一個索引節點的值進行比較,如果大於索引節點,就向右移動,繼續比較,如果小於,則向下移動到下一層進行比較。

下圖兩條線展示了查詢值19和8的過程:

計算機程式的思維邏輯 (75) - 併發容器 - 基於SkipList的Map和Set
對於19,查詢過程是:

  1. 與9相比,大於9
  2. 向右與21相比,小於21
  3. 向下與17相比,大於17
  4. 向右與21相比,小於21
  5. 向下與19相比,找到

對於8,查詢過程是:

  1. 與9相比,小於9
  2. 向下與6相比,大於6
  3. 向右與9相比,小於9
  4. 向下與7相比,大於7
  5. 向右與9相比,小於9,不能再向下,沒找到

這個結構是有序的,查詢的效能與二叉樹類似,複雜度是O(log(N)),不過,這個結構是如何構建起來的呢?

與二叉樹類似,這個結構是在更新過程中進行保持的,儲存元素的基本思路是:

  1. 先儲存到基本連結串列,找到待插入的位置,找到位置後,先插入基本連結串列
  2. 更新索引層。

對於索引更新,隨機計算一個數,表示為該元素最高建幾層索引,一層的概率為1/2,二層為1/4,三層為1/8,依次類推。然後從最高層到最低層,在每一層,為該元素建立索引節點,建的過程也是先查詢位置,再插入。

對於刪除元素,ConcurrentSkipListMap不是一下子真的進行刪除,為了避免併發衝突,有一個複雜的標記過程,在內部遍歷元素的過程中會真正刪除。

以上我們只是介紹了基本思路,為了實現併發安全、高效、無鎖非阻塞,ConcurrentSkipListMap的實現非常複雜,具體我們就不探討了,感興趣的讀者可以參考其原始碼,其中提到了多篇學術論文,論文中描述了它參考的一些演算法。

對於常見的操作,如get/put/remove/containsKey,ConcurrentSkipListMap的複雜度都是O(log(N))。

上面介紹的SkipList結構是為了便於併發操作的,如果不需要併發,可以使用另一種更為高效的結構,資料和所有層的索引放到一個節點中,如下圖所示:

計算機程式的思維邏輯 (75) - 併發容器 - 基於SkipList的Map和Set

對於一個元素,只有一個節點,只是每個節點的索引個數可能不同,在新建一個節點時,使用隨機演算法決定它的索引個數,平均而言,1/2的元素有兩個索引,1/4的元素有三個索引,依次類推。

小結

本節簡要介紹了ConcurrentSkipListMap和ConcurrentSkipListSet,它們基於跳錶實現,有序,無鎖非阻塞,完全並行,主要操作複雜度為O(log(N))。

下一節,我們來探討併發佇列。

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


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

計算機程式的思維邏輯 (75) - 併發容器 - 基於SkipList的Map和Set

相關文章