跳躍表原理以及實現(含ConcurrentSkipListMap和zset底層實現原理分析)

前言

我們常用來加快查找速度的兩類數據結構分別是哈希表和平衡樹,哈希表查找時間複雜度是O(1),而各類平衡樹(包括紅黑樹)的查找時間複雜度是O(logn)。哈希表雖然查找速度快,但它不是有序的,無法進行範圍查詢;平衡樹雖然也很香,但它每一次的插入和刪除都可能導致全局調整,耗時多。而本篇博文介紹的跳躍表查找的效率與平衡樹差不多,時間複雜度都是O(logn),但插入或者刪除值時,只是局部調整,過程簡單速度快,再者跳躍表的實現比平衡樹簡單的不是一星半點。

跳躍表(Skip list)

跳躍表是美國計算機科學家William Pugh在1989年提出來的一種用於加快查詢速度的數據結構,發明者提出跳躍表在很多場合可以用來替代平衡樹,因爲它的查找時間與平衡樹近似且它插入和刪除數據更簡單快速而且跳躍表的實現也更簡單。

跳躍表思想簡述

跳躍表是一種隨機化層次化鏈表結構,它是在鏈表的基礎上改進而來的,雖然跳躍表的具體實現有很多種,但它的主要思想都是在有序的鏈表上(原始鏈表),隨機提取一些關鍵結點到上一層形成一個新的有序鏈表作爲索引鏈表,當然爲了提高查找效率,索引鏈表往往不會只是一層,而是在索引鏈表中再提取一些關鍵結點到上一層形成更高一層的有序索引鏈表,不斷的提取以此形成多層鏈表(Redis底層使用的跳錶最多允許32層)。例如下圖中的跳躍表的形成過程就是在原始鏈表的基礎上提取1、4、7、9、13、17這6個結點到上一層並用指針連接起來形成一級有序索引鏈表,又在一級有序索引鏈表的基礎上提取3個結點形成了二級索引鏈表,不同層鏈表中的相同值結點通過向下指針連接。(當然跳躍表真正的實現過程並不是如此,而是動態的隨着結點的插入而形成,這裏講解的一層一層的提取鏈表只是爲了便於讀者理解跳躍表的主要思想,真正的實現會在跳躍表實現原理詳述 這部分講解)

查找過程

查找過程是從最頂層索引鏈表開始查找,從左往右開始查找,當結點A的下一個結點值大於等於待查找值,則通過結點A的指針到其指向的下一層鏈表的結點B(A與B的值一樣),從結點B開始從左向右查找,當結點C的下一個結點值大於等於待查找值,則繼續往下走,一直走到原始鏈表中返回待查找值結點或者原始鏈表中比待查找值略小的結點。如下圖中,我要查找跳躍表中是否有值爲18的結點,先在頂層鏈表(圖中第二層索引鏈表)從左往右進行查找,結點13雖然小於18,但之後就沒有結點,則通過結點13的指針向下到下一層鏈表–一級索引鏈表。然後從一級索引鏈表的結點13繼續向右查找,結點17之後就沒有結點,所以從結點17到下一層鏈表,此時就到了原始鏈表了,繼續向右查找,最終找到結點18。

查找值18的整個過程經歷了7個結點,若直接在原始鏈表中查找值18,則需要查找12個結點。不難發現跳躍表加快了查找速度,而跳躍表加快查找速度的關鍵就在於跳躍,通過先在高層索引鏈表進行查找來跳躍一些中間結點,快速定位了待查找值結點在原始鏈表的範圍,例如在下圖跳躍表查找值18的過程中,在第二級索引鏈表經過三個結點就確定了值18的大概範圍,跳躍了6箇中間結點。當數據夠多、隨機產生的鏈表層數夠高,跳躍表的優越性就更能體現出來,達到O(logn)的查找時間複雜度級別。

跳躍表的隨機化思想

那麼我們如何決定哪些結點是需要被提取到上一層鏈表的關鍵結點,以及它會被提取到幾級索引鏈表上?這裏採用的是隨機化的思想,我們取一個概率值p(比如說p值爲0.5)。每當插入一個新的結點時,此時新結點的隨機層數默認爲1(原始鏈表默認爲第一層鏈表),我們需要確定該結點是否被提取以及被提取到幾級索引鏈表,則利用隨機函數產生一個取值範圍在0~1的值,若值<=p則隨機層數+1,然後繼續使用隨機函數產生值,再進行判斷;一直到隨機產生的值>p則停止。類似於拋硬幣的思想,當拋的結果是正面,則隨機層數+1然後繼續拋,一直拋到結果爲反面才停止,以此決定結點的層數。其實不難發現結點想要達到的層數越高,其實概率越低,比如p值爲0.5,則該結點被提取到第一級索引(隨機層數爲2)的概率爲0.5,隨機層數爲3的概率就是0.5*0.5,以此類推,達到隨機層數爲11的概率就是0.5的10次方。因此上層鏈表結點稀疏,跳躍性大,而下層鏈表結點密集,跳躍性小,而查找時是從最頂層鏈表向左向下進行查找,正是以此原理才能跳躍結點提高查找效率。在這裏插入圖片描述
(圖片來自百度圖片)

跳躍表實現原理詳述

上面只是簡單的講述跳躍表的思想,讓讀者有個初步瞭解。而這個部分則詳細的介紹跳躍表的不同的具體實現,這裏不同實現具體體現在結點中指針的數量、以及索引結點是與原始鏈表的結點分離,還是在原始鏈表的結點中創建索引指針數組?要實現跳躍表一般需要2個指針,分別是指向右邊結點和指向下層鏈表結點,以此形成多層鏈表。但有的跳躍表爲了實現更方便,而使用了4個指針,在向右、向下到的基礎上加上了向左、向上的指針,形成了水平和垂直方向的雙向鏈表。Java的ConcurrentSkipListMap容器的底層結構是跳躍表,它使用的跳躍表使用的是兩個指針,並且索引結點和原始鏈表數據結點分離;而Redis的有序列表 zset 的底層實現結構所使用的跳躍表使用的是兩個方向的指針,它使用的是索引結點和原始鏈表數據結點聚合(類似於聚集索引的思想),在結點內使用一個索引指針數組存儲向右的指針。

四指針、索引結點分離的跳躍表

從下圖就可以發現該跳躍表具有以下特點:
(1)它具有4個指針(left、right、up、down),使得跳躍表在水平方向和垂直方向都形成雙向鏈表。
(2)索引鏈表都是由一個個獨立的結點連接而成的,數據結點和索引結點分離。
(3)每層都具有頭結點(-∞)和尾結點(+∞)使得在插入結點時不需要進行判空操作,實現過程更簡單,但代價就是存儲空間的消耗。
(4)每一層鏈表都是有序的
(5)具有一個頭指針,指向頂層鏈表的頭結點。
(6)每一層都是一個有序鏈表。

查找過程

這裏拿在下圖的跳躍表查找值39爲例。
(1)通過頭指針找到最頂層鏈表的頭結點(此時h=5),然後從左向右進行查找,當結點的下一個結點的值大於等於待查找的值,則向下走,+∞>39,因此從結點17向下到達下一層鏈表(h=4)。
(2)自結點17從左向右進行查找,55>39,自結點25向下走達到下一層鏈表(h=3)。
(3)自結點25從左向右進行查找,55>39,自結點33向下達到下一層鏈表(h=2)。
(4)自結點33從左向右進行查找,44>39,自結點38向下達到下一層鏈表(h=1)。
(5)此時達到原始鏈表,自結點38從左向右進行查找,找到節點返回結點39,若查找的是鏈表中不存在的結點42,此時返回的也是結點39,返回待找值結點或者其小於待找值的最大值結點。

鏈表形成過程

初始狀態
在這裏插入圖片描述
鏈表的形成過程其實就是由一個個結點的插入過程組成
(1)查找新結點的插入位置: 插入一個值時,先查找該值在跳躍表的最底層鏈表(原始鏈表)的位置,若不存在,這個查找函數會返回表中比待插入值小的最大值結點。若已存在則根據你要是實現的跳躍表是否允許相同值來確定是選擇值覆蓋還是繼續插入。若選擇覆蓋,完成值覆蓋插入過程就結束;若選擇繼續插入,則返回該與待插入值相同的結點。
(2)獲得level: 通過隨機函數獲得待插入值的隨機層數(level),以便確認該值存在於幾層鏈表中。
(3)判斷是否需要添加新鏈表: 判斷level是否大於目前跳躍表的高度,若大於,則新建相應層數的的鏈表,並連接進跳躍表。比如說下圖中,若我再插入一個新結點58,該結點的level爲7,而目前跳躍表的高度是5,因此我們需要先建立兩層只含有-∞和+∞結點的鏈表,並且將這兩層鏈表連接進跳躍表中。
(4)將新結點插入到原始鏈表: new新結點,將新結點插入到原始鏈表的步驟(1)返回的結點之後,改變相應的指針,使得結點插入到原始鏈表中。
(5)將新結點插入到索引鏈表: 當level大於1時,new新的結點插入到索引鏈表(除原始鏈表外的都是索引鏈表)中,這個過程就可以使用left指針和up指針。例如在下圖插入值43,假定它的level是3,在步驟(4)中已經將結點43插入到原始鏈表的結點39之後,結點44之前。將結點43插入到索引鏈表的過程如下,用指針left從結點39開始向左進行檢查結點的up指針是否爲空,即檢查結點是否有上層結點,結點38有因此從結點38到達第二層鏈表,new一個值爲43的結點,將其插入到結點38之後,並將其與原始鏈表的結點43用up和down指針進行連接。因爲結點43的level爲3,因此自結點38向左開始檢查結點up指針是否爲空(此時是在第二層鏈表進行查找),結點38沒有上層結點,up爲空,而31的指針up不爲空,通過結點31的up指針到達第三層,new一個值爲43的結點,將其插入達到結點31的後面,並將結點43和下層鏈表結點43進行連接。到此完成了值43的插入。
在這裏插入圖片描述
其它操作過程,無論是修改過程,還是刪除操作,都類似於值插入過程,這裏就不進行詳述了。

源代碼

代碼來自github,跳躍表結構一樣,過程大同小異。

public class SkipList {

    public SkipListEntry head;    // First element of the top level
    public SkipListEntry tail;    // Last element of the top level

    public int n;        // number of entries in the Skip List
    public int h;        // Height

    public Random r;    // Coin toss

    // constructor
    public SkipList() {
        SkipListEntry p1, p2;

        // 創建一個 -oo 和一個 +oo 對象
        p1 = new SkipListEntry(SkipListEntry.negInf, null);
        p2 = new SkipListEntry(SkipListEntry.posInf, null);

        // 將 -oo 和 +oo 相互連接
        p1.right = p2;
        p2.left = p1;

        // 給 head 和 tail 初始化
        head = p1;
        tail = p2;

        n = 0;
        h = 0;
        r = new Random();
    }

    private SkipListEntry findEntry(String key) {

        SkipListEntry p;

        // 從head頭節點開始查找
        p = head;

        while (true) {
            // 從左向右查找,直到右節點的key值大於要查找的key值
            while (p.right.key != SkipListEntry.posInf
                    && p.right.key.compareTo(key) <= 0) {
                p = p.right;
            }

            // 如果有更低層的節點,則向低層移動
            if (p.down != null) {
                p = p.down;
            } else {
                break;
            }
        }

        // 返回p,!注意這裏p的key值是小於等於傳入key的值的(p.key <= key)
        return p;
    }

    public Integer get(String key) {

        SkipListEntry p;

        p = findEntry(key);

        if (p.key.equals(key)) {
            return p.value;
        } else {
            return null;
        }
    }

    public Integer put(String key, Integer value) {

        SkipListEntry p, q;
        int i = 0;

        // 查找適合插入的位子
        p = findEntry(key);

        // 如果跳躍表中存在含有key值的節點,則進行value的修改操作即可完成
        if (p.key.equals(key)) {
            Integer oldValue = p.value;
            p.value = value;
            return oldValue;
        }

        // 如果跳躍表中不存在含有key值的節點,則進行新增操作
        q = new SkipListEntry(key, value);
        q.left = p;
        q.right = p.right;
        p.right.left = q;
        p.right = q;

        // 再使用隨機數決定是否要向更高level攀升
        while (r.nextDouble() < 0.5) {

            // 如果新元素的級別已經達到跳躍表的最大高度,則新建空白層
            if (i >= h) {
                addEmptyLevel();
            }

            // 從p向左掃描含有高層節點的節點
            while (p.up == null) {
                p = p.left;
            }
            p = p.up;

            // 新增和q指針指向的節點含有相同key值的節點對象
            // 這裏需要注意的是除底層節點之外的節點對象是不需要value值的
            SkipListEntry z = new SkipListEntry(key, null);

            z.left = p;
            z.right = p.right;
            p.right.left = z;
            p.right = z;

            z.down = q;
            q.up = z;

            q = z;
            i = i + 1;
        }

        n = n + 1;

        // 返回null,沒有舊節點的value值
        return null;
    }

    private void addEmptyLevel() {

        SkipListEntry p1, p2;

        p1 = new SkipListEntry(SkipListEntry.negInf, null);
        p2 = new SkipListEntry(SkipListEntry.posInf, null);

        p1.right = p2;
        p1.down = head;

        p2.left = p1;
        p2.down = tail;

        head.up = p1;
        tail.up = p2;

        head = p1;
        tail = p2;

        h = h + 1;
    }

    public Integer remove(String key) {

        SkipListEntry p, q;

        p = findEntry(key);

        if (!p.key.equals(key)) {
            return null;
        }

        Integer oldValue = p.value;
        while (p != null) {
            q = p.up;
            p.left.right = p.right;
            p.right.left = p.left;
            p = q;
        }

        return oldValue;
    }
    class SkipListEntry {

    // data
    public String key;
    public Integer value;

    // links
    public SkipListEntry up;
    public SkipListEntry down;
    public SkipListEntry left;
    public SkipListEntry right;

    // special
    public static final String negInf = "-oo";
    public static final String posInf = "+oo";

    // constructor
    public SkipListEntry(String key, Integer value) {
        this.key = key;
        this.value = value;
    }

    // methods...
}
}

雙指針、索引結點分離的跳躍表(ConcurrentSkipListMap容器的底層結構實現原理)

這裏通過ConcurrentSkipListMap容器的底層結構來說明雙指針、索引結點分離的跳躍表的具體實現原理。ConcurrentSkipListMap容器是線程安全的,操作中保證線程安全的這部分我就不進行講述了。

在這裏插入圖片描述

ConcurrentSkipListMap容器使用的跳躍表的結點分爲鏈表頭結點(HeadIndex)、索引結點(Index)、數據結點(Node),Index結點對Node結點進行了封裝,HeadIndex結點對Index結點進行封裝。Node結點是原始鏈表中除了頭結點之外的結點,Index結點是索引鏈表中除了頭結點之外的結點,而HeadIndex結點是每層的頭結點。

public class ConcurrentSkipListMap<K, V> extends AbstractMap<K, V> implements ConcurrentNavigableMap<K, V>, Cloneable, Serializable {
    //head是跳錶的表頭
    private transient volatile HeadIndex<K,V> head;
    static final class Node<K, V>{
    final K key;  // key 是 final 的, 說明節點一旦定下來, 除了刪除, 不然不會改動 key 了
    volatile Object value; // 對應的 value
    volatile Node<K, V> next; // 下一個節點
        Node(K key, Object value, Node<K,V> next) {
        this.key = key;
        this.value = value;
        this.next = next;
    }
    }
    
    static class Index<K, V>{
    final Node<K, V> node; // 索引指向的節點, 縱向上所有索引指向鏈表最下面的節點
    final Index<K, V> down; // 下邊level層的 Index
    volatile Index<K, V> right; // 右邊的  Index
    Index(Node<K,V> node, Index<K,V> down, Index<K,V> right) {
        this.node = node;
        this.down = down;
        this.right = right;
    }

}

    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;
    }
}
}

插入過程

以在下圖插入值80爲例,初始狀態:
在這裏插入圖片描述

(1)通過findPredecessor()函數找到待插入值的前驅結點,也就是確定插入位置,new一個node結點,將結點插入到原始鏈表中。
在這裏插入圖片描述

(2)獲得待插入值的隨機層數(level),假設80的隨機層數爲4.
(3)判斷隨機層數是否大於跳躍表此時的高度,若大於則新建相應層數鏈表,將其連接進跳躍表,並將head結點的指針指向最高層鏈表的頭結點。若小於則直接進入步驟(4)。80的level爲4,此時的高度(h)爲3,因此level-h=1大於0,需要新建一層鏈表。
在這裏插入圖片描述

(4)若level大於1,則根據獲得的隨機層數(level),new相應個數的Index結點,通過向下指針,使得他們建立起垂直方向的連接。
在這裏插入圖片描述
(5)將索引結點插入到索引鏈表中,建立其水平方向的連接。過程是從頂層鏈表從左向右查找,直到找到其下一個結點大於待插入值或者null的結點,若此時你位於的鏈表高度大於插入結點的隨機層數(level),則通過該結點的down指針,一直向下走直到鏈表的高度等於插入結點level,然後繼續向右走,直到找到其下一個結點大於待插入值或者null的結點,然後將索引結點插入到其後,然後繼續向下到一層,繼續向左,繼續插入,循環反覆,一直到原始鏈表則停止。

(6)至此插入過程結束,其中保證併發安全的操作我選擇了跳過,若感興趣可以查看源碼,以下給出源碼分析,若覺得沒講清楚可以自行搜索。
在這裏插入圖片描述

插入過程源碼分析

//基本的 put 方法,向跳錶中添加一個節點
public V put(K key, V value) {
    if (value == null)
        throw new NullPointerException();
    return doPut(key, value, false);
}
//真正的插入函數
private V doPut(K key, V value, boolean onlyIfAbstsent){
    Node<K, V> z; // adde node
    if(key == null){
        throw new NullPointerException();
    }
    Comparator<? super K> cmp = comparator;
    outer:
    ////雙層 for 循環+ CAS 無鎖式更新
    for(;;){
   // 1. 通過findPredecessor()函數找到key的前驅結點,若沒發生 條件競爭, 最終 key在 b 與 n 之間 (找到的b在 base_level 上)
        for(Node<K, V> b = findPredecessor(key, cmp), n = b.next;;)
   // 2. n = null時 b 是鏈表的最後一個節點, key 直接插到 b 之後 (調用 b.casNext(n, z))         
            if(n != null){ 
                Object v; int c;
                Node<K, V> f = n.next; // 3 獲取 n 的右節點
                if(n != b.next){ 
 // 4. 條件競爭(另外一個線程在b之後插入節點, 或直接刪除結點n), 則 break 到位置 0, 重新開始
                    break ;
                }
//若結點n已經刪除, 則 調用 helpDelete 進行幫助刪除 (詳情見 helpDelete), 則 break 到位置 0, 重新來                
                if((v = n.value) == null){ 
                    n.helpDelete(b, f);
                    break ;
                }
// 5. 結點b被刪除中 ,則 break 到位置 0, 調用 findPredecessor 幫助刪除 index 層的數據, 至於 node 層的數據 會通過 helpDelete 方法進行刪除
                if(b.value == null || v == n){ 
                    break ;
                }
 // 6. 若 key 真的 > n.key (在調用 findPredecessor 時是成立的), 則進行 向後走               
                if((c = cpr(cmp, key, n.key)) > 0){ 
                    b = n;
                    n = f;
                    continue ;
                }
                if(c == 0){ // 7. 直接進行賦值
                    if(onlyIfAbstsent || n.casValue(v, value)){
                        V vv = (V) v;
                        return vv;
                    }
                    break ; // 8. cas 競爭條件失敗 重來
                }
                // else c < 0; fall through
            }
            // 9. 到這邊時 n.key > key > b.key
            z = new Node<K, V> (key, value, n);
            if(!b.casNext(n, z)){
                break ; // 10. cas競爭條件失敗 重來
            }
   // 11. 注意 這裏 break outer 後, 上面的 for循環不會再執行, 而後執行下面的代碼, 這裏是break 不是 continue outer, 這兩者的效果是不一樣的               
            break outer;
        }
    }

    int rnd = KThreadLocalRandom.nextSecondarySeed();
    if((rnd & 0x80000001) == 0){ // 12. 判斷是否需要添加level
        int level = 1, max;
        while(((rnd >>>= 1) & 1) != 0){
            ++level;
        }
   // 13. 上面這段代碼是獲取 level 的, 我們這裏只需要知道獲取 level 就可以 (50%的機率返回0,25%的機率返回1,12.5%的機率返回2...最大返回31。)
        Index<K, V> idx = null;
        HeadIndex<K, V> h = head;
   // 14. 初始化 max 的值, 若 level 小於 max , 則進入這段代碼 (level 是 1-31 之間的隨機數)      
        if(level <= (max = h.level)){
            for(int i = 1; i <= level; ++i){
   // 15 添加 z 對應的 index 數據, 並將它們組成一個上下的鏈表(index層是上下左右都是鏈表)         
                idx = new Index<K, V>(z, idx, null);
            }
        }
        else{ // 16. 若 level > max 則只增加一層 index 索引層
            level = max + 1; // 17. 跳錶新的 level 產生
            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; // 18. 獲取老的 level 層
                if(level <= oldLevel){
    // 19. 另外的線程進行了index 層增加操作, 所以 不需要增加 HeadIndex 層數
                    break;
                }
                HeadIndex<K, V> newh = h;
                Node<K, V> oldbase = h.node; // 20. 這裏的 oldbase 就是BASE_HEADER
                for(int j = oldLevel+1; j <= level; ++j){ // 21. 這裏其實就是增加一層的 HeadIndex (level = max + 1)
                    newh = new HeadIndex<K, V>(oldbase, newh, idxs[j], j); // 22. idxs[j] 就是上面的 idxs中的最高層的索引
                }
                if(casHead(h, newh)){ // 23. 這隻新的 headIndex
                    h = newh;  // 24. 這裏的 h 變成了 new HeadIndex
                    idx = idxs[level = oldLevel];  // 25. 這裏的 idx 上從上往下第二層的 index 節點 level 也變成的 第二
                    break;
                }
            }
        }

        // find insertion points and splice in
        splice:
    // 26. 這時的 level 已經是 第二高的 level(若上面 步驟19 條件競爭失敗, 則多出的 index 層其實是無用的, 因爲 那是 調用 Index.right 是找不到它的)    
        for(int insertionLevel = level;;){ 
            int j = h.level;
            for(Index<K, V> q = h, r = q.right, t = idx;;){ // 27. 初始化對應的數據
                if(q == null || t == null){ // 28. 節點都被刪除 直接 break出去
                    break splice;
                }
                if(r != null){
                    Node<K, V> n = r.node;
                    // compare before deletion check avoids needing recheck
                    int c = cpr(cmp, key, n.key);
                    if(n.value == null){ // 29. 老步驟, 幫助index 的刪除
                        if(!q.unlink(r)){
                            break ;
                        }
                        r = q.right; // 30. 向右進行遍歷
                        continue ;
                    }

                    if(c > 0){ // 31. 向右進行遍歷
                        q = r;
                        r = r.right;
                        continue ;
                    }
                }

                // 32.
                // 代碼運行到這裏, 說明 key < n.key
                // 第一次運行到這邊時, j 是最新的 HeadIndex 的level j > insertionLevel 非常用可能, 而下面又有 --j, 所以終會到 j == insertionLevel
                if(j == insertionLevel){
                    if(!q.link(r, t)){ // 33. 將 index t 加到 q 與 r 中間, 若條件競爭失敗的話就重試
                        break ; // restrt
                    }
  // 34. 若這時 node 被刪除, 則開始通過 findPredecessor 清理 index 層, findNode 清理 node 層, 之後直接 break 出去, doPut調用結束                  
                    if(t.node.value == null){ 
                        findNode(key);
                        break splice;
                    }
                    if(--insertionLevel == 0){ // 35. index 層添加OK, --1 爲下層插入 index 做準備
                        break splice;
                    }
                }

                /**
                 * 下面這行代碼其實是最重要的, 理解這行代碼, 那 doPut 就差不多了
                 * 1). --j 要知道 j 是 newhead 的level, 一開始一定 > insertionLevel的, 通過 --1 來爲下層操作做準備 (j 是 headIndex 的level)
                 * 2). 通過 19. 21, 22 步驟, 個人認爲 --j >= insertionLevel 是橫成立, 而 --j 是必須要做的
                 * 3) j 經過幾次--1, 當出現 j < level 時說明 (j+1) 層的 index已經添加成功, 所以處理下層的 index
                 */
                if(--j >= insertionLevel && j < level){
                    t = t.down;
                }
                /** 到這裏時, 其實有兩種情況
                 *  1) 還沒有一次index 層的數據插入
                 *  2) 已經進行 index 層的數據插入, 現在爲下一層的插入做準備
                 */
                q = q.down; // 從 index 層向下進行查找
                r = q.right;

            }
        }
    }
    return null;
}
//返回值的前驅結點
private Node<K, V> findPredecessor(Object key, Comparator<? super K> cmp){
    if(key == null)
        throw new NullPointerException();
    for(;;){
     // 1. 初始化數據 q 是head, r 是 最頂層 h 的右Index節點
        for(Index<K, V> q = head, r = q.right, d;;){
            if(r != null){ // 2. 對應的 r =  null, 則進行向下查找
                Node<K, V> n = r.node;
                K k = n.key;
                // 3. n.value = null 說明 節點n 正在刪除的過程中
                if(n.value == null){ 
                // 4. 在 index 層直接刪除 r 節點, 若條件競爭發生直接進行break 到步驟1 , 重新從 head 節點開始查找
                    if(!q.unlink(r)){ 
                        break; // restart
                    }
              // 5. 刪除 節點r 成功, 獲取新的 r 節點, 回到步驟 2 
                    r = q.right; //reread r 
                    continue;
                }
 // 6. 若 r.node.key < 參數key, 則繼續向右遍歷, continue 到 步驟 2處, 若 r.node.key >  參數key 直接跳到 步驟 7
                if(cpr(cmp, key, k) > 0){
                    q = r;
                    r = r.right;
                    continue;
                }
            }
            // 7.此處時函數的出口,也就是說這個程序執行時已經到了原始鏈表這一層
            if((d = q.down) == null){ 
                return q.node;
            }
          // 8.未到原始鏈表這一層,繼續向下走 
            q = d; 
            r = d.right;
        }
    }
}
//Node 結點的內部實例方法,通過該方法可以完成將 b.next 指向 f,完成對 n 結點的刪除。
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);
    }
}

查找過程

查找過程與其它實現方法基本相同,這裏就不進行詳述,給出源碼,若感興趣自行閱讀源碼。

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 (;;) {
        //1.獲得key的前驅結點
        for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
            Object v; int c;
            //2.n應該是key值結點的位置,因爲爲null,說明key值不存在容器中,返回null
            if (n == null)
                break outer;
            Node<K,V> f = n.next;
            //3.有其它線程在進行修改相關結點,重頭再來保證併發安全
            if (n != b.next)           
                break;
             //4. n結點被刪除, 進行helpDelete 後重頭再來保證併發安全
            if ((v = n.value) == null) {    
                n.helpDelete(b, f);
                break;
            }
            // 5.前驅結點b已經是刪除了的節點, 則 break 後再來
            if (b.value == null || v == n)  
                break;
            //6.c = 0 說明 n 就是我們要的結點
            if ((c = cpr(cmp, key, n.key)) == 0) {
                @SuppressWarnings("unchecked") V vv = (V)v;
                return vv;
            }
            //7.c < 0 說明不存在這個 key 所對應的結點
            if (c < 0)
                break outer;
            // 8. 運行到這一步時, 其實是 在調用 findPredecessor 後又有節點添加到 節點b的後面所致   
            b = n;
            n = f;
        }
    }
    return null;
}

其它過程原理相同,可以參考查找過程和插入過程,或者自行查看源碼以及從下方本文參考的博文中進行翻閱。

雙指針、索引結點聚合的跳躍表(Redis的zset底層結構)

Redis支持5種數據類型,zset是其中一種,zset是有序數據集合,它類似於 Java 中的 SortedSet 和 HashMap 的結合體,一方面它是一個 set 保證了內部 value 的唯一性,另一方面又可以給每個 value 賦予一個排序的權重值 score,來達到排序的目的,zset使得數據根據socre大小進行排序,且可以提供排名查詢和範圍查找(根據分數區間查詢數據集合和根據排名區間查詢數據集合)。原本可以使用數組實現zset,但其需要提供隨機的插入和刪除功能,使用數組性能太差,而平衡樹雖然能在O(logn)複雜度時間完成查找,但其插入或者刪除很可能造成樹的全局調整,而跳躍表插入和刪除只會造成局部調整,因此Redis選擇了跳躍表(SkipList)。

當然Redis的zset的底層結構並不是簡簡單單的完全由SkipList組成。實際上當zset內部元素少時,其底層結構是zipList(壓縮列表);而元素多時,底層結構是skipList(跳躍表)+dict(字典)。簡單來講,dict用來查詢數據到分數的對應關係(dict用哈希表實現),而skiplist用來根據分數查詢數據(在普通跳躍表的基礎上進行了擴展)。Redis中的skiplist跟前面介紹的經典的skiplist相比,有如下不同:
(1)分數(score)允許重複,即skiplist的key允許重複。這在最開始介紹的經典skiplist中是不允許的。
(2)在比較時,不僅比較分數(相當於skiplist的key),還比較數據本身。在Redis的skiplist實現中,數據本身的內容唯一標識這份數據,而不是由key來唯一標識。另外,當多個元素分數相同的時候,還需要根據數據內容來進字典排序。
(3)第1層鏈表不是一個單向鏈表,而是一個雙向鏈表。這是爲了方便以倒序方式獲取一個範圍內的元素。
(4)在skiplist中可以很方便地計算出每個元素的排名(rank)。

Redis中跳躍表的結點結構

Redis跳躍表的結構體是zskiplist,它包含頭指針和尾指針以及表中結點的個數和跳錶目前的高度,頭指針指向頭結點。結點是zskiplistNode,結點內除了分數,還存有一個前向指針(backward)和一個後向指針數組(level[]),數組內存放的是後向指針和span值,每個後向指針還對應了一個span值,它表示當前的指針跨越了多少個節點。span用於計算元素排名(rank),這正是前面我們提到的Redis對於skiplist所做的一個擴展。而且數組內的結構體是當需要分配時才分配,而不是按照規定的數組大小分配。

#define ZSKIPLIST_MAXLEVEL 32  //鏈表最大層數
#define ZSKIPLIST_P 0.25       //隨機化,結點提升到上一層的概率

typedef struct zskiplistNode {
    robj *obj;                 //存放的是節點數據,它的類型是一個string robj。
    double score;              //分數
    struct zskiplistNode *backward;   //前向指針
    //後向指針數組
    struct zskiplistLevel {
        struct zskiplistNode *forward; //後向指針
        unsigned int span;    //跨度
    } level[];
} zskiplistNode;
//跳錶的結構
typedef struct zskiplist {
    //頭指針,尾指針
    struct zskiplistNode *header, *tail;
    //鏈表包含的節點總數,不包含頭結點
    unsigned long length;
    //跳錶目前的高度
    int level;
} zskiplist;

在這裏插入圖片描述
(Redis中跳躍表的結構示意圖)

以下給出Redis源碼中創建跳躍表和跳躍表插入元素的源碼分析,源碼分析來自Redis(2)——跳躍表,若想要看刪除元素或者元素排名實現源碼分析可以通過傳送門過去。

創建跳躍表

位於源碼中的 t_zset.c/zslCreate

zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    // 申請內存空間
    zsl = zmalloc(sizeof(*zsl));
    // 初始化層數爲 1
    zsl->level = 1;
    // 初始化長度爲 0
    zsl->length = 0;
    // 創建一個層數爲 32,分數爲 0,沒有 value 值的跳躍表頭節點
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    
    // 跳躍表頭結點初始化
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        // 將跳躍表頭節點的所有前進指針 forward 設置爲 NULL
        zsl->header->level[j].forward = NULL;
        // 將跳躍表頭節點的所有跨度 span 設置爲 0
        zsl->header->level[j].span = 0;
    }
    // 跳躍表頭節點的後退指針 backward 置爲 NULL
    zsl->header->backward = NULL;
    // 表頭指向跳躍表尾節點的指針置爲 NULL
    zsl->tail = NULL;
    return zsl;
}

即執行完之後創建瞭如下結構的初始化跳躍表:
在這裏插入圖片描述

插入結點過程

Redis裏跳躍表的插入過程與其它跳躍表的插入過程其實是類似的,唯一的不同就是其它跳躍表從上向下、從左向右遍歷索引鏈表的過程在這裏變成了在[]level數組內實現。從頭結點的數組level的31層開始向左向下進行遍歷,詳情可以翻閱以下源碼或者自行搜索。

//第一部分:聲明需要存儲的變量
// 存儲搜索路徑
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
// 存儲經過的節點跨度
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
//第二部分:搜索當前節點插入位置
serverAssert(!isnan(score));
x = zsl->header;
// 逐步降級尋找目標節點,得到 "搜索路徑"
for (i = zsl->level-1; i >= 0; i--) {
    /* store rank that is crossed to reach the insert position */
    rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
    // 如果 score 相等,還需要比較 value 值
    while (x->level[i].forward &&
            (x->level[i].forward->score < score ||
                (x->level[i].forward->score == score &&
                sdscmp(x->level[i].forward->ele,ele) < 0)))
    {
        rank[i] += x->level[i].span;
        x = x->level[i].forward;
    }
    // 記錄 "搜索路徑"
    update[i] = x;
}
//第三部分:生成插入節點
/* we assume the element is not already inside, since we allow duplicated
 * scores, reinserting the same element should never happen since the
 * caller of zslInsert() should test in the hash table if the element is
 * already inside or not. */
level = zslRandomLevel();
// 如果隨機生成的 level 超過了當前最大 level 需要更新跳躍表的信息
if (level > zsl->level) {
    for (i = zsl->level; i < level; i++) {
        rank[i] = 0;
        update[i] = zsl->header;
        update[i]->level[i].span = zsl->length;
    }
    zsl->level = level;
}
// 創建新節點
x = zslCreateNode(level,score,ele);
//第四部分:重排前向指針
for (i = 0; i < level; i++) {
    x->level[i].forward = update[i]->level[i].forward;
    update[i]->level[i].forward = x;

    /* update span covered by update[i] as x is inserted here */
    x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
    update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}

/* increment span for untouched levels */
for (i = level; i < zsl->level; i++) {
    update[i]->level[i].span++;
}
//第五部分:重排後向指針並返回
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
    x->level[0].forward->backward = x;
else
    zsl->tail = x;
zsl->length++;
return x;

在這裏插入圖片描述
(此圖爲說明插入過程的簡要圖,Redis中的跳錶結構圖參照這部分的第一個圖。)

總結

(1)跳躍表的主要思想是隨機提取結點建立索引鏈表,查找時先查找索引鏈表以跳過中間結點以提高性能。
(2)跳躍表的查找、插入、更新、刪除元素的時間複雜度是O(logn),Redis爲什麼用跳錶而不用平衡樹?這篇博文中有跳躍表的查找時間複雜度爲O(logn)的證明。
(3)跳躍表相比於平衡樹的優勢在於插入或者刪除時只用做局部調整,且實現代碼簡單,並且內存佔用更靈活。
(4)哈希表比跳躍表的查找性能更好,但哈希表卻只支持單值查找,而跳躍表支持範圍查找。這也是數據庫索引更多使用B+樹而不是哈希表的原因。
(5)把握住跳躍表的主要思想即可,具體實現可不同,可根據具體的需求以及空間存儲要求等方面來使用不同的跳躍表。比如ConcurrentSkipListMap底層使用的和zset底層使用的跳躍表的具體實現就不同。

參考:
跳躍表Skip List的原理和實現(Java)
基於跳躍表的 ConcurrentSkipListMap 內部實現(Java 8)
ConcurrentSkipListMap 源碼分析 (基於Java 8)
Redis爲什麼用跳錶而不用平衡樹?
Redis(2)——跳躍表

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章