我們知道 HashMap 是一種鍵值對形式的數據存儲容器,但是它有一個缺點是,元素內部無序。由于它內部根據鍵的 hash 值取模表容量來得到元素的存儲位置,所以整體上說 HashMap 是無序的一種容器。當然,jdk 中也為我們提供了基于紅黑樹的存儲的 TreeMap 容器,它的內部元素是有序的,但是由于它內部通過紅黑結點的各種變換來維持二叉搜索樹的平衡,相對復雜,并且在并發環境下礙于 rebalance 操作,性能會受到一定的影響。
跳表(SkipList)是一種隨機化的數據結構,通過“空間來換取時間”的一個算法,建立多級索引,實現以二分查找遍歷一個有序鏈表。時間復雜度等同于紅黑樹,O(log n)。但實現卻遠遠比紅黑樹要簡單,本篇我們主要從以下幾個方面來對這種并發版本的數據結構進行學習:
- 跳躍表的數據結構介紹
- ConcurrentSkipListMap 的前導知識預備
- 基本的成員屬性介紹
- put 方法并發添加
- remove 方法的并發刪除
- get 方法獲取指定結點的 value
- 其它的一些方法的簡單描述
一、跳躍表的數據結構介紹
跳躍表具有以下幾個必備的性質:
- 最底層包含所有節點的一個有序的鏈表
- 每一層都是一個有序的鏈表
- 每個節點都有兩個指針,一個指向右側節點(沒有則為空),一個指向下層節點(沒有則為空)
- 必備一個頭節點指向最高層的第一個節點,通過它可以遍歷整張表
當我們查找一個元素的時候就是這樣的:
查找的過程有點像我們的二分查找,不過這里我們是通過為鏈表建立多級索引,以空間換時間來實現二分查找。所以,跳表的查詢操作的時間復雜度為 O(logN)。
接著我們看看跳表的插入操作:
首先,跳表的插入必然會在底層增加一個節點,但是往上的層次是否需要增加節點則完全是隨機的,SkipList 通過概率保證整張表的節點分布均勻,它不像紅黑樹是通過人為的 rebalance 操作來保證二叉樹的平衡性。(數學對于計算機還是很重要的)。
通過概率算法得到新插入節點的一個 level 值,如果小于當前表的最大 level,從最底層到 level 層都添加一個該節點。例如:
如圖,首先 119 節點會被添加到最底層鏈表的合適位置,然后通過概率算法得到 level 為 2,于是 1---level 層中的每一層都添加了 119 節點。
如果概率算法得到的 level 大于當前表的最大 level 值的話,那么將會新增一個 level,并且將新節點添加到該 level 上。
跳表的刪除操作其實就是一個查找加刪除節點的操作
好了,有關跳表這種數據結構的基本理論知識已經簡單的介紹了,下面我們看 jdk 中對該數據結構的基本實現情況,并了解它的并發版本是如何實現的。
二、ConcurrentSkipListMap 的前導知識預備
在實際分析 put 方法之前,有一些預備的知識我們需要先有個大致的了解,否則在實際分析源碼的時候會感覺吃力些。
首先是刪除操作,在我們上述的跳表數據結構中談及的刪除操作主要是定位待刪結點+刪除該結點的一個復合操作。而在我們的并發跳表中,刪除操作相對復雜點,需要分為以下三個步驟:
- 找到待刪結點并將其 value 屬性值由 notnull 置為 null,整個過程是基于 CAS 無鎖式算法的
- 向待刪結點的 next 位置新增一個 marker 標記結點,整個過程也是基于 CAS 無鎖式算法
- CAS 式刪除具體的結點,實際上也就是跳過該待刪結點,讓待刪結點的前驅節點直接越過本身指向待刪結點的后繼結點即可
例如我們有以下三個結點,n 為待刪除的結點。
+------+ +------+ +------+
... | b |------>| n |----->| f | ...
+------+ +------+ +------+
第一步是找到 n ,然后 CAS 該結點的 value 值為 null。如果該步驟失敗了,那么 ConcurrentSkipListMap 會通過循環再次嘗試 CAS 將 n 的 value 屬性賦值為 null。
第二步是建立在第一步成功的前提下的,n 的當前 value 屬性的值為 null,ConcurrentSkipListMap 試圖在 n 的后面增加一個空的 node 結點(marker)以分散下一步的并發沖突性。
+------+ +------+ +------+ +------+
... | b |------>| n |----->|marker|---->| f | ...
+------+ +------+ +------+ +------+
第三步,斷鏈操作。如果 marker 添加失敗,將不會有第三步,直接回重新回到第一步。如果成功添加,那么將試圖斷開 b 到 n 的鏈接,直接繞過 n,讓 b 的 next 指向 f。那么,這個 n 結點將作為內存中的一個游離結點,最終被 GC 掉。斷開失敗的話,也將回到第一步。
+------+ +------+
... | b |----------------------->| f | ...
+------+ +------+
主要還是有關刪除這方面的預備知識,其它的信息點我們將從實際方法的源碼中再進行分析。
三、基本的成員屬性介紹
static final class Node<K,V> {
final K key;
volatile Object value;
volatile Node<K,V> next;
Node(K key, Object value, Node<K,V> next) {
this.key = key;
this.value = value;
this.next = next;
}
//省略其它的一些基于當前結點的 CAS 方法
}
這是 node 結點類型的定義,是最基本的數據存儲單元。
static class Index<K,V> {
final Node<K,V> node;
final Index<K,V> down;
volatile Index<K,V> right;
Index(Node<K,V> node, Index<K,V> down, Index<K,V> right) {
this.node = node;
this.down = down;
this.right = right;
}
//省略其它的一些基于當前結點的 CAS 方法
}
Index 結點封裝了 node 結點,作為跳表的最基本組成單元。
static final class HeadIndex<K,V> extends Index<K,V> {
final int level;
HeadIndex(Node<K,V> node, Index<K,V> down, Index<K,V> right, int level) {
super(node, down, right);
this.level = level;
}
}
封裝了 Index 結點,作為每層的頭結點,level 屬性用于標識當前層次的序號。
/**
* The topmost head index of the skiplist.
*/
private transient volatile HeadIndex<K,V> head;
整個跳表的頭結點,通過它可以遍歷訪問整張跳表。
//比較器,用于比較兩個元素的鍵值大小,如果沒有顯式傳入則默認為自然排序
final Comparator<? super K> comparator;
/**
* Special value used to identify base-level header
* 特殊的值,用于初始化跳表
*/
private static final Object BASE_HEADER = new Object();
緊接著,我們看看它的幾個構造器:
//未傳入比較器,則為默認值
public ConcurrentSkipListMap() {
this.comparator = null;
initialize();
}
public ConcurrentSkipListMap(Comparator<? super K> comparator) {
this.comparator = comparator;
initialize();
}
//所有的構造器都會調用這個初始化的方法
private void initialize() {
keySet = null;
entrySet = null;
values = null;
descendingMap = null;
head = new HeadIndex<K,V>(new Node<K,V>(null, BASE_HEADER, null),null, null, 1);
}
這個初始化方法主要完成的是對整張跳表的一個初始化操作,head 頭指針指向這個并沒有什么實際意義的頭結點。
基本的成員屬性就簡單介紹到這,重點還是那三個內部類,都分別代表了什么樣的結點類型,都使用在何種場景下,務必清晰。
四、put 并發添加的內部實現
//基本的 put 方法,向跳表中添加一個節點
public V put(K key, V value) {
if (value == null)
throw new NullPointerException();
return doPut(key, value, false);
}
put 方法的內部調用的是 doPut 方法來實現添加元素的,但是由于 doPut 方法的方法體很長,我們分幾個部分進行分析。
//第一部分
private V doPut(K key, V value, boolean onlyIfAbsent) {
Node<K,V> z;
//邊界值判斷,空的 key 自然是不允許插入的
if (key == null)
throw new NullPointerException();
//拿到比較器的引用
Comparator<? super K> cmp = comparator;
outer: for (;;) {
//根據 key,找到待插入的位置
//b 叫做前驅節點,將來作為新加入結點的前驅節點
//n 叫做后繼結點,將來作為新加入結點的后繼結點
//也就是說,新節點將插入在 b 和 n 之間
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
//如果 n 為 null,那么說明 b 是鏈表的最尾端的結點,這種情況比較簡單,直接構建新節點插入即可
//否則走下面的判斷體
if (n != null) {
Object v; int c;
Node<K,V> f = n.next;
//如果 n 不再是 b 的后繼結點了,說明有其他線程向 b 后面添加了新元素
//那么我們直接退出內循環,重新計算新節點將要插入的位置
if (n != b.next)
break;
//value =0 說明 n 已經被標識位待刪除,其他線程正在進行刪除操作
//調用 helpDelete 幫助刪除,并退出內層循環重新計算待插入位置
if ((v = n.value) == null) {
n.helpDelete(b, f);
break;
}
//b 已經被標記為待刪除,前途結點 b 都丟了,可不得重新計算待插入位置嗎
if (b.value == null || v == n)
break;
//如果新節點的 key 大于 n 的 key 說明找到的前驅節點有誤,按序往后挪一個位置即可
//回到內層循環重新試圖插入
if ((c = cpr(cmp, key, n.key)) > 0) {
b = n;
n = f;
continue;
}
//新節點的 key 等于 n 的 key,這是一次 update 操作,CAS 更新即可
//如果更新失敗,重新進循環再來一次
if (c == 0) {
if (onlyIfAbsent || n.casValue(v, value)) {
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
break;
}
}
//無論遇到何種問題,到這一步說明待插位置已經確定
z = new Node<K,V>(key, value, n);
if (!b.casNext(n, z))
break;
//如果成功了,退出最外層循環,完成了底層的插入工作
break outer;
}
}
以上這一部分主要完成了向底層鏈表插入一個節點,至于其中具體的怎么找前驅節點的方法稍后介紹。但這其實只不過才完成一小半的工作,就像紅黑樹在插入后需要 rebalance 一樣,我們的跳表需要根據概率算法保證節點分布穩定,它的調節措施相對于紅黑樹來說就簡單多了,通過往上層索引層添加相關引用即可,以空間換時間。具體的我們來看:
//第二部分
//獲取一個線程無關的隨機數,占四個字節,32 個比特位
int rnd = ThreadLocalRandom.nextSecondarySeed();
//和 1000 0000 0000 0000 0000 0000 0000 0001 與
//如果等于 0,說明這個隨機數最高位和最低位都為 0,這種概率很大
//如果不等于 0,那么將僅僅把新節點插入到最底層的鏈表中即可,不會往上層遞歸
if ((rnd & 0x80000001) == 0) {
int level = 1, max;
//用低位連續為 1 的個數作為 level 的值,也是一種概率策略
while (((rnd >>>= 1) & 1) != 0)
++level;
Index<K,V> idx = null;
HeadIndex<K,V> h = head;
//如果概率算得的 level 在當前跳表 level 范圍內
//構建一個從 1 到 level 的縱列 index 結點引用
if (level <= (max = h.level)) {
for (int i = 1; i <= level; ++i)
idx = new Index<K,V>(z, idx, null);
}
//否則需要新增一個 level 層
else {
level = max + 1;
@SuppressWarnings("unchecked")
Index<K,V>[] idxs =(Index<K,V>[])new Index<?,?>[level+1];
for (int i = 1; i <= level; ++i)
idxs[i] = idx = new Index<K,V>(z, idx, null);
for (;;) {
h = head;
int oldLevel = h.level;
//level 肯定是比 oldLevel 大一的,如果小了說明其他線程更新過表了
if (level <= oldLevel)
break;
HeadIndex<K,V> newh = h;
Node<K,V> oldbase = h.node;
//正常情況下,循環只會執行一次,如果由于其他線程的并發操作導致 oldLevel 的值不穩定,那么會執行多次循環體
for (int j = oldLevel+1; j <= level; ++j)
newh = new HeadIndex<K,V>(oldbase, newh, idxs[j], j);
//更新頭指針
if (casHead(h, newh)) {
h = newh;
idx = idxs[level = oldLevel];
break;
}
}
}
這一部分的代碼主要完成的是根據 level 的值,確認是否需要增加一層索引,如果不需要則構建好底層到 level 層的 index 結點的縱向引用。如果需要,則新創建一層索引,完成 head 結點的指針轉移,并構建好縱向的 index 結點引用。
//第三部分
if ((rnd & 0x80000001) == 0){
//省略第二部分的代碼段
//第三部分的代碼是緊接著第二部分代碼段后面的
splice: for (int insertionLevel = level;;) {
int j = h.level;
for (Index<K,V> q = h, r = q.right, t = idx;;) {
//其他線程并發操作導致頭結點被刪除,直接退出外層循環
//這種情況發生的概率很小,除非并發量實在太大
if (q == null || t == null)
break splice;
if (r != null) {
Node<K,V> n = r.node;
int c = cpr(cmp, key, n.key);
//如果 n 正在被其他線程刪除,那么調用 unlink 去刪除它
if (n.value == null) {
if (!q.unlink(r))
break;
//重新獲取 q 的右結點,再次進入循環
r = q.right;
continue;
}
//c > 0 說明前驅結點定位有誤,重新進入
if (c > 0) {
q = r;
r = r.right;
continue;
}
}
if (j == insertionLevel) {
//嘗試著將 t 插在 q 和 r 之間,如果失敗了,退出內循環重試
if (!q.link(r, t))
break; // restart
//如果插入完成后,t 結點被刪除了,那么結束插入操作
if (t.node.value == null) {
findNode(key);
break splice;
}
// insertionLevel-- 處理底層鏈接
if (--insertionLevel == 0)
break splice;
}
//--j,j 應該與 insertionLevel 同步,它代表著我們創建的那個縱向的結點數組的索引
//并完成層次下移操作
if (--j >= insertionLevel && j < level)
t = t.down;
//至此,新節點在當前層次的前后引用關系已經被鏈接完成,現在處理下一層
q = q.down;
r = q.right;
}
}
}
return null;
}
我們根據概率算法得到了一個 level 值,并且通過第二步創建了 level 個新節點并構成了一個縱向的引用關聯,但是這些縱向的結點并沒有鏈接到每層中。而我們的第三部分代碼就是完成的這個工作,將我們的新節點在每個索引層都構建好前后的鏈接關系。下面用三張圖描述著三個部分所完成的主要工作。
初始化的跳表如下:
第一部分,新增一個結點到最底層的鏈表上。
第二部分,假設概率得出一個 level 值為 10,那么根據跳表的算法描述需要新建一層索引層。
第三步,鏈接各個索引層次上的新節點。
這樣就完成了新增結點到跳表中的全部過程,大體上已如上圖描述,至于 ConcurrentSkipListMap 中關于并發處理的細節之處,圖中無法展示,大家可據此重新感受下源碼的實現過程。下面我們著重描述下整個 doPut 方法中還涉及的其他幾個方法的具體實現。
首先是 findPredecessor 方法,我們說該方法將根據給定的 key,為我們返回最合適的前驅節點。
private Node<K,V> findPredecessor(Object key, Comparator<? super K> cmp) {
if (key == null)
throw new NullPointerException();
for (;;) {
for (Index<K,V> q = head, r = q.right, d;;) {
//r 為空說明 head 后面并沒有其他節點了
if (r != null) {
Node<K,V> n = r.node;
// r 節點處于待刪除狀態,那么嘗試 unlink 它,失敗了將重新進入循環再此嘗試
//否則重新獲取 q 的右結點并重新進入循環查找前驅節點
if (n.value == null) {
if (!q.unlink(r))
break; // restart
r = q.right; // reread r
continue;
}
//大于零說明當前位置上的 q 還不是我們要的前驅節點,繼續往后找
if (cpr(cmp, key, k) > 0) {
q = r;
r = r.right;
continue;
}
}
//如果當前的 level 結束了或者 cpr(cmp, key, k) <= 0 會達到此位置
//往低層遞歸,如果沒有低層了,那么當前的 q 就是最合適的前驅節點
//整個循環只有這一個出口,無論如何最終都會從此處結束方法
if ((d = q.down) == null)
return q.node;
//否則向低層遞歸并重置 q 和 r
q = d;
r = d.right;
}
}
}
最后總結下 findPredecessor 方法的大體邏輯,首先程序會從 head 節點開始在當前的索引層上尋找最后一個比給定 key 小的結點,它就是我們需要的前驅節點(q),我們只需要返回它即可。
其次我們看看 helpDelete 方法,當檢測到某個結點的 value 屬性值為 null 的時候,一般都會調用這個方法來刪除該結點。
/*
一般的調用形式如下:
n.helpDelete(b, f);
*/
void helpDelete(Node<K,V> b, Node<K,V> f) {
if (f == next && this == b.next) {
if (f == null || f.value != f)
casNext(f, new Node<K,V>(f));
else
b.casNext(this, f.next);
}
}
該方法是 Node 結點的內部實例方法,邏輯相對簡單,此處不再贅述。通過該方法可以完成將 b.next 指向 f,完成對 n 結點的刪除。
至此,有關 put 方法的源碼分析就簡單到這,大部分的代碼還是用于實現跳表這種數據結構的構建和插入,關于并發的處理,你會發現基本都是雙層 for 循環+ CAS 無鎖式更新,如果遇到競爭失利將退出里層循環重新進行嘗試,否則成功的話就會直接 return 或者退出外層循環并結束 CAS 操作。下面我們看刪除操作是如何實現的。
五、remove 并發刪除操作的內部實現
remove 方法的部分內容我們在介紹相關預備知識中已經提及過,此處的理解想必會容易些。
public V remove(Object key) {
return doRemove(key, null);
}
//代碼比較多,建議讀者結合自己的 jdk 源碼共同來分析
final V doRemove(Object key, Object value) {
if (key == null)
throw new NullPointerException();
Comparator<? super K> cmp = comparator;
outer: for (;;) {
//找到 key 的前驅節點
//因為刪除不單單是根據 key 找到對應的結點,然后賦 null 就完事的,還要負責鏈接該結點前后的關聯
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
Object v; int c;
//目前 n 基本上就是我們要刪除的結點,它為 null,那自然不用繼續了,已經被刪除了
if (n == null)
break outer;
Node<K,V> f = n.next;
//再次確認 n 還是不是 b 的后繼結點,如果不是將退出里層循環重新進入
if (n != b.next)
break;
//如果有人正在刪除 n,那么幫助它刪除
if ((v = n.value) == null) {
n.helpDelete(b, f);
break;
}
//b 被刪除了,重新定位前驅節點
if (b.value == null || v == n)
break;
//正常情況下,key 應該等于 n.key
//key 大于 n.key 說明我們要找的結點可能在 n 的后面,往后遞歸即可
//key 小于 n.key 說明 key 所代表的結點根本不存在
if ((c = cpr(cmp, key, n.key)) < 0)
break outer;
if (c > 0) {
b = n;
n = f;
continue;
}
//如果刪除是根據鍵和值兩個參數來刪除的話,value 是不為 null 的
//這種情況下,如果 n 的 value 屬性不等于我們傳入的 value ,那么是不進行刪除的
if (value != null && !value.equals(v))
break outer;
//下面三個步驟才是整個刪除操作的核心,大致的邏輯我們也在上文提及過了,此處想必會容易理解些
//第一步,嘗試將待刪結點的 value 屬性賦值 null,失敗將退出重試
if (!n.casValue(v, null))
break;
//第二步和第三步如果有一步由于競爭失敗,將調用 findNode 方法根據我們第一步的成果,也就是刪除所有 value 為 null 的結點
if (!n.appendMarker(f) || !b.casNext(n, f))
findNode(key);
//否則說明三個步驟都成功完成了
else {
findPredecessor(key, cmp);
//判斷此次刪除后是否導致某一索引層沒有其他節點了,并適情況刪除該層索引
if (head.right == null)
tryReduceLevel();
}
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
}
return null;
}
remove 方法其實從整體上來看,首先會有一堆的判斷,根據給定的 key 和 value 會判斷是否存在與 key 對應的一個節點,也會判斷和待刪結點相關的前后結點是否正在被刪除,并適情況幫助刪除。其次才是刪除的三大步驟,核心步驟還是將待刪結點的 value 屬性賦 null 以標記該結點無用了,至于這個 marker 也是為了分散并發沖突的,最后通過 casNext 完成結點的刪除。
六、get 方法獲取指定結點的 value
算上本小節將要介紹的 "查" 方法,我們就完成了對并發跳表 "增刪改查" 的全部分析。 相對于“增”來說,其他的三種操作還是相對容易的,尤其是本小節的“查”操作,下面我們看看它的內部實現:
public V get(Object key) {
return doGet(key);
}
private V doGet(Object key) {
if (key == null)
throw new NullPointerException();
Comparator<? super K> cmp = comparator;
//依然是雙層循環來處理并發
outer: for (;;) {
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
Object v; int c;
//以下的一些判斷的作用已經描述了多次,此處不再贅述了
if (n == null)
break outer;
Node<K,V> f = n.next;
if (n != b.next)
break;
if ((v = n.value) == null) {
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n)
break;
//c = 0 說明 n 就是我們要的結點
if ((c = cpr(cmp, key, n.key)) == 0) {
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
//c < 0 說明不存在這個 key 所對應的結點
if (c < 0)
break outer;
b = n;
n = f;
}
}
return null;
}
doGet 方法的實現相對還是比較簡單的,所以并沒有給出太多的注釋,主要還是由于大量的并發判斷的代碼都是一樣的,大多都已經在 doPut 方法中給予了詳細的注釋了。
七、其它的一些方法的簡單描述
//是否包含指定 key 的結點
public boolean containsKey(Object key) {
return doGet(key) != null;
}
//根據 key 返回該 key 所代表的結點的 value 值,不存在該結點則返回默認的 defaultValue
public V getOrDefault(Object key, V defaultValue) {
V v;
return (v = doGet(key)) == null ? defaultValue : v;
}
//返回跳表的實際存儲元素個數,采取遍歷來進行統計
public int size() {
long count = 0;
for (Node<K,V> n = findFirst(); n != null; n = n.next) {
if (n.getValidValue() != null)
++count;
}
return (count >= Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int) count;
}
//返回所有鍵的集
public NavigableSet<K> keySet() {
KeySet<K> ks = keySet;
return (ks != null) ? ks : (keySet = new KeySet<K>(this));
}
//返回所有值的集
public Collection<V> values() {
Values<V> vs = values;
return (vs != null) ? vs : (values = new Values<V>(this));
}
這里需要說明一點的是,雖然返回來的是鍵或者值的一個集合,但是無論你是通過這個集合獲取鍵或者值,還是刪除集合中的鍵或者值,都會直接映射到當前跳表實例中。原因是這個集合中沒有一個方法是自己實現的,都是調用傳入的跳表實例的內部方法,具體的大家查看源碼即可知曉,此處不再貼出源碼。
至此,有關 SkipList 這種跳表數據結構及其在 jdk 中的實現,以及它的并發版本 ConcurrentSkipListMap 的實現,我們都已經簡單的分析完了,有理解錯誤之處,望指出,相互學習!