前言
我们常用来加快查找速度的两类数据结构分别是哈希表和平衡树,哈希表查找时间复杂度是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)——跳跃表