单线程下若想使用有序的键值对,我们选用 TreeMap,若是考虑到线程安全问题,则可以使用 ConcurrentSkipListMap,它与ConcurrentSkipListSet之间的关系就如 TreeMap 与 TreeSet 之家的关系一样,set 由 map来实现,本篇主要来分析 ConcurrentSkipListMap。
底层数据结构是 跳表 Skip List:
- 跳表分为多层,层级越高跳跃性越大,数据越少
- 跳表的层级是通过“掷硬币”方式来决定增长的,也就是说在增加元素时,概率增长层数
- 查找数据时,按照从上到下,从左往右的顺序查找
- 时间复杂度O(log n),空间复杂度O(n)
跳表的实现:
三种类型的节点:
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