併發程式設計從零開始(九)-ConcurrentSkipListMap&Set
CAS知識點補充:
我們都知道在使用 CAS 也就是使用 compareAndSet(current,next)方法進行無鎖自加或者更換棧的表頭之類的問題時會出現ABA問題。
Java中使用 AtomicStampedReference 來解決 CAS 中的ABA問題,它不再像一般原子類中的 compareAndSet 方法一樣只比較記憶體中的值也當前值是否相等,而且先比較引用是否相等,然後比較值是否相等,此外還會比對版本戳是否和預期的值相等,這樣就避免了ABA問題。
常用API:
//構造方法, 傳入引用和戳
public AtomicStampedReference(V initialRef, int initialStamp)
//返回引用
public V getReference()
//返回版本戳
public int getStamp()
//如果當前引用 等於 預期值並且 當前版本戳等於預期版本戳, 將更新新的引用和新的版本戳到記憶體
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp)
//使用方法和compareAndSet相同,但是weakCompareAndSet有可能不是原子的去更新值,這取決於虛擬機器的實現。
public boolean weekCompareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp)
//如果當前引用 等於 預期引用, 將更新新的版本戳到記憶體
public boolean attemptStamp(V expectedReference, int newStamp)
//設定當前引用的新引用和版本戳
public void set(V newReference, int newStamp)
5.6 ConcurrentSkipListMap/Set
ConcurrentHashMap 是一種 key 無序的 HashMap,ConcurrentSkipListMap則是 key 有序的,實現了NavigableMap介面,此介面又繼承了SortedMap介面。
5.6.1 ConcurrentSkipListMap
1.為什麼要使用kipList實現Map?
在Java的util包中,有一個非執行緒安全的HashMap,也就是TreeMap,是key有序的,基於紅黑樹實現。
而在Concurrent包中,提供的key有序的HashMap,也就是ConcurrentSkipListMap,是基於SkipList(跳查表)來實現的。這裡為什麼不用紅黑樹,而用跳查表來實現呢?
因為目前計算機領域還未找到一種高效的、作用在樹上的、無鎖的、增加和刪除節點的辦法。
那為什麼SkipList可以無鎖地實現節點的增加、刪除呢?這要從無鎖連結串列的實現說起。
2. 無鎖連結串列
在前面講解AQS時,曾反覆用到無鎖佇列,其實現也是連結串列。究竟二者的區別在哪呢?
前面講的無鎖佇列、棧,都是隻在隊頭、隊尾進行CAS操作,通常不會有問題。如果在連結串列的中間進行插入或刪除操作,按照通常的CAS做法,就會出現問題!
關於這個問題,Doug Lea的論文中有清晰的論述,此處引用如下:
操作1:在節點10後面插入節點20。如下圖所示,首先把節點20的next指標指向節點30,然後對節點10的next指標執行CAS操作,使其指向節點20即可。
操作2:刪除節點10。如下圖所示,只需把頭節點的next指標,進行CAS操作到節點30即可。
但是,如果兩個執行緒同時操作,一個刪除節點10,一個要在節點10後面插入節點20。並且這兩個操作都各自是CAS的,此時就會出現問題。如下圖所示,刪除節點10,會同時把新插入的節點20也刪除掉!這個問題超出了CAS的解決範圍。
為什麼會出現這個問題呢?
究其原因:在刪除節點10的時候,實際受到操作的是節點10的前驅,也就是頭節點。節點10本身沒 有任何變化。故而,再往節點10後插入節點20的執行緒,並不知道節點10已經被刪除了!
針對這個問題,在論文中提出瞭如下的解決辦法,如下圖所示,把節點 10 的刪除分為兩2步:
第一步,把節點10的next指標,mark成刪除,即軟刪除;
第二步,找機會,物理刪除。
做標記之後,當執行緒再往節點10後面插入節點20的時候,便可以先進行判斷,節點10是否已經被刪 除,從而避免在一個刪除的節點10後面插入節點20。這個解決方法有一個關鍵點:“把節點10的next指標指向節點20(插入操作)”和“判斷節點10本身是否已經刪除(判斷操作),必須是原子的,必須在1 個CAS操作裡面完成!
具體的實現有兩個辦法:
辦法一:AtomicMarkableReference
保證每個 next 是 AtomicMarkableReference 型別。但這個辦法不夠高效,Doug Lea 在ConcurrentSkipListMap的實現中用了另一種辦法(即Mark節點方法)。
辦法2:Mark節點
我們的目的是標記節點10已經刪除,也就是標記它的next欄位。那麼可以新造一個marker節點,使節點10的next指標指向該Marker節點。這樣,當向節點10的後面插入節點20的時候,就可以在插入的同時判斷節點10的next指標是否指向了一個Marker節點,這兩個操作可以在一個CAS操作裡面完成。
3. 跳查表
解決了無鎖連結串列的插入或刪除問題,也就解決了跳查表的一個關鍵問題。因為跳查表就是多層連結串列疊起來的。
下面先看一下跳查表的資料結構(下面所用程式碼都引用自JDK 7,JDK 8中的程式碼略有差異,但不影響下面的原理分析)。
整體結構:
Node:跳查表底層節點型別。所有的<K, V>對都是由這個單向連結串列串起來的。
上層index:
node屬性不儲存實際資料,指向Node節點。
down屬性:每個Index節點,必須有一個指標,指向其下一個Level對應的節點。
right屬性:Index也組成單向連結串列。
整個ConcurrentSkipListMap就只需要記錄頂層的head節點即可:
下面詳細分析如何從跳查表上查詢、插入和刪除元素:
1. put實現分析
在底層,節點按照從小到大的順序排列,上面的index層間隔地串在一起,因為從小到大排列。查詢的時候,從頂層index開始,自左往右、自上往下,形成圖示的遍歷曲線。假設要查詢的元素是32,遍歷過程如下:
先遍歷第2層Index,發現在21的後面;
從21下降到第1層Index,從21往後遍歷,發現在21和35之間;
從21下降到底層,從21往後遍歷,最終發現在29和35之間。
在整個的查詢過程中,範圍不斷縮小,最終定位到底層的兩個元素之間。
關於上面的put(...)方法,有一個關鍵點需要說明:在通過findPredecessor找到了待插入的元素在[b,n]之間之後,並不能馬上插入。因為其他執行緒也在操作這個連結串列,b、n都有可能被刪除,所以在插入之前執行了一系列的檢查邏輯,而這也正是無鎖連結串列的複雜之處。
2. remove(...)分析
上面的刪除方法和插入方法的邏輯非常類似,因為無論是插入,還是刪除,都要先找到元素的前驅,也就是定位到元素所在的區間[b,n]。在定位之後,執行下面幾個步驟:
-
如果發現b、n已經被刪除了,則執行對應的刪除清理邏輯;
-
否則,如果沒有找到待刪除的(k, v),返回null;
-
如果找到了待刪除的元素,也就是節點n,則把n的value置為null,同時在n的後面加上Marker節點,同時檢查是否需要降低Index的層次。
3. get分析
無論是插入、刪除,還是查詢,都有相似的邏輯,都需要先定位到元素位置[b,n],然後判斷b、n是否已經被刪除,如果是,則需要執行相應的刪除清理邏輯。這也正是無鎖連結串列複雜的地方。
5.6.2 ConcurrentSkipListSet
如下面程式碼所示,ConcurrentSkipListSet只是對ConcurrentSkipListMap的簡單封裝,此處不再進一步展開敘述。