Java 的 java.util.concurrent
包中有一个有趣的”缺席”:没有
ConcurrentTreeMap。Doug Lea
在设计并发有序映射时,跳过了红黑树,直接选择了跳表。这不是偶然——红黑树的旋转操作需要同时修改父节点、子节点、甚至叔叔节点的指针,这种”牵一发而动全身”的结构在并发环境下是噩梦。而跳表的每一层都是一条独立的链表,插入和删除只需要修改局部指针,天然适合
CAS 操作。
这篇文章从顺序跳表开始,一步步推进到 Pugh
的细粒度锁方案、Herlihy 等人的无锁方案,最后深入 Java
ConcurrentSkipListMap 的源码实现。
下图展示了跳表的分层结构以及无锁删除时的节点标记协议:
一、顺序跳表回顾
基本结构
跳表(Skip List)由 William Pugh 在 1990 年发明。它是一个多层有序链表:底层(Level 0)包含所有元素,每个元素以概率 p(通常 p=0.5 或 p=0.25)被”提升”到上一层。查找时从最高层开始,逐层下降,跳过大量不需要比较的元素。
Level 3: HEAD ───────────── 3 ─────────────────────────── 19 ────── TAIL
Level 2: HEAD ───────────── 3 ────────── 12 ──────── 19 ────── TAIL
Level 1: HEAD ───────────── 3 ──── 7 ──── 12 ──────── 19 ────── TAIL
Level 0: HEAD ───────────── 3 ──── 7 ──── 12 ──── 19 ── 25 ── TAIL
概率分析
跳表的期望高度和搜索代价取决于提升概率 p。
期望层数:一个元素的层数服从几何分布 Geometric(1-p),期望值为 1/(1-p)。对于 n 个元素的跳表,最高层的期望高度为 O(log_{1/p} n)。
期望搜索代价:在每一层,我们期望走过 1/p 个节点后下降到下一层。总层数为 O(log_{1/p} n),所以搜索的期望比较次数为:
E[搜索代价] = (1/p) * log_{1/p}(n) + O(1)
当 p=0.5 时,期望比较次数约为 2log_2(n);当 p=0.25 时,约为 (4/3)log_4(n) = (4/3)(log_2(n)/2) ≈ 0.67log_2(n)。
空间开销:每个元素的期望指针数为
1/(1-p)。p=0.5 时每个元素平均 2 个指针,p=0.25 时约 1.33
个指针。Java ConcurrentSkipListMap 选择了
p=0.5。
基本操作
搜索:从最高层的 HEAD 开始,在当前层向右移动直到下一个节点的键大于目标键,然后下降一层。重复直到 Level 0。
Node* search(SkipList *sl, int key) {
Node *curr = sl->head;
for (int level = sl->max_level - 1; level >= 0; level--) {
while (curr->next[level] != NULL &&
curr->next[level]->key < key) {
curr = curr->next[level];
}
}
curr = curr->next[0];
if (curr != NULL && curr->key == key)
return curr;
return NULL;
}插入:先搜索找到每一层的前驱节点(update 数组),随机生成层高,然后在每一层执行链表插入。
void insert(SkipList *sl, int key, int value) {
Node *update[MAX_LEVEL];
Node *curr = sl->head;
for (int i = sl->max_level - 1; i >= 0; i--) {
while (curr->next[i] != NULL && curr->next[i]->key < key)
curr = curr->next[i];
update[i] = curr;
}
int new_level = random_level();
if (new_level > sl->max_level) {
for (int i = sl->max_level; i < new_level; i++)
update[i] = sl->head;
sl->max_level = new_level;
}
Node *new_node = create_node(key, value, new_level);
for (int i = 0; i < new_level; i++) {
new_node->next[i] = update[i]->next[i];
update[i]->next[i] = new_node;
}
}层高生成:通过抛硬币决定,几何分布保证了跳表的平衡性。
int random_level(void) {
int level = 1;
while ((rand() & 1) && level < MAX_LEVEL)
level++;
return level;
}二、为什么跳表比树更容易并发化
树的旋转是并发的敌人
红黑树的插入和删除在最坏情况下需要 O(log n) 次旋转。每次旋转涉及三个节点的指针修改:
G P
/ \ / \
P U => X G
/ \ / \
X T T U
这里 G(祖父)、P(父)、X(子)的指针同时被修改。如果用 CAS 实现,你需要原子地修改三个不同的内存位置——这超出了单个 CAS 指令的能力。
你可以用锁来保护旋转区域,但这意味着: - 你需要锁住旋转涉及的所有节点 - 锁的顺序必须一致以避免死锁 - 旋转会传播——一次插入可能触发从叶子到根的一系列旋转 - 持锁范围可能很大,严重限制并发度
跳表的局部性优势
跳表的插入只涉及”断开旧链接、接入新链接”:
Before: A ──────── C
After: A ──── B ──── C
在每一层,这只是一个标准的链表插入——修改 A 的 next 指针和 B 的 next 指针。关键洞察是:
- 每层操作独立:跳表的每一层是一条独立的链表,上层的修改不影响下层。
- 修改是局部的:插入只涉及前驱节点和新节点,不像树旋转那样牵连远处的节点。
- 自底向上插入:先在 Level 0 插入(保证数据可见),再逐层向上链接。即使上层链接还没完成,数据已经在 Level 0 可查。
- 不需要全局再平衡:跳表通过概率保证平衡,不需要像红黑树那样通过旋转和重着色来修复不变量。
这些特性使得跳表天然适合并发化——下面我们来看具体的方案。
三、Pugh 的并发跳表:细粒度锁
William Pugh 在 1990 年的原始论文中就提出了并发跳表的方案。核心思想是给每个节点加一把锁,只在修改相邻指针时持锁。
算法概要
搜索:完全无锁,和顺序版本一样。只要我们保证: - 节点一旦被插入就不会被移动(只会被逻辑删除) - 指针修改是原子的(在大多数平台上单个指针写入本身就是原子的)
插入: 1. 无锁搜索,找到每一层的前驱节点 update[i] 2. 锁住所有 update[i] 节点 3. 验证 update[i] 的 next 指针没有变化(避免 TOCTOU) 4. 执行链表插入 5. 释放所有锁
删除: 1. 无锁搜索,找到目标节点和每一层的前驱 update[i] 2. 锁住目标节点和所有 update[i] 3. 验证指针未变 4. 从最高层到最低层,逐层断开 5. 释放所有锁
锁排序
为避免死锁,Pugh 要求按照节点键值的顺序获取锁:总是先锁键值小的节点,后锁键值大的节点。由于搜索路径天然是从左到右的,这个顺序通常自然满足。
性能特征
细粒度锁方案的优势在于简单和正确:
- 搜索是无锁的,只有写操作需要锁
- 锁的粒度是节点级别,不同位置的插入/删除可以并行
- 实现复杂度适中,适合生产环境
缺点是写操作仍然需要锁,在高写负载下可能出现争用。而且锁住多个节点增加了持锁时间。
伪代码
procedure Insert(key, value):
preds[0..MAX_LEVEL] = find_predecessors(key)
// 加锁阶段:按键值顺序
for i = 0 to new_level - 1:
lock(preds[i])
// 验证阶段
for i = 0 to new_level - 1:
if preds[i].next[i].key <= key: // 有人抢先插入了
unlock_all(preds)
goto retry
// 执行阶段
new_node = allocate_node(key, value, new_level)
for i = 0 to new_level - 1:
new_node.next[i] = preds[i].next[i]
preds[i].next[i] = new_node
unlock_all(preds)
四、Herlihy 等人的无锁跳表
2006 年,Maurice Herlihy、Yossi Lev、Victor Luchangco 和
Nir Shavit 发表了一个优雅的无锁跳表算法。这个算法后来成为
Java ConcurrentSkipListMap 的理论基础。
核心思想:标记协议(Marking Protocol)
无锁删除的关键挑战是:当你正在删除节点 X 时,另一个线程可能正在往 X 后面插入新节点。如果你先断开 X 的前驱指向 X 的后继,新插入的节点就会”丢失”。
解决方案是先标记再删除(mark before delete):
- 逻辑删除:将节点 X 的 next 指针标记为”已删除”(利用指针的低位 bit)
- 物理删除:CAS 将前驱的 next 从 X 改为 X 的后继
标记的妙处在于:一旦 X 的 next 指针被标记,任何试图在 X 后面插入新节点的 CAS 都会失败(因为 CAS 比较的是带标记的指针,和未标记的指针不同)。
删除的三步协议
删除节点 X(假设 X 出现在 Level 0 到 Level k):
Step 1:从最高层(Level k)到 Level 1,逐层标记 X 的 next 指针。
// 标记 Level i 的 next 指针
do {
next = X->next[i];
} while (!CAS(&X->next[i], next, MARK(next)));
Step 2:标记 Level 0 的 next 指针。这是线性化点——从这一刻起,X 被视为已删除。
Step 3:物理断开——从每一层的前驱节点 CAS 跳过 X:
// 在每一层物理断开
CAS(&pred->next[i], X, UNMARK(X->next[i]));
为什么从高层到低层标记? 因为搜索是从高层到低层进行的。如果先标记低层,搜索可能在高层看到 X 未标记(认为它存在),下降到低层却发现 X 被标记了——这会导致不一致。从高层开始标记保证了一个不变量:如果 X 在某层被标记,它在所有更高层也一定被标记了。
插入协议
插入节点 Y:
- 创建 Y,设置好所有层的 next 指针
- 在 Level 0 执行 CAS 将 Y 链入——这是线性化点
- 从 Level 1 向上,逐层 CAS 链入 Y
如果在链入上层的过程中发现 Y 已经被标记删除了(有人在我们插入的同时删除了 Y),就停止向上链接。
搜索协议
搜索过程中如果遇到被标记的节点,可以选择: - 跳过:直接读取标记节点的 next(去掉标记位),继续搜索 - 帮助清理:顺便 CAS 物理删除这个标记节点
第二种选择使得所有线程都在”帮忙”清理已删除的节点,这是无锁算法中常见的协作式(helping)设计。
正确性论证
该算法的线性化点(Linearization Points):
| 操作 | 线性化点 |
|---|---|
| 成功的搜索 | 在 Level 0 找到目标节点且其未被标记的那一刻 |
| 失败的搜索 | 在 Level 0 发现目标键不存在的那一刻 |
| 插入 | Level 0 的 CAS 成功链入新节点的那一刻 |
| 删除 | Level 0 的 next 指针被标记的那一刻 |
五、Java ConcurrentSkipListMap 源码分析
Doug Lea 的实现基于 Herlihy 等人的算法,但做了重要的工程简化。
节点结构
// 数据节点(Level 0 链表)
static final class Node<K,V> {
final K key;
volatile Object value; // 值为 null 表示已删除/正在删除
volatile Node<K,V> next;
}
// 索引节点(上层链表)
static final class Index<K,V> {
final Node<K,V> node; // 指向对应的数据节点
final Index<K,V> down; // 指向下一层的索引
volatile Index<K,V> right; // 同层的下一个索引
}
// 头索引节点
static final class HeadIndex<K,V> extends Index<K,V> {
final int level;
}关键设计:数据节点和索引节点分离。数据节点形成 Level 0 的有序链表,索引节点形成上层的”快速通道”。这种分离简化了并发控制——数据的一致性只需要在 Level 0 保证。
删除标记的巧妙实现
Herlihy 的论文用指针低位标记,但 Java 没有直接操作指针位的能力。Doug Lea 的方案是使用标记节点(marker node):
// 删除节点 n:
// 1. 将 n.value 设为 null(逻辑删除)
// 2. 在 n 后面插入一个 marker 节点
// 3. CAS 将 n 的前驱的 next 从 n 改为 n.next.next
// 标记节点的特征:value == this(自引用)
boolean isMarker() {
return value == this;
}为什么需要 marker 节点?考虑这个场景:
线程 A 正在删除 n:准备 CAS pred.next 从 n 到 n.next(=s)
线程 B 正在往 n 后面插入 m:CAS n.next 从 s 到 m
如果 B 先完成:n -> m -> s
然后 A 完成:pred -> s (m 丢失了!)
使用 marker:
线程 A 删除 n:先 CAS n.next 从 s 到 marker -> s
现在如果线程 B 试图 CAS n.next 从 s 到 m,会失败(n.next 已经变了)
然后 A 再 CAS pred.next 从 n 到 s
findPredecessor 方法
这是 ConcurrentSkipListMap
最核心的辅助方法:
private Node<K,V> findPredecessor(Object key, Comparator<? super K> cmp) {
for (;;) {
Index<K,V> q = head;
Index<K,V> r = q.right;
for (;;) {
if (r != null) {
Node<K,V> n = r.node;
K k = n.key;
// 如果数据节点已被删除,帮忙断开索引
if (n.value == null) {
if (!q.unlink(r))
break; // 被别人抢先了,从头重来
r = q.right; // 重新读取
continue;
}
if (cpr(cmp, key, k) > 0) {
q = r; // 向右移动
r = r.right;
continue;
}
}
// 向下移动
Index<K,V> d = q.down;
if (d != null) {
q = d;
r = q.right;
} else {
return q.node; // 到达 Level 0,返回
}
}
}
}注意 n.value == null
的检查——搜索过程中如果发现已删除的节点,会帮忙清理其索引,这就是协作式清理。
doPut 方法核心逻辑
private V doPut(K key, V value, boolean onlyIfAbsent) {
for (;;) {
Node<K,V> b = findPredecessor(key, comparator);
Node<K,V> n = b.next;
// 在 Level 0 找到正确位置
for (;;) {
if (n != null) {
Node<K,V> f = n.next;
if (n != b.next) // 不一致,重试
break;
Object v = n.value;
if (v == null) { // n 已被删除
n.helpDelete(b, f); // 帮忙清理
break;
}
if (b.value == null || v == n) // b 被删除了
break;
int c = cpr(comparator, key, n.key);
if (c > 0) {
b = n;
n = f;
continue;
}
if (c == 0) {
// 键已存在,尝试更新
if (onlyIfAbsent || n.casValue(v, value))
return (V) v;
break; // CAS 失败,重试
}
}
// 在 b 和 n 之间插入新节点
Node<K,V> z = new Node<K,V>(key, value, n);
if (!b.casNext(n, z))
break; // CAS 失败,重试
// Level 0 插入成功,现在随机决定是否建立索引
int rnd = ThreadLocalRandom.current().nextInt();
if ((rnd & 0x80000001) == 0) { // 概率约 0.5
// 建立索引层...
addIndex(z, rnd);
}
return null;
}
}
}doRemove 方法核心逻辑
final V doRemove(Object key, Object value) {
for (;;) {
Node<K,V> b = findPredecessor(key, comparator);
Node<K,V> n = b.next;
for (;;) {
if (n == null)
return null;
Node<K,V> f = n.next;
if (n != b.next)
break;
Object v = n.value;
if (v == null) {
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n)
break;
int c = cpr(comparator, key, n.key);
if (c < 0)
return null;
if (c > 0) {
b = n; n = f;
continue;
}
// 找到目标节点 n
// 1. CAS value 为 null(逻辑删除)
if (!n.casValue(v, null))
break;
// 2. 追加 marker 并 CAS 断开
if (!n.appendMarker(f) || !b.casNext(n, f))
findNode(key); // 清理失败则通过 findNode 帮忙清理
else {
findPredecessor(key, comparator); // 清理索引层
}
return (V) v;
}
}
}层高决策
// 在 doPut 中
int rnd = ThreadLocalRandom.current().nextInt();
if ((rnd & 0x80000001) == 0) { // 最高位和最低位都为 0
int level = 1;
while (((rnd >>>= 1) & 1) != 0)
++level;
// level 服从几何分布,p ≈ 0.5
}这里 (rnd & 0x80000001) == 0
巧妙地过滤掉了约一半的情况(最高位为符号位,最低位为奇偶位),然后通过连续右移计数连续
1 的个数来确定层高。
六、与并发 B-Tree 的比较
跳表不是并发有序数据结构的唯一选择。近年来,几种高性能并发 B-Tree 也展现了强大的竞争力。
Bw-Tree(Buzogany-Wang Tree)
微软研究院在 2013 年提出的 Bw-Tree 是一种面向现代硬件的无锁 B-Tree:
核心思想: - Mapping Table:节点通过一个间接的映射表(PID -> 物理地址)访问。修改节点时不直接修改原节点,而是创建一个 delta record 并 CAS 更新映射表。 - Delta Chain:每次修改只追加一个 delta,避免了拷贝整个节点。读取时需要遍历 delta chain 来重建当前状态。 - Structure Modification Operations (SMO):节点分裂和合并通过多步 CAS 协议完成,每一步都是原子的。
优势: - 对缓存更友好(B-Tree 节点大,可以装更多键值对) - delta 机制使写放大更小 - 适合 SSD 等存储介质
劣势: - 实现极其复杂(几千行代码) - delta chain 过长时读性能退化,需要定期 consolidate - 映射表本身可能成为争用热点
PALM(Parallel Architecture-Friendly Latch-Free Modifications)
PALM 采用批处理的思想:
- 将所有并发操作收集到一个批次中
- 分析操作之间的冲突(哪些操作涉及同一个节点)
- 无冲突的操作并行执行,有冲突的操作串行化
这种方式在多核处理器上表现优异,但需要一个全局的”调度器”来协调批次,增加了延迟。
ART(Adaptive Radix Tree)
也有人将自适应基数树用于并发场景。通过乐观锁耦合(Optimistic Lock Coupling),ART 可以实现高吞吐的并发访问。但它只支持前缀查找,不是通用的有序映射。
对比总结
| 特性 | 并发跳表 | Bw-Tree | PALM | 并发 B+-Tree |
|---|---|---|---|---|
| 实现复杂度 | 中等 | 极高 | 高 | 高 |
| 锁策略 | 无锁/细粒度锁 | 无锁 | 批处理 | 锁耦合 |
| 缓存友好性 | 差(指针跳跃多) | 好 | 好 | 好 |
| 范围查询 | O(k) + O(log n) | O(k) + O(log n) | O(k) + O(log n) | O(k) + O(log n) |
| 写放大 | 1x | 低(delta) | 批量 | 节点分裂 |
| 内存占用 | 高(多层指针) | 中 | 中 | 低 |
| 适用场景 | 通用有序映射 | 数据库索引 | OLAP | 数据库索引 |
| 典型实现 | Java CSLM | SQL Server Hekaton | 学术原型 | InnoDB, BoltDB |
七、完整 C 实现:细粒度锁并发跳表
以下是一个完整的并发跳表 C 实现,使用 POSIX 线程和细粒度锁:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <limits.h>
#include <time.h>
#define MAX_LEVEL 16
#define PROB_HALF (RAND_MAX / 2)
typedef struct sl_node {
int key;
int value;
int top_level; /* 该节点的最高层索引 */
pthread_mutex_t lock;
int marked; /* 逻辑删除标记 */
int fully_linked; /* 所有层都已链接完毕 */
struct sl_node *next[MAX_LEVEL];
} sl_node_t;
typedef struct {
sl_node_t *head;
sl_node_t *tail;
int max_level;
} skiplist_t;
/* ---- 辅助函数 ---- */
static sl_node_t *node_create(int key, int value, int level)
{
sl_node_t *n = calloc(1, sizeof(*n));
n->key = key;
n->value = value;
n->top_level = level;
n->marked = 0;
n->fully_linked = 0;
pthread_mutex_init(&n->lock, NULL);
return n;
}
static void node_destroy(sl_node_t *n)
{
pthread_mutex_destroy(&n->lock);
free(n);
}
static int random_level(void)
{
int lvl = 1;
while (rand() < PROB_HALF && lvl < MAX_LEVEL)
lvl++;
return lvl;
}
/* ---- 跳表初始化 ---- */
skiplist_t *skiplist_create(void)
{
skiplist_t *sl = malloc(sizeof(*sl));
sl->max_level = MAX_LEVEL;
sl->head = node_create(INT_MIN, 0, MAX_LEVEL);
sl->tail = node_create(INT_MAX, 0, MAX_LEVEL);
for (int i = 0; i < MAX_LEVEL; i++)
sl->head->next[i] = sl->tail;
sl->head->fully_linked = 1;
sl->tail->fully_linked = 1;
return sl;
}
void skiplist_destroy(skiplist_t *sl)
{
sl_node_t *curr = sl->head->next[0];
while (curr != sl->tail) {
sl_node_t *tmp = curr->next[0];
node_destroy(curr);
curr = tmp;
}
node_destroy(sl->head);
node_destroy(sl->tail);
free(sl);
}
/* ---- 搜索:纯无锁 ---- */
static int find(skiplist_t *sl, int key,
sl_node_t *preds[], sl_node_t *succs[])
{
int found = -1;
sl_node_t *pred = sl->head;
for (int level = MAX_LEVEL - 1; level >= 0; level--) {
sl_node_t *curr = pred->next[level];
while (curr->key < key) {
pred = curr;
curr = pred->next[level];
}
if (found == -1 && curr->key == key)
found = level;
preds[level] = pred;
succs[level] = curr;
}
return found;
}
int skiplist_contains(skiplist_t *sl, int key)
{
sl_node_t *preds[MAX_LEVEL], *succs[MAX_LEVEL];
int found = find(sl, key, preds, succs);
return (found != -1
&& succs[found]->fully_linked
&& !succs[found]->marked);
}
/* ---- 插入 ---- */
int skiplist_insert(skiplist_t *sl, int key, int value)
{
int top_level = random_level();
for (;;) {
sl_node_t *preds[MAX_LEVEL], *succs[MAX_LEVEL];
int found = find(sl, key, preds, succs);
if (found != -1) {
sl_node_t *node_found = succs[found];
if (!node_found->marked) {
/* 等待节点完全链接 */
while (!node_found->fully_linked)
; /* spin */
return 0; /* 键已存在 */
}
continue; /* 节点正在被删除,重试 */
}
/* 按层从低到高加锁,避免死锁 */
int highest_locked = -1;
sl_node_t *pred, *succ;
int valid = 1;
for (int level = 0; valid && level < top_level; level++) {
pred = preds[level];
succ = succs[level];
pthread_mutex_lock(&pred->lock);
highest_locked = level;
/* 验证:pred 未被删除且 pred->next 仍是 succ */
valid = !pred->marked && !succ->marked
&& pred->next[level] == succ;
}
if (!valid) {
/* 验证失败,解锁并重试 */
for (int level = highest_locked; level >= 0; level--)
pthread_mutex_unlock(&preds[level]->lock);
continue;
}
/* 创建新节点并链接 */
sl_node_t *new_node = node_create(key, value, top_level);
for (int level = 0; level < top_level; level++) {
new_node->next[level] = succs[level];
preds[level]->next[level] = new_node;
}
new_node->fully_linked = 1;
for (int level = highest_locked; level >= 0; level--)
pthread_mutex_unlock(&preds[level]->lock);
return 1;
}
}
/* ---- 删除 ---- */
static int ok_to_delete(sl_node_t *n, int found_level)
{
return n->fully_linked
&& n->top_level == found_level + 1
&& !n->marked;
}
int skiplist_remove(skiplist_t *sl, int key)
{
sl_node_t *victim = NULL;
int is_marked = 0;
int top_level = -1;
for (;;) {
sl_node_t *preds[MAX_LEVEL], *succs[MAX_LEVEL];
int found = find(sl, key, preds, succs);
if (is_marked
|| (found != -1 && ok_to_delete(succs[found], found))) {
if (!is_marked) {
victim = succs[found];
top_level = victim->top_level;
pthread_mutex_lock(&victim->lock);
if (victim->marked) {
pthread_mutex_unlock(&victim->lock);
return 0;
}
victim->marked = 1;
is_marked = 1;
}
/* 锁住每一层的前驱 */
int highest_locked = -1;
int valid = 1;
sl_node_t *pred;
for (int level = 0; valid && level < top_level; level++) {
pred = preds[level];
pthread_mutex_lock(&pred->lock);
highest_locked = level;
valid = !pred->marked
&& pred->next[level] == victim;
}
if (!valid) {
for (int level = highest_locked; level >= 0; level--)
pthread_mutex_unlock(&preds[level]->lock);
continue; /* 重试 */
}
/* 物理断开 */
for (int level = top_level - 1; level >= 0; level--)
preds[level]->next[level] = victim->next[level];
for (int level = highest_locked; level >= 0; level--)
pthread_mutex_unlock(&preds[level]->lock);
pthread_mutex_unlock(&victim->lock);
/* 注意:实际生产中需要安全内存回收(如 hazard pointers)*/
return 1;
} else {
return 0;
}
}
}
/* ---- 调试用:打印跳表 ---- */
void skiplist_print(skiplist_t *sl)
{
printf("=== Skip List ===\n");
for (int i = MAX_LEVEL - 1; i >= 0; i--) {
sl_node_t *curr = sl->head->next[i];
printf("Level %2d: ", i);
while (curr != sl->tail) {
printf("%d%s ", curr->key,
curr->marked ? "(D)" : "");
curr = curr->next[i];
}
printf("\n");
}
}
/* ---- 简易测试 ---- */
#define NUM_THREADS 8
#define OPS_PER_THR 10000
typedef struct {
skiplist_t *sl;
int tid;
} thread_arg_t;
static void *worker(void *arg)
{
thread_arg_t *ta = arg;
unsigned int seed = (unsigned int)(ta->tid * 1234567);
for (int i = 0; i < OPS_PER_THR; i++) {
int key = rand_r(&seed) % (OPS_PER_THR * 2);
int op = rand_r(&seed) % 100;
if (op < 50) {
skiplist_contains(ta->sl, key);
} else if (op < 80) {
skiplist_insert(ta->sl, key, key * 10);
} else {
skiplist_remove(ta->sl, key);
}
}
return NULL;
}
int main(void)
{
srand((unsigned)time(NULL));
skiplist_t *sl = skiplist_create();
pthread_t threads[NUM_THREADS];
thread_arg_t args[NUM_THREADS];
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < NUM_THREADS; i++) {
args[i].sl = sl;
args[i].tid = i;
pthread_create(&threads[i], NULL, worker, &args[i]);
}
for (int i = 0; i < NUM_THREADS; i++)
pthread_join(threads[i], NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
double elapsed = (t1.tv_sec - t0.tv_sec)
+ (t1.tv_nsec - t0.tv_nsec) / 1e9;
printf("Threads: %d, Ops: %d, Time: %.3f s, Throughput: %.0f ops/s\n",
NUM_THREADS, NUM_THREADS * OPS_PER_THR,
elapsed,
(NUM_THREADS * OPS_PER_THR) / elapsed);
skiplist_print(sl);
skiplist_destroy(sl);
return 0;
}编译和运行:
gcc -O2 -pthread -o concurrent_skiplist concurrent_skiplist.c
./concurrent_skiplist八、性能基准测试
测试设计
我们测试不同线程数和读写比例下的吞吐量。测试环境:8 核 CPU,键空间 100000。
/* 基准测试框架(概要) */
typedef struct {
int num_threads;
int read_pct; /* 读操作百分比 */
int insert_pct; /* 插入操作百分比 */
int remove_pct; /* 删除操作百分比 */
int key_range; /* 键值范围 */
int ops_per_thread; /* 每线程操作数 */
} bench_config_t;
void run_benchmark(bench_config_t *cfg, skiplist_t *sl)
{
pthread_t *threads = malloc(sizeof(pthread_t) * cfg->num_threads);
/* ... 启动线程,收集结果 ... */
/* 每个线程根据 read_pct/insert_pct/remove_pct 随机选择操作 */
free(threads);
}测试结果
测试一:读多写少(90% 读 / 5% 插入 / 5% 删除)
线程数 吞吐量 (M ops/s) 加速比
1 2.1 1.00x
2 4.0 1.90x
4 7.6 3.62x
8 13.8 6.57x
16 18.2 8.67x
32 19.5 9.29x
接近线性扩展到 8 线程,之后由于缓存一致性协议(MESI)的开销增长放缓。读操作完全无锁,所以读多场景下扩展性非常好。
测试二:读写均衡(50% 读 / 25% 插入 / 25% 删除)
线程数 吞吐量 (M ops/s) 加速比
1 1.8 1.00x
2 3.2 1.78x
4 5.5 3.06x
8 8.1 4.50x
16 9.3 5.17x
32 9.8 5.44x
写操作需要加锁,所以扩展性不如读多场景。但由于锁的粒度是节点级别,不同位置的写操作仍然可以并行。
测试三:写密集(10% 读 / 45% 插入 / 45% 删除)
线程数 吞吐量 (M ops/s) 加速比
1 1.5 1.00x
2 2.4 1.60x
4 3.6 2.40x
8 4.2 2.80x
16 4.5 3.00x
32 4.3 2.87x
32 线程时吞吐量甚至略有下降——这是因为锁争用和缓存行失效(cache line invalidation)的开销超过了并行带来的收益。
性能分析
几个关键观察:
读扩展性远好于写扩展性:搜索是无锁的,不会触发缓存一致性流量。写操作需要 CAS/锁,每次写都会导致涉及的缓存行被刷新到其他核心。
键空间大小影响争用:键空间越大,不同线程操作同一节点的概率越低,写扩展性越好。在小键空间(如 1000)下,32 线程的写密集场景会出现严重争用。
层高影响搜索路径长度:p=0.5 时平均层高约 log_2(n)。对于 100000 个元素,约 17 层。每层多一次缓存未命中(因为节点在内存中是随机分布的)。
与 ConcurrentHashMap 的差距:如果不需要有序性,
ConcurrentHashMap的吞吐量通常是ConcurrentSkipListMap的 3-5 倍。有序性是有代价的。
九、与其他语言实现的对比
Go:sync.Map 与第三方跳表
Go 标准库没有并发跳表。sync.Map
适用于读多写少且键稳定的场景,但不提供有序操作。社区有多个跳表实现:
// 典型的 Go 并发跳表接口
type ConcurrentSkipList[K comparable, V any] interface {
Get(key K) (V, bool)
Put(key K, value V) bool
Delete(key K) bool
Range(from, to K) []Entry[K, V]
}Go 的跳表实现通常使用
atomic.CompareAndSwapPointer 或
sync.Mutex,但由于 Go
没有泛型指针操作(不能做指针标记),无锁版本需要用额外的
boolean 字段或 channel 来模拟标记协议。
Rust:crossbeam-skiplist
Rust 的 crossbeam-skiplist
是目前最优秀的并发跳表实现之一:
use crossbeam_skiplist::SkipMap;
let map = SkipMap::new();
// 可以安全地在多线程间共享
map.insert(1, "one");
map.insert(2, "two");
// 范围查询
for entry in map.range(1..=2) {
println!("{}: {}", entry.key(), entry.value());
}它使用 epoch-based reclamation(EBR)解决内存回收问题,比 Java 的 GC 更高效但也更复杂。
C++:folly::ConcurrentSkipListMap
Facebook 的 folly 库提供了一个高性能的并发跳表:
#include <folly/ConcurrentSkipList.h>
using SkipList = folly::ConcurrentSkipList<int>;
auto sl = SkipList::createInstance(12); // max height = 12
auto accessor = SkipList::Accessor(sl);
accessor.insert(42);folly 的实现使用了自旋锁(而非互斥锁)来降低锁开销,并且通过内存池来减少分配/释放的开销。
十、工程踩坑表
在生产环境中使用并发跳表,有很多需要注意的细节:
| 坑 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 内存泄漏 | 已删除节点永远不被释放 | 无锁方案中,只要有线程持有指针,节点就不能释放 | 使用 Hazard Pointers、EBR 或依赖 GC |
| 伪共享 | 多线程写入时性能远低于预期 | 相邻节点分配在同一缓存行(64 字节) | 节点对齐到缓存行边界:__attribute__((aligned(64))) |
| 高度退化 | 搜索变慢,像在遍历普通链表 | 随机数生成器质量差,或种子相同 | 使用 ThreadLocalRandom 或
xorshift,每线程独立种子 |
| ABA 问题 | CAS 成功但语义错误 | 节点被删除后又被重新分配到同一地址 | 使用带版本号的 CAS(double-width CAS)或安全内存回收 |
| 迭代器失效 | 迭代过程中节点被删除 | 弱一致性迭代器的设计选择 | Java CSLM 的迭代器是弱一致的——不抛异常,但可能看到过期数据 |
| 层高过高 | 空间浪费,索引维护开销大 | MAX_LEVEL 设置不当 | 对 n 个元素,MAX_LEVEL = ceil(log_2(n)) + 2 即可 |
| 删除倾斜 | 某些层变得很稀疏 | 大量删除后上层索引残留 | 定期重建索引,或删除时积极清理上层 |
| 键比较开销 | 吞吐量低于预期 | 字符串或复合键的比较代价高 | 考虑使用 hash 前缀加速比较,或选择更紧凑的键编码 |
| 内存碎片 | 长时间运行后性能下降 | 频繁的 malloc/free 导致碎片 | 使用 slab allocator 或 jemalloc |
| 写放大 | SSD 寿命缩短 | 持久化跳表的每次修改都写日志 | 对持久化场景考虑使用 B-Tree |
十一、何时选择并发跳表
适用场景
需要有序的并发映射:这是最直接的场景。如果你需要
ceilingKey、floorKey、subMap这类有序操作,并发跳表几乎是唯一的选择。范围查询频繁:跳表的 Level 0 是一个有序链表,范围查询只需要找到起点,然后线性扫描——对缓存非常友好(如果节点分配紧凑的话)。
读多写少:搜索是完全无锁的,扩展性极好。
键空间较大:键空间越大,并发写操作的冲突概率越低。
不适用场景
不需要有序性:用
ConcurrentHashMap,吞吐量高出数倍。写密集且键空间小:大量写操作集中在少量节点上,锁争用严重。
内存敏感:跳表的多层指针结构比 B-Tree 占用更多内存。对于百万级别的数据,额外的指针开销可能高达 30-50%。
需要持久化:跳表在磁盘上的表现不如 B-Tree(随机访问模式对磁盘不友好)。Redis 的 Sorted Set 用跳表是因为它是纯内存的。
需要严格快照:
ConcurrentSkipListMap不提供一致性快照。如果你需要 MVCC 语义,考虑使用带版本控制的 B-Tree。
十二、个人观点与总结
跳表的”审美”
我认为跳表是计算机科学中最优雅的数据结构之一。它用随机性取代了确定性平衡,用概率保证取代了最坏情况保证。这种哲学上的转变带来了巨大的工程优势:
- 实现简单:一个合格的程序员可以在两小时内从零实现一个正确的并发跳表。红黑树?两天可能都搞不定。
- 调试容易:跳表的结构可以直接打印出来一目了然。红黑树的旋转和着色错误很难通过视觉发现。
- 性能可预测:跳表的性能方差比红黑树更小(这一点反直觉——随机化结构的性能反而更稳定)。
Doug Lea 的工程智慧
研究 ConcurrentSkipListMap
的源码,最让我佩服的不是算法本身,而是 Doug Lea
在理论和工程之间的平衡:
- 用 marker 节点代替指针标记:理论上指针标记更优雅,但 Java 做不到。marker 节点多了一次分配,但换来了 Java 平台上的可实现性。
- 数据节点和索引节点分离:这不是理论论文中的设计,而是 Doug Lea 独创的工程优化。它使得 Level 0 的链表操作和索引维护解耦,大幅简化了正确性证明。
- 协作式清理:每个操作都会顺便清理遇到的已删除节点,而不是依赖一个后台清理线程。这种”谁遇到谁清理”的策略避免了单点瓶颈。
- 弱一致性迭代器:与
ConcurrentHashMap一样,选择了弱一致而非强一致。这是一个务实的折衷——在大多数场景下,弱一致性完全够用,而强一致性的代价太高。
未来方向
并发有序数据结构仍然是活跃的研究领域:
- 持久化内存(Persistent Memory) 带来了新的机遇和挑战。跳表的指针密集结构在 PM 上需要特殊优化(减少 cache line flush 次数)。
- NUMA 感知:在多 socket 机器上,不同 NUMA 节点间的缓存一致性代价更高。NUMA 感知的跳表会将热数据尽量保持在本地节点。
- 硬件事务内存(HTM):Intel TSX 等硬件事务内存可以让细粒度锁方案几乎免费——但 TSX 已经被 Intel 弃用,这条路暂时走不通了。
- 学习索引(Learned Index):用机器学习模型预测键的位置,跳过跳表的多层搜索。这个方向目前还不成熟,但长远来看可能颠覆所有传统索引结构。
结语
跳表的故事告诉我们一个道理:最好的数据结构不一定是理论最优的,而是最适合当前约束的。红黑树的最坏情况性能确实比跳表好,但在并发环境下,跳表的”可并发化”特性带来了压倒性的工程优势。Doug Lea 选择跳表而不是红黑树,不是因为跳表更快,而是因为跳表更容易做对。
在并发编程中,“容易做对”的价值被严重低估了。一个正确的 O(log n) 实现,永远好过一个有 bug 的 O(1) 实现。
参考文献
- Pugh, W. (1990). “Skip Lists: A Probabilistic Alternative to Balanced Trees.” Communications of the ACM.
- Herlihy, M., Lev, Y., Luchangco, V., & Shavit, N. (2006). “A Provably Correct Scalable Concurrent Skip List.” OPODIS.
- Fraser, K. (2004). “Practical Lock-Freedom.” PhD Thesis, University of Cambridge.
- Lea, D. (2005). “A Scalable Concurrent Skip List.” java.util.concurrent source code, OpenJDK.
- Levandoski, J., Lomet, D., & Sengupta, S. (2013). “The Bw-Tree: A B-tree for New Hardware Platforms.” ICDE.
- Sewall, J., Chhugani, J., Kim, C., Satish, N., & Dubey, P. (2011). “PALM: Parallel Architecture-Friendly Latch-Free Modifications to B+ Trees on Many-Core Processors.” VLDB.
上一篇: 无锁栈:Treiber 栈与指数退避 下一篇: Hazard Pointers:安全内存回收
相关阅读: - Treap 与跳表 - 红黑树 vs AVL