单线程下若想使用有序的键值对,我们选用 TreeMap,若是考虑到线程安全问题,则可以使用 ConcurrentSkipListMap,它与ConcurrentSkipListSet之间的关系就如 TreeMap 与 TreeSet 之家的关系一样,set 由 map来实现,本篇主要来分析 ConcurrentSkipListMap。
底层数据结构是 跳表 Skip List:
跳表的实现:
三种类型的节点:
Node:键值对存储在其中,next字段构成了单向链表
Index:跳表中的索引节点,包含了右指针(right),向下的索引(down)和持有一个Node对象的引用。right 指向右侧的Index,down 指向下层的Index。
HeadIndex:继承自Index,每一条从左至右的Index 链的头节点,多了一个标识当前层级的level。
跳表结构模型:下层是Node 的单向链表,其上引用其的 Index 节点链,利用 Index 节点的 right 与 down 构成一个二维 Index 链,并非每个Node上都有Index 链,新的 Node 节点是否要构建 Index 链以及构建几层都是堆积的,沿着 Index 链从左至右,从上至下的搜寻过程就是所谓的 跳。
初始化时结构如下:
BASE_HEADER是个空对象,是 Node 链的头节点,其上是 HeadIndex 链,每一个HeadIndex 都是该层 Index 链的头节点;
private static final Object BASE_HEADER = new Object();
ConcurrentSkipListMap#head 指向 HeadIndex 链的顶层
private transient volatile HeadIndex<K,V> head;
HeadIndex 有个 level 字段,用于标识当前层级。
整个跳表结构如下图:
首先从 head 开始,沿着Index节点链从左至右,从上至下进行搜索,找到合适位置插入,新Node节点是否要在其上创建Index链,要创建几层Index,是随机的,下面分析源码时再说。
跳表结构分为两种链,一个是 Index 二维链表,另一个是 Node 数据链。
在Index 二维链中,每次只需遍历几个Index节点就可找到要 get / put / remove 位置附近的前置 Node 节点,然后从该前置节点处往后遍历Node链找寻,通常也只需较少的遍历就可找到。
并发下 Index 节点的插入与删除:
Index 的 down , node 字段是 final 的,不会改变,不存在安全问题。
Index 节点的 next 指针是 volatile 的,使用 CAS 添加/删除 Index 节点,这保证了安全性,对于失败的线程会采用重试的方式,让其再从头开始遍历搜寻,这是种退让的处理方式,过度的竞争反而影响性能。CAS + volatile 确实保证了单个节点操作的安全性,但是对于整个链在并发下的正确性还需要其它的保护措施:空值节点禁止执行 link / unlink 操作,对于失败的重试机制,以及主动的避让。我会在接下来对它们进行分析。
节点的 Index 链无论插入还是删除都不是一下全部完成,这就造成 Index 二维链表不能即时更新,但这并不会影响安全性,因为我们仍然可以靠着原来的 Index 链来查找,会对性能造成影响。
来看看 Index 节点的连接与断开操作 :link , unlink
final boolean link(Index<K,V> succ, Index<K,V> newSucc) {
Node<K,V> n = node;
newSucc.right = succ;
return n.value != null && casRight(succ, newSucc);
}
首先将 newSucc 指向 succ,之后 CAS 将当前Index指向 newSucc,这样一次CAS 就能完成插入操作。竞争导致失败的话,调用方会采取重试的方式来处理。
如果当前 Index 持有的 Node 节点被删除 (指的是其value为null,这代表被删除状态,虽然仍可以访问到)是无法调用 link 方法的,因为这有丢失 Index 节点的风险。在文章后面的论述里,对此做了详细解释。
unlink
final boolean unlink(Index<K,V> succ) {
return node.value != null && casRight(succ, succ.right);
}
不允许删除节点的 Index 执行unlink操作,为了防止情况的复杂性。
能调用unlink说明该Index关联的Node之前并未被删除且其next节点是删除状态的节点,因此调用unlink解绑,不过在调用前该节点也被删除了,处理方法就是使其unlink失败,强制让其重试,重试意味着从Index 链头开始重新搜寻。这是为了防止情况的复杂性。
可以看出 Index 的链接/断开都与 Node 节点删除操作的设计息息相关。
关于 Node 链的删除操作:
有些并发下列表的删除操作是在删除时标记已删除节点的 next 指针,以避免与并发插入冲突,以及在遍历时跟踪三元组(前置、节点、后续)以检测何时以及如何取消这些已删除节点的链接。出于性能和空间的考虑ConcurrentSkipListMap没采用这种方式。
ConcurrentSkipListMap 使用 volatile 的 next 指针,并使用 CAS 更改它们。删除时,它们会被更改指向一个新节点上,该节点可以看作是种 “ 标记 ” (该特殊的节点应该具有某种特殊的标识,比如该类中标记节点的key为null,value指向自身)
除了使用删除标记外,列表还使用节点 value 字段的空性(null)来指示删除,延迟删除方案。如果一个节点的 value 为 null,那么我们就认为它已被删除,即使它仍然可以被访问到。
为什么要在要删除的节点后CAS添加一个特殊的标记节点?value为null不就已经可以标识节点的状态是删除了吗?
重点在于CAS,通过主动的CAS来式该节点的其它CAS操作失败,或因竞争或因next改变,总之为删除节点尽量排除其它操作的干扰,那些失败的线程会重试。
以下是在 Node 节点链里删除 n 节点,其前置节点是b,后续节点是f,最初是:
* +------+ +------+ +------+
* ... | b |------>| n |----->| f | ...
* +------+ +------+ +------+
1,CAS 将 n 的 value 从非空 置为 null
之后遍历到该节点的其它操作将视其为已被删除而做相应处理。其他正在进行的插入和删除操作可能仍然会修改 n 的 next 指针,因为其仍在链内。
2,CAS 将 n 的 next 指向标记节点 marker ( 该节点的key为null,value指向自身) 。之后,对于想将节点插入到 n 后的操作,在检测到 n.next 也就是 marker 后,不会进行插入操作。
* +------+ +------+ +------+ +------+
* ... | b |------>| n |----->|marker|------>| f | ...
* +------+ +------+ +------+ +------+
3,CAS 将 b 的 next 指向 f 。此后不会在链表中遍历到 n,它最终可以被 gc 掉。
* +------+ +------+
* ... | b |----------------------------------->| f | ...
* +------+ +------+
对于上面的这三步:
对于步骤 1 来说,若是由于与另一个操作的竞争而失败,重试即可。
步骤2-3可能会失败,一些操作在遍历时发现空值节点会帮助进行删除操作,所以存在竞争,采用这种帮助删除的方式而非等待,是为了防止操作因删除节点的影响而迟迟得不到执行,所以主动帮忙执行删除任务。
标记节点的使用稍微使帮助代码变得复杂,因为遍历必须跟踪四个节点(b、n、marker、f)的一致读取,而不仅仅是(b、n、f),不过标记节点 marker 的 next 字段是不会被改变的,空值节点指向 marker 的 next 也是不会再被更改的。这是因为竞争失败的会重试,重试发现空值节点会 helpDelete,帮完忙后会再次重试,所以说 n.next 和 marker.next 是不变的。
(b, n, f) 是三个Node节点,分别表示 (前置节点,当前节点,后置节点),称他们为跟踪三元组。
并发下的情况是复杂的,状态不断的变化,通常执行一操作,会先获取此刻状态的一个快照,然后进行分析处理,只要在最后将结果进行正确的同步即可确保程序的线程安全,若是提交结果失败重试即可,重试获取最新的状态再次进行分析处理。
最重要的就是如何正确同步代码,接下来我们来看看ConcurrentSkipListMap是如何做的。
无论get,set ,remove 等都需要先沿着Index 二维链表从左到右从上到下找到相应位置附近的节点,这就是 findPredecessor 主要做的事,方法返回一个Node,之后就从该节点处开始遍历 Node 链,有了在Index二维链上的 跳 ,就避免了从头开始一个个遍历Node 链,也就节省了搜索时间。
findPredecessor:
private Node<K,V> findPredecessor(Object key, Comparator<? super K> cmp) {
if (key == null)
throw new NullPointerException(); // don't postpone errors
// 两层for循环
for (;;) {
for (Index<K,V> q = head, r = q.right, d;;) {
if (r != null) {
Node<K,V> n = r.node;
K k = n.key;
// 发现被删除的Node,则unlink修正链
// 若失败,break跳出当前循环,而外围是个无限的for循环,
// 会重新读取head 开始搜索。
if (n.value == null) {
if (!q.unlink(r))
break; // restart
r = q.right; // reread r
continue;
}
// 继续往右遍历Index链
if (cpr(cmp, key, k) > 0) {
q = r;
r = r.right;
continue;
}
}
// 什么情况代表在Index二维链中找到了那个最靠近(从左至右的)的节点?
// 那就是搜到了某一Node上的Index节点链的最底层
if ((d = q.down) == null)
return q.node; // 返回这个最靠近的节点
// 跳到下一层Index链,下次循环从该链再往右搜索
q = d;
r = d.right;
}
}
}
head 是volatile,对于 findPredecessor 只要在执行时获得那是最新的head即可,至于之后head 是否改变不影响本次的搜索,这就是一开始将 head 赋给局部变量 q。
该方法还有一个用处:在remove中删除key的节点后,会调用
findPredecessor (key, cmp),将删除键key作为参数,而这会将key节点上的Index链全部清除干净。
unlink 失败会break,也就是我们上面说的重试:我在此处失败了,并发下此处现在是什么情况我无法确定,干脆直接从头开始,获取最新的状态信息。
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;
// n已不再是b的next节点,重试
if (n != b.next) // inconsistent read
break;
// n为被删除节点,helpDelete帮助删除,完成后回来重试
if ((v = n.value) == null) { // n is deleted
n.helpDelete(b, f);
break;
}
// 前置节点 b 为被删除节点,v == n为true说明删除步骤的第二步已完成
// 被删除节点在ConcurrentSkipListMap中很重要,它被多个操作检测并处理,
// 这导致删除节点状态的复杂性,所以这里直接忽略重试一次
if (b.value == null || v == n) // b is deleted
break;
// 到这里节点b 与 n 是什么状态?能确定吗?不能
if ((c = cpr(cmp, key, n.key)) == 0) {
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
// b 比 key 小,n 比 key大,返回null,但此时链中一定
// 不存在 key 吗?不一定
if (c < 0)
break outer;
// 往后遍历
b = n;
n = f;
}
}
return null;
}
利用 findPredecessor 跳到位置附近,之后开始往后遍历。
之前说了ConcurrentSkipListMap 对于删除节点的处理就是尽量避免其它操作的干扰,让他们重试以回避,实现就在于这两个 if 判断,你会在下面的代码中多次看到这二位。
if ((v = n.value) == null) { // n is deleted
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n) // b is deleted
break;
关于 helpDelete
void helpDelete(Node<K,V> b, Node<K,V> f) {
if (f == next && this == b.next) {
if (f == null || f.value != f) // not already marked
casNext(f, new Node<K,V>(f));
else
b.casNext(this, f.next);
}
}
上面说过删除分三步,你要帮忙最好先确认是否需要帮忙,以及帮第二步还是第三部。
f == next && this == b.next
为true说明只完成了第一步,有忙可帮
f == null || f.value != f
为true,说明第二步还未完成,来帮忙完成第二步。
否则则说明是第三步未完成,来帮忙完成第三步。
插入操作代码分为三部分:1,将新 Node 插入链中;2,构建该节点上的Index链。3,将该链插入到 Index 二维链表中。
关于第一部分的代码的操作已经很熟悉了,这里不做过多分析。
private V doPut(K key, V value, boolean onlyIfAbsent) {
Node<K,V> z; // added node
if (key == null)
throw new NullPointerException();
Comparator<? super K> cmp = comparator;
outer: for (;;) {
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
if (n != null) {
Object v; int c;
Node<K,V> f = n.next;
if (n != b.next) // inconsistent read
break;
if ((v = n.value) == null) { // n is deleted
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n) // b is deleted
break;
if ((c = cpr(cmp, key, n.key)) > 0) {
b = n;
n = f;
continue;
}
// 相等
if (c == 0) {
// onlyIfAbsent 为true则不会更新value值
if (onlyIfAbsent || n.casValue(v, value)) {
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
break; // restart if lost race to replace value
}
// else c < 0; fall through
}
z = new Node<K,V>(key, value, n);
if (!b.casNext(n, z))
break; // restart if lost race to append to b
break outer;
}
}
对于竞争或者空值节点采取重试发方式,找到合适位置后CAS更改使 b 由 n 指向新节点,竞争失败则重试。
Index 链的构建:新 Node 节点是否要构建Index 链是随机的,构建高度也是随机的,这一切都通过ThreadLocalRandom生成的随机数来控制。
若是高度小于等于原先高度则直接构建即可;若是高度大于,则高度等于 head.level+1, HeadIndex 链同样增1
// 利用ThreadLocalRandom生成随机数,它是线程安全的
int rnd = ThreadLocalRandom.nextSecondarySeed();
// 节点是否构建Index链是随机的,这里当rnd为正偶数时构建链
if ((rnd & 0x80000001) == 0) { // test highest and lowest bits
int level = 1, max;
// Index链的长度也是随机的
while (((rnd >>>= 1) & 1) != 0)
++level;
Index<K,V> idx = null; // 指向顶层的Index节点
HeadIndex<K,V> h = head; // 头链
// 未超过原有高度,则直接开始构造该链表
if (level <= (max = h.level)) {
for (int i = 1; i <= level; ++i)
idx = new Index<K,V>(z, idx, null);
}
// 超过原有高度,则HeadIndex链也要跟着增长
else { // try to grow by one level
level = max + 1; // hold in array and later pick the one to use
@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;
if (level <= oldLevel) // lost race to add level
break;
HeadIndex<K,V> newh = h;
Node<K,V> oldbase = h.node;
for (int j = oldLevel+1; j <= level; ++j)
// 在新层里将HeadIndex与该层的Index连接
newh = new HeadIndex<K,V>(oldbase, newh, idxs[j], j);
if (casHead(h, newh)) {
h = newh; // 更改 h 指向新的头Headindex
// 对于Index链,level层及以下都是未链入链表的
// 下面会将它们链入。
idx = idxs[level = oldLevel];
break;
}
}
}
重点来看下高度超过原有高度的处理:
首先创建好 head.level + 1 个 Index 节点,彼此用 down 字段连接,存进数组
之后开启一个for循环,为了确保并发下的安全性,每次循环都获取最新的 head.level与要创建的高度比较,超过则break跳出,因为高度已满足你已没必要再添加了,否则利用for循环增加 level - oldLevel 个HeadIndex,彼此之间down字段连接
最后是真正关键的地方——正确同步,利用CAS更改head,并发下竞争的就是该字段,失败的化就再次循环重复上述步骤,成功则更改局部变量 h 与 idx 的值,h 指向新的头Headindex,对于Index链 level层及以下都是未插入链表的下面会将它们插入,idx 指向 level 层的未插入 Index节点。
Index链的插入:
// insertionLevel 层的 Index 未插入
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;
// compare before deletion check avoids needing recheck
int c = cpr(cmp, key, n.key);
if (n.value == null) { //对被删除节点进行unlink处理
if (!q.unlink(r))
break;
r = q.right;
continue;
}
if (c > 0) {
q = r;
r = r.right;
continue;
}
}
// 确认与未插入的Index节点同层
if (j == insertionLevel) {
// 链入
if (!q.link(r, t))
break; // 重试
// 链的Node已处于删除状态,则调用findNode,
// 传入key,清除该链 并 帮助删除节点。
// findNode 之所以由这两种功能,实际利用的是
// findPredecessor 和 对空值节点的unlink
if (t.node.value == null) {
findNode(key);
break splice;
}
// Index链全部链入,跳出
if (--insertionLevel == 0)
break splice;
}
// 能够执行到这,且这两个判断都为true,则说明本次循环链入了节点
if (--j >= insertionLevel && j < level)
t = t.down;
q = q.down;
r = q.right;
}
}
}
return null;
}
可以看出 put 涵盖Node 的插入,Index 链的创建及插入这一整个流程,其实 remove 也一样包含Node 与 Index 的删除,也就是说执行添加/删除操作的线程会完成Node及Index的全部操作,这增加了单个线程的开销,但干扰的减少提高了并发下的性能。
ConcurrentSkipListMap 的删除操作的设计是这样的:
禁止除删除这一行为之外的任何行为对空节点前后这块Node链区域的操作,也就是这块域内只进行并发下的删除操作。当节点从Node链中删除后会将其上的Index链从二维链中删除,也就是调用remove的线程从方法返回后,要删除的节点及其Index链都将被清除干净。
关于禁止其它行为的执行的实现就是我们在上面各个操作中多次见到的这两个 if 判断:
if ((v = n.value) == null) { // n is deleted
n.helpDelete(b, f);
break;
}
帮助完成删除任务,采用这种主动帮忙而不是退让的方式,目的是防止线程因空节点的阻碍而长时间无法继续执行。
if (b.value == null || v == n) // b is deleted
break;
当 跳 到空节点时,由于该块区域是被并发操作的,导致节点情况无法预测,采用退让的方式。
final V doRemove(Object key, Object value) {
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) // inconsistent read
break;
if ((v = n.value) == null) { // n is deleted
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n) // b is deleted
break;
if ((c = cpr(cmp, key, n.key)) < 0)
break outer;
if (c > 0) {
b = n;
n = f;
continue;
}
// value要么为null,要么等于该key的value
if (value != null && !value.equals(v))
break outer;
// 三步中的第一步,将节点value置为null
if (!n.casValue(v, null))
break;
// 三步中的剩下两步:在节点后插入一标记节点;重新链接剔除该节点
if (!n.appendMarker(f) || !b.casNext(n, f))
findNode(key); // retry via findNode
else {
findPredecessor(key, cmp); // clean index
if (head.right == null)
tryReduceLevel();
}
@SuppressWarnings("unchecked") V vv = (V)v;
return vv;
}
}
return null;
}
删除分三步操作,正如一开始描述的。若是第二步或第三步失败,调用 findNode,目的是利用该方法对 findPredecessor 的调用 以及 空节点的处理,帮助线程完成删除任务,这里删除任务的完成指的是该节点从Node链中删除,以及该节点的Index链从二维Index链中删除。
findPredecessor(key,cmp)若参数key的节点被删除,则 findPredecessor 会将该节点的Index链从上至下全部从链中清除。
关于降级:
head 这一层以及其下面的两层,这三个 level 层内没有节点,才考虑降低head的 level,降低后会再次对原先 head 层是否还为空进行判断,不是则尝试恢复原先的层级。
private void tryReduceLevel() {
HeadIndex<K,V> h = head;
HeadIndex<K,V> d;
HeadIndex<K,V> e;
if (h.level > 3 &&
(d = (HeadIndex<K,V>)h.down) != null &&
(e = (HeadIndex<K,V>)d.down) != null &&
e.right == null &&
d.right == null &&
h.right == null &&
casHead(h, d) && // try to set
h.right != null) // recheck
casHead(d, h); // try to backout
}
并发下 HeadIndex 链的添加与删除没有用锁保护,那么如何协调插入与删除操作。对于Node链是不允许插入与删除之间相互干扰,因为我们不允许出现数据丢失的情况,这涉及到程序的正确性问题。但是对于 Index 链,带有索引节点的HeadIndex被删除是允许出现的情况,因为这只会影响性能而非正确性,所以我们要做的是尽量减少这种错误情况的发生。这里采用的是:在连续三层为空的情况下才尝试降低层级,成功后还会再次校验原先head的情况,若非空就回滚,及时的修正这一错误情况。
这可真是够麻烦的,不过比起让层级无限制增长对访问速度的影响,这还是可以忍受的。
论述插入与删除之间的协调:
一:相同位置的竞争插入
指的是像 b, c 节点插入 a -> d 链这样情况下的并发安全问题,对于Node链其next字段为volatile,插入使用的是CAS,即使这两个插入线程看到的链的情况相同,都是 a -> d ,并发下只有一个会成功,另一个失败了重试。那么假设这两个成功插入Node链后,都构建了自己的Index链,b 的Index链的插入工作是有 b 的插入线程来完成,c 也同样,Index节点的 right 是 volatile的,其插入也是利用的CAS,所以对于这一情况程序能正确处理。
二:删除节点对插入的影响
假设Node链 a -> c -> e,c 处于删除状态,b 要插入,来分情况讨论:
1,在if ((v = n.value) == null)
之前 b 看见了c.value 为null,则调用helpDelete,当 c 删除后再执行 b 的插入操作,至于 c 的Index 链,它的清除会与 b 的Index 链的插入之间产生冲突吗?有这个可能。
关于链的删除:remove方法返回前会调用 findNode 或 findPredecessor 传入删除节点的key来清除Index链,其它遍历Index二维链的线程也会主动unlink。
来分析下冲突的情况:假设某一层Index链 ia -> ic -> ie,ib 要插入,ic 要清除,都利用了CAS来更改 ia.right 的指向,所以只有一个能成功,失败一方重试。对于这一情况程序能正确处理。
2,在if ((v = n.value) == null)
之后 c 的 value 才为 null,虽然我认为应该乐观的认为不会出现这一情况,但理论上是可能的,所以这里来分析下该种情况下程序的运行状况:
b 的插入与 c 的删除都是CAS改变 a 的next指向,失败的一方再重试,至于它们的Index链之间的竞争情况类似。
3,现在假设 d 要插再c,e之间,来分析下可能出现的情况即代码的处理。
关于 d 与 marker 之间关于 c.next 的竞争由CAS+volatile来确保安全,问题在于 c 链的清除与 d 链的插入之间的冲突,这二者的CAS之间并无冲突,可能导致d 链的丢失,所以需要其它措施来避免错误,来看看代码的处理:
d 的 Index 节点 id 在插入前会进行if (n.value == null)
判断,也就是会先unlink,使 ia 指向 ie,之后再插入 id;但这仍非万全之策,比如 c 的value 在该 if 判断后才为null,这也不要紧,因为 Index#link 方法在本节点处于删除状态时不会更改其next字段,即n.value != null && casRight(succ, newSucc)
。对于这一情况程序能正确处理。
4,如果在 b 的链未完成插入,c 的链未全部清除时,a 的 value 被置为null,程序会出错吗?
删除节点的Index节点是不能执行 link 与 unlink的,对于不让删除状态节点的Index执行link的原因是:为了防止Index节点的丢失,就是 3 中的情况;对于 unlink 不让执行的原因是想让其失败以强制进行重试。
所以说程序不会出错,执行b,c的线程都会在最后执行 link/unlink 时失败而重试,每次重试都是从头开始遍历。
三元组 b -> n -> f,n 从标记为空后到 marker 插入这一阶段无论发生什么都是安全的,上面已论述过,之后变为四元组 x1 -> n -> marker -> x2,由于b 和 f 都有可能发生改变,所以这里用 x 表示,之后 n 指向 marker 的next, 与 marker 指向 x2 的 next 将不会再被改变,这点对于删除操作很重要,正是因为此我们才可以利用 casNext 来实现删除操作将 x1 指向 x2,casNext同样用于 Node 的插入。
Index 禁止空值节点执行 link 与 unlink 操作,这就使得删除与插入之间的竞争集中在删除节点 n 之前节点的 next 字段上,而对该字段的操作是CAS的,所以这一设计实现了线程安全。
当然能够实现协调的基础是重试机制。