概述
在数据结构的演化史上,有序存储与高并发访问曾是一对难以调和的矛盾。平衡二叉搜索树(如红黑树、AVL 树)提供了确定的 O(log n) 有序操作,但其复杂的旋转和变色机制在并发环境下代价高昂——锁住一棵树远比锁住一个节点困难。哈希表虽能以 O(1) 实现点查,却丧失了有序性,无法应对范围查询、排名统计等需求。跳表的出现,以“概率平衡”这一哲学突破,巧妙化解了这对矛盾:它用随机数替代复杂的旋转,用局部指针修改替代全局结构调整,在保持期望 O(log n) 的同时,天然适配细粒度锁乃至无锁并发设计。
本文将遵循“是什么→怎么用→为什么→如何用得好”的认知路径,从跳表的核心思想与随机层高算法出发,严格推导其时间复杂度的概率来源,深入解析查找、插入、删除的完整机制,并在 Java ConcurrentSkipListMap、Redis ZSet 等工业实现中印证其设计权衡。无论你是备战大厂的面试者,还是在生产系统中做出数据结构选型的架构师,都能从这篇文章中获得专家级的理解深度。
- 概率平衡的哲学:通过随机层高代替复杂的旋转/变色,在期望上保证 O(log n),实现简单、维护成本低。这是跳表最根本的设计思想。
- 有序且并发:天然支持范围查找和有序遍历,插入仅修改局部指针,锁粒度可细至单个节点,支持 CAS 无锁实现。这是跳表在工业界立足的核心竞争力。
- 性能特征:所有操作期望 O(log n),常数因子略高于红黑树(因多级索引访问),但并发场景下吞吐量可成倍领先。空间开销为期望 O(n),通常可接受。
- 工程地位:
ConcurrentSkipListMap是 Java 并发库的有序容器基石;Redis ZSet 选择跳表作为有序集合的内部结构;LevelDB/RocksDB 的 Memtable 也广泛使用跳表。 - 适用场景:高并发有序映射、实时排行榜、分布式协调服务的有序存储、需要无锁有序遍历的任何场合。
文章组织架构
flowchart TB
subgraph M1["① 概述与核心特性"]
A1["定义与 ADT"]
A2["核心特性清单"]
A3["适用场景 ≥5"]
A4["反模式 ≥3"]
A5["工业界概览"]
end
subgraph M2["② 数据结构详解"]
B1["分层结构模型"]
B2["节点与索引节点分离"]
B3["逻辑结构与物理实现"]
end
subgraph M3["③ 核心操作"]
C1["查找:向右下潜"]
C2["插入:随机层高"]
C3["删除:逐层摘除"]
end
subgraph M4["④ 概率平衡与复杂度"]
D1["随机层高几何分布"]
D2["时间复杂度严格推导"]
D3["空间复杂度分析"]
end
subgraph M5["⑤ 并发设计深度解读"]
E1["CAS 无锁实现"]
E2["标记节点与逻辑删除"]
E3["与红黑树并发对比"]
end
subgraph M6["⑥ 工程实现与最佳实践"]
F1["ConcurrentSkipListMap 使用"]
F2["选型决策框架"]
F3["Redis ZSet 实现"]
F4["避坑清单"]
end
subgraph M7["⑦ 面试高频专题"]
G1["10 道高频题"]
G2["系统设计:股票排名"]
end
M1 --> M2 --> M3 --> M4 --> M5 --> M6 --> M7
图表说明:
- 图表主旨:展示本文七大模块的递进式学习路径,从感性认知到算法内核,再到并发设计与工程落地。
- 逐层分解:
- ① 概述与核心特性:建立跳表的全局认知,包括它是什么、能做什么、不适合做什么。这是“感性认知”阶段。
- ② 数据结构详解:深入跳表的物理构成——多层链表、索引节点、数据节点的分离设计。这是“物理认知”阶段。
- ③ 核心操作:完整拆解查找、插入、删除的每一步,配以伪代码和图解。这是“算法认知”阶段。
- ④ 概率平衡与复杂度:从随机层高的几何分布出发,严格推导 O(log n) 的概率来源。这是“数学认知”阶段。
- ⑤ 并发设计:揭示 CAS 无锁实现的精妙之处,理解跳表为何是“并发友好”数据结构。这是“工程纵深”阶段。
- ⑥ 工程实践:将理论映射到 Java
ConcurrentSkipListMap、Redis ZSet 等真实实现,给出选型建议。这是“工程落地”阶段。 - ⑦ 面试专题:集中攻克高频考点,含系统设计题。这是“知识检验”阶段。
- 原理映射:跳表的学习天然需要这样一条路径——先理解“概率平衡”这个核心哲学,再层层深入算法细节,最后回到工程实践。
- 场景关联:每个模块都能独立阅读,也可按序形成完整知识体系。如果你已有基础,可以直接跳到感兴趣的模块。
- 关键结论:跳表的精粹在于“概率平衡”——用随机性替代确定性,用局部修改替代全局调整,用空间换时间,最终在并发场景下实现了对平衡树的全面超越。
模块 1:跳表概述与核心特性
1.1 定义
跳表是一种基于多层有序链表和随机化索引的概率性数据结构。它通过在原始有序链表之上建立多层稀疏索引,每层索引都是下一层的子集抽样,从而在查找时能够跳过大量无关节点,将平均时间复杂度从 O(n) 降至 O(log n)。同时,插入和删除操作仅需修改局部指针,无需像平衡树那样进行全局结构调整(旋转、变色),这使其在并发环境下具有天然的优越性。
跳表由 William Pugh 于 1990 年在论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》中首次提出。Pugh 的核心洞见是:平衡树试图用复杂的确定性规则来维持完美平衡,而跳表接受了一种“足够好”的概率平衡,从而大幅降低了实现复杂度。
1.2 抽象数据类型定义
跳表实现了一个有序映射的抽象数据类型,以下是其标准 ADT 接口:
/**
* 有序映射的抽象数据类型
* K 必须可比较(实现 Comparable 或提供 Comparator)
*/
public interface SortedMapADT<K, V> {
// 核心写入操作
V put(K key, V value); // 插入或更新键值对
V get(K key); // 按键查找
V remove(K key); // 删除键值对
boolean containsKey(K key); // 判断键是否存在
// 有序操作(跳表的核心价值)
K firstKey(); // 获取最小键
K lastKey(); // 获取最大键
K lowerKey(K key); // 获取小于 key 的最大键
K higherKey(K key); // 获取大于 key 的最小键
K floorKey(K key); // 获取 ≤ key 的最大键
K ceilingKey(K key); // 获取 ≥ key 的最小键
// 范围查询(跳表的杀手级特性)
SortedMapADT<K, V> subMap(K from, K to); // 子映射视图
// 元信息
int size(); // 元素数量
boolean isEmpty(); // 是否为空
}
关键理解:跳表的“有序性”来源于两个层面:
- 底层链表的有序性:第 0 层是一个完整的、按键排序的单向链表(或双向链表),包含所有数据节点。
- 多层索引的加速性:上层索引节点是下层节点的稀疏抽样,虽然索引本身不存储完整的键值对集合,但索引节点按相同顺序排列,从而保证了在任何层进行向右查找时,仍然遵循键的递增顺序。
1.3 核心特性清单
| 特性 | 根源 | 工程影响 |
|---|---|---|
| 期望 O(log n) 操作 | 多层索引 + 随机层高的几何分布 | 动态有序容器的高效保证,可在百万级数据下保持微秒级响应 |
| 自然支持有序遍历和范围查询 | 底层是完整有序链表 | 无需中序遍历即可顺序输出,范围查询只需 O(log n) 定位 + O(k) 遍历 |
| 无全局结构调整 | 插入仅修改局部多层指针 | 实现比平衡树简单约 40%(Pugh 论文估算),并发锁粒度可细至单节点 |
| 并发友好 | 可无锁实现,无旋转阻塞,索引更新可滞后 | 高并发下吞吐量近线性扩展,远超全局锁保护的平衡树 |
| 空间换时间 | 额外索引节点,期望额外空间 O(n) | 内存占用比纯链表高约 1 倍(p=0.5),比红黑树高约 1.5–2 倍 |
| 概率性而非确定性 | 层高由随机数决定 | 极端情况下可能退化,但概率极低(n=10⁶ 时退化概率 < 2⁻¹⁰⁰⁰⁰) |
1.4 适用场景详解
场景 1:高并发实时排行榜
问题:某竞技游戏需要维护全服玩家的实时积分排名,支持积分变更、查询玩家排名、获取 Top 100 榜单。每秒操作量 10 万+。
为什么跳表是最优解:
- 有序性天然支持排名:积分降序排列,
firstKey()获取最高分,通过subMap获取任意区间排名。 - 高写入吞吐:跳表的 CAS 无锁插入使得大量积分更新并发完成,无需全局锁竞争。
- 扩展排名计算:如在索引节点中存储 span(跨度),可将
getRank(key)优化为 O(log n),这正是 Redis ZSet 的做法。
替代方案的不足:红黑树每次插入需旋转并加锁,竞争激烈时吞吐量急剧下降;堆仅支持取堆顶,无法高效获取任意排名。
场景 2:分布式协调服务的有序存储
问题:ZooKeeper 的持久顺序节点需要在内存中维护有序集合,支持查找下一个序列号、获取某一范围的所有节点。
为什么跳表是最优解:
- 顺序节点的键是递增的序列号,跳表的
higherKey()、floorKey()等操作天然匹配此需求。 - 分布式协调服务需要高可用,跳表实现的简洁性降低了 bug 风险(相比之下平衡树的旋转边界条件复杂得多)。
场景 3:缓存系统中的过期时间排序
问题:一个本地缓存需要按过期时间排序,定期清理已过期的条目。需快速获取“最早过期的对象”并删除。
为什么跳表是最优解:
- 以过期时间为键存储缓存条目,
firstKey()返回最早过期时间,subMap(0, now)获取所有已过期条目。 - 删除操作 O(log n),且是局部修改,不影响其他缓存操作的并发执行。
- 相比之下,使用定时轮询 + 哈希表需要 O(n) 扫描所有条目。
场景 4:全文检索引擎的倒排索引合并
问题:搜索引擎中,多个词的倒排索引(有序文档 ID 列表)需要取交集(AND 操作)。
为什么跳表是最优解:
- 多路归并时,跳表的“向前跳”能力可快速跳过不匹配的文档 ID,将合并复杂度从 O(n×m) 降至接近 O(n)。
- 具体来说,在两个有序列表求交时,若列表 A 当前 ID 远小于列表 B,A 可以通过索引快速跳到 ≥ B 的位置,而非逐一遍历。
场景 5:内存数据库的有序集合
问题:Redis 的 Sorted Set 需要支持 ZADD、ZRANGE、ZRANK、ZSCORE 等操作,且必须是内存高效、操作快速的。
为什么跳表是最优解:
- Redis 选择跳表而非红黑树,核心原因有三:实现简单(Redis 是 C 语言编写,跳表约 200 行,红黑树约 400+ 行)、范围查询高效(底层双向链表直接遍历)、支持排名计算(索引节点中存储 span,实现 O(log n) 的 ZRANK)。
- 详见模块 6.4。
1.5 反模式详解
反模式 1:微小数据集纯读取场景
表现:100 个元素,启动后仅读取不更新,却使用了 ConcurrentSkipListMap。
问题分析:
- 跳表的索引建立开销和内存占用在极小数据集上不划算。有序数组的二分查找在 100 个元素时约 7 次比较,且缓存友好度极高。
- 跳表的多级指针跳转破坏了 CPU 缓存局部性,在微小数据集上性能可能落后数组二分查找 2–5 倍。
正确做法:使用 ArrayList + Collections.binarySearch(),或直接 TreeMap(单线程下有优化的红黑树实现)。
反模式 2:纯点查场景,无需有序性
表现:仅需要 get(key) 和 put(key),从不使用范围查询或有序遍历,却用了 ConcurrentSkipListMap。
问题分析:
ConcurrentHashMap提供 O(1) 平均点查性能,而跳表是 O(log n)。在 100 万数据下,这是约 20 次比较 vs 1-2 次哈希运算的差距。- 并发哈希表的分段锁/CAS 设计同样优秀,在点查场景全面优于跳表。
正确做法:使用 ConcurrentHashMap。
反模式 3:单线程且对插入延迟极度敏感
表现:单线程事件循环中高频插入,延迟预算极紧(如 P99 < 100μs),使用了跳表。
问题分析:
- 跳表的常数因子略高于红黑树:每次查找约需 20 次比较(p=0.5, n=10⁶),而红黑树约需 18 次。虽然差距不大,但在单线程无竞争时,精细实现的红黑树(如
TreeMap)通常更快。 - 跳表的索引层分配涉及随机数生成和数组分配,这些开销在插入路径上比红黑树节点染色更重。
正确做法:单线程场景优先使用 TreeMap,多线程或需要无锁设计时再选跳表。
1.6 工业界使用现状概览
| 系统/库 | 跳表用途 | 实现语言 | 特点 |
|---|---|---|---|
JDK ConcurrentSkipListMap | 并发有序映射 | Java | CAS 无锁,弱一致性迭代器,支持 NavigableMap 接口 |
JDK ConcurrentSkipListSet | 并发有序集合 | Java | 基于 ConcurrentSkipListMap 封装 |
| Redis Sorted Set | 有序集合 | C | 底层双向链表 + 索引节点含 span 字段,支持 ZRANK |
| LevelDB Memtable | 内存表 | C++ | 基于跳表,支持并发读写,key 有序 |
| RocksDB Memtable | 内存表 | C++ | 默认使用跳表,可选哈希链表 |
HBase CellSkipListSet | MemStore 内部索引 | Java | 基于跳表的多版本 Cell 索引 |
| Apache Lucene | 倒排索引合并 | Java | 多路归并使用跳表加速 |
| WiredTiger(MongoDB 引擎) | 内存索引 | C | 跳表作为内存 B+ 树的替代 |
模块 2:数据结构详解:多层索引与节点
2.1 逻辑结构与物理实现的解耦
逻辑层面,跳表是一个有序映射:用户看到的是一个按键排序的键值对集合,支持 get、put、remove、subMap。
物理层面,跳表是一个多层的链表结构:
- 第 0 层(Level 0):一个包含所有数据节点的完全有序链表,是数据的唯一真实存储。
- 第 i 层(Level i, i > 0):第 i-1 层的稀疏索引,其中每个节点指向第 i-1 层中相同键的节点(down 指针),并在本层形成有序链表(right 指针)。
- 层数越高,节点越稀疏。当 p=0.5 时,第 1 层约有 n/2 个节点,第 2 层约有 n/4 个节点,以此类推。
这种逻辑与物理分离的设计,是跳表并发能力的根基:索引节点的更新可以滞后甚至丢失,而不影响数据节点的正确性——因为所有数据都在第 0 层,索引只是加速查找的“缓存”。
2.2 节点与索引节点的分离
在跳表的经典实现和 Java ConcurrentSkipListMap 中,数据节点和索引节点是两种不同的对象:
classDiagram
class Node~K~ {
+K key
+V value
+Node~K~ next
+void setValue(V value)
+boolean casNext(Node cmp Node val)
+boolean casValue(V cmp V val)
}
class Index~K~ {
+Node~K~ node
+Index~K~ down
+Index~K~ right
+boolean casRight(Index cmp Index val)
}
class HeadIndex~K~ {
+int level
}
class SkipList~K~ {
-HeadIndex~K~ head
-int maxLevel
-Comparator comparator
+V get(K key)
+V put(K key V value)
+V remove(K key)
-Node findPredecessor(K key)
-int randomLevel()
}
Node <-- Index
Index <|-- HeadIndex
Index --> Index
Index --> Index
Node --> Node
SkipList --> HeadIndex
图表说明:
- 图表主旨:展示跳表中三种核心类——数据节点、索引节点、头索引节点的职责分离与引用关系。
- 逐层分解:
- Node(数据节点):存储真实的键值对,通过
next指针形成第 0 层的完整有序链表。提供 CAS 操作(casNext、casValue)支持无锁并发修改。 - Index(索引节点):不存储值,仅持有对数据节点的引用
node,以及right(本层向右)和down(向下一层)两个指针。索引节点形成上层稀疏索引。 - HeadIndex(头索引节点):继承自
Index,额外记录当前索引层高度level。跳表通过head字段持有最高层头节点,作为所有查找操作的入口。 - SkipList(跳表容器):持有头节点引用、最大层数限制、比较器等元信息,提供
get、put、remove等对外接口。
- Node(数据节点):存储真实的键值对,通过
- 原理映射:节点与索引节点的分离,使得索引的更新可以完全独立于数据节点。插入时,可以先在底层链表插入数据节点,然后“懒加载”地逐层添加索引——索引无论是否添加完成,查找都能正确进行(只不过可能退化为更底层的查找)。
- 场景关联:在并发场景下,两个线程可以同时修改同一数据节点附近的索引,互不干扰。
- 工程实现对应:Java
ConcurrentSkipListMap中,Node、Index、HeadIndex正是以上三个内部类,所有字段均为volatile,所有修改通过sun.misc.Unsafe的 CAS 操作完成。 - 关键结论:节点与索引节点的分离是实现无锁并发的核心设计之一——数据节点的更新与索引节点的更新解耦,索引可以滞后或丢失,但数据一致性由底层链表的 CAS 保证。
2.3 分层结构示意图
flowchart TB
subgraph L3["Level 3 (顶层)"]
H3["Head"] --> N3_1["15"] --> N3_2["63"]
end
subgraph L2["Level 2"]
H2["Head"] --> N2_1["7"] --> N2_2["15"] --> N2_3["31"] --> N2_4["63"] --> N2_5["95"]
end
subgraph L1["Level 1"]
H1["Head"] --> N1_1["3"] --> N1_2["7"] --> N1_3["11"] --> N1_4["15"] --> N1_5["23"] --> N1_6["31"] --> N1_7["47"] --> N1_8["63"] --> N1_9["79"] --> N1_10["95"]
end
subgraph L0["Level 0 (底层完整链表)"]
H0["Head"] --> N0_1["3"] --> N0_2["4"] --> N0_3["7"] --> N0_4["9"] --> N0_5["11"] --> N0_6["14"] --> N0_7["15"] --> N0_8["18"] --> N0_9["23"] --> N0_10["27"] --> N0_11["31"] --> N0_12["38"] --> N0_13["47"] --> N0_14["52"] --> N0_15["63"] --> N0_16["71"] --> N0_17["79"] --> N0_18["86"] --> N0_19["95"] --> N0_20["99"]
end
N3_1 -.-> N2_2
N3_1 -.-> N1_4
N3_1 -.-> N0_7
N3_2 -.-> N2_4
N3_2 -.-> N1_8
N3_2 -.-> N0_15
N2_1 -.-> N1_2
N2_1 -.-> N0_3
N2_2 -.-> N1_4
N2_2 -.-> N0_7
N2_3 -.-> N1_6
N2_3 -.-> N0_11
N2_4 -.-> N1_8
N2_4 -.-> N0_15
N2_5 -.-> N1_10
N2_5 -.-> N0_19
图表说明:
- 图表主旨:展示一个包含 20 个元素的跳表(键值 3–99)的完整三层索引结构,直观呈现“层越高越稀疏”的特点。
- 逐层分解:
- Level 0(底层):包含全部 20 个数据节点,按键有序排列。这是数据的唯一真实存储,即使所有上层索引被移除,跳表仍能正确工作(只是退化为 O(n) 链表查找)。
- Level 1:约 10 个节点(每 2 个抽 1 个),形成了对底层链表的稀疏索引。
- Level 2:约 5 个节点,进一步稀疏化。
- Level 3(顶层):仅 2 个节点(15 和 63),作为查找的入口加速层。
- 虚线连接:表示索引节点通过
down指针垂直连接至下一层的同名节点。例如 Level 3 的节点 15 向下指向 Level 2、Level 1、Level 0 的节点 15。
- 原理映射:跳表的高度不是固定的,而是由随机算法动态决定。当 p=0.5 时,每向上一层,节点数期望减半。20 个节点的期望层高约为 log₂(20)+1 ≈ 5,此图中最高到 Level 3 是合理的(因为实际层高取决于每个节点独立掷硬币的结果)。
- 场景关联:查找键 52 时,从 Level 3 Head 出发,向右到 15(15 < 52),再到 63(63 ≥ 52,太多了),然后下潜到 Level 2 的 15,向右经 31 到 63,再下潜,最终在底层找到 52。
- 关键结论:跳表的本质是在有序链表之上叠加了“快速公交车道”——越高的层越稀疏,能跨越的节点越多;下潜意味着切换到更密集的“慢车道”,精确定位。
模块 3:核心操作详解
3.1 查找操作
查找是跳表所有操作的基础,插入和删除都依赖查找来定位位置。
伪代码
function find(key):
// 从最高层头节点开始
current = head
// 从最高层向下遍历
for level from maxLevel down to 0:
// 在当前层向右滑动,直到下一个节点的 key >= 目标 key
while current.right != null and current.right.key < key:
current = current.right
// 如果找到目标键
if current.right != null and current.right.key == key:
return current.right.value // 数据节点的值
// 否则下潜一层
current = current.down
return null // 未找到
查找过程图解
flowchart LR
subgraph Step1["步骤1:从顶层 Head 出发"]
S1_H["Head(L3)"] -->|"right"| S1_15["15 < 52 ✓"]
end
subgraph Step2["步骤2:继续向右"]
S2_15["15(L3)"] -->|"right"| S2_63["63 ≥ 52 ✗"]
S2_15 -->|"down"| S2_15d["15(L2)"]
end
subgraph Step3["步骤3:下潜至 L2 向右"]
S3_15["15(L2)"] -->|"right"| S3_31["31 < 52 ✓"]
S3_31 -->|"right"| S3_63["63 ≥ 52 ✗"]
S3_31 -->|"down"| S3_31d["31(L1)"]
end
subgraph Step4["步骤4:下潜至 L1 向右"]
S4_31["31(L1)"] -->|"right"| S4_47["47 < 52 ✓"]
S4_47 -->|"right"| S4_63["63 ≥ 52 ✗"]
S4_47 -->|"down"| S4_47d["47(L0)"]
end
subgraph Step5["步骤5:下潜至 L0 精确定位"]
S5_47["47(L0)"] -->|"next"| S5_52["52(L0) ✓ 找到!"]
end
Step1 --> Step2 --> Step3 --> Step4 --> Step5
图表说明:
- 图表主旨:演示查找键 52 的完整路径——从最高层向右跨越、到临界点时下潜、最终在底层精确定位。
- 逐层分解:
- 步骤 1:从 Level 3 头节点出发,向右看到节点 15,15 < 52,向右移动。
- 步骤 2:在 15(L3) 向右看到 63,63 ≥ 52,停止向右。从 15 向下潜入 Level 2。
- 步骤 3:在 15(L2) 向右到 31(31 < 52),再向右到 63(63 ≥ 52,停止)。从 31 向下潜入 Level 1。
- 步骤 4:在 31(L1) 向右到 47(47 < 52),再向右到 63(63 ≥ 52,停止)。从 47 向下潜入 Level 0。
- 步骤 5:在 47(L0) 的
next恰好是 52,找到目标节点。
- 原理映射:“向右下潜”规则——若右边节点键值小于目标,向右移动;否则向下移动。这一规则保证了:① 不会越过目标;② 每次下潜时,当前节点是当前层中小于目标键的最大节点。
- 场景关联:n=20 时,查找 52 经过了 5 步。若在纯链表中需 15 步。规模越大,加速效果越明显。
- 关键结论:查找的路径长度 = 每层的向右步数之和。每层期望向右步数为 1/p(几何分布均值),总层数期望为 log₁/p(n),因此总期望比较次数 = O(log n / p)。
3.2 插入操作
插入操作分三步:查找前置节点、随机生成层高、逐层插入。
伪代码
function put(key, value):
// Step 1: 在各层记录"前驱节点"
// predecessors[i] = 第 i 层中小于 key 的最大节点
predecessors[] = 在各层查找并记录前驱节点
// Step 2: 检查键是否已存在
if predecessors[0].next.key == key:
// 更新值
predecessors[0].next.value = value
return oldValue
// Step 3: 随机生成新节点的层高
newNodeLevel = randomLevel()
// Step 4: 创建数据节点,插入第 0 层
newNode = Node(key, value)
newNode.next = predecessors[0].next
predecessors[0].next = newNode
// Step 5: 从第 1 层到 newNodeLevel 层,逐层插入索引节点
for level from 1 to newNodeLevel:
// 如果当前层不存在(跳表增高),创建新的头索引层
if level > currentMaxLevel:
createNewHeadLevel()
// 在每层插入索引节点
indexNode = Index(newNode)
indexNode.next = predecessors[level].next
indexNode.down = previousLevelIndexNode
predecessors[level].next = indexNode
随机层高算法
// ConcurrentSkipListMap 的精简实现原理
int randomLevel() {
int level = 1;
// 以概率 p 递增层高,默认 p = 0.5 (即 random() & 1)
while ((ThreadLocalRandom.current().nextInt() & 1) == 0) {
level++;
}
// 限高 31(或 32),防止过度增长
return Math.min(level, MAX_LEVEL);
}
层高分布:
- 层高 = 1 的概率:1/2(第一次随机就失败)
- 层高 = 2 的概率:1/4(成功一次后失败)
- 层高 = k 的概率:(1/2)^k
- 期望层高 = 1/(1-p) = 2(当 p=0.5 时)
插入过程图解
flowchart TB
subgraph L2["Level 2"]
direction LR
PH2["前驱节点(Level 2)"] -->|"插入新索引"| NI2["新索引节点(30)"]
end
subgraph L1["Level 1"]
direction LR
PH1["前驱节点(Level 1)"] -->|"插入新索引"| NI1["新索引节点(30)"]
end
subgraph L0["Level 0"]
direction LR
PH0["前驱节点(27)"] -->|"插入数据节点"| NN["新数据节点(30, value)"]
NN -->|"next"| ORIGNEXT["原后继(31)"]
end
NI2 -.->|"down"| NI1
NI1 -.->|"down"| NN
subgraph RandomDecision["随机决策点"]
RD["randomLevel() = 2"]
end
RandomDecision -->|"决定了索引层数"| NI1
图表说明:
- 图表主旨:展示插入键 30、层高为 2 的完整过程——从随机决策到逐层索引插入。
- 逐层分解:
- 随机决策点:调用
randomLevel()返回 2,意味着新节点需要第 0 层(数据层)和第 1 层(一层索引)。 - Level 0(数据层):在前驱节点 27 之后插入数据节点 30,
30.next指向原后继 31。无论索引层是否插入成功,数据节点必须在第 0 层插入。 - Level 1(索引层):在前驱节点之后插入索引节点 30(Index 对象),其
node引用指向数据节点,down为 null(因为下面没有同名索引了),right指向原后继索引。
- 随机决策点:调用
- 原理映射:插入只影响各层中前驱节点的后继指针,不涉及任何全局结构调整。被影响的节点数是 O(log n) 级别的——层高期望 2,实际需要修改的层数 = 随机层高。
- 场景关联:在并发环境下,多个线程可以在跳表的不同键值位置同时插入,互不干扰(只要前驱节点不重叠)。
- 关键结论:插入的局部性是跳表并发优势的根源——插入只修改 O(层高) 个节点的指针,且索引层的更新可以“后置”甚至丢失,不影响正确性(仅影响查找性能)。
3.3 删除操作
伪代码
function remove(key):
// 在各层查找前驱节点
predecessors[] = 在各层记录前驱
if predecessors[0].next == null or predecessors[0].next.key != key:
return null // 不存在
targetNode = predecessors[0].next
// 从顶层到底层,逐层摘除索引
for level from targetNode.topLevel down to 0:
while predecessors[level].next != targetNode (或其索引):
// 前驱可能被其他线程修改,需重新定位
adjust predecessor
predecessors[level].next = targetNode.next (在 level 层)
return targetNode.value
删除的精髓:
- 删除是从上到下逐层进行的,先移除高层索引,再移除底层数据节点。
- 在并发实现中(如
ConcurrentSkipListMap),删除采用逻辑删除→物理删除两步走:先在节点上打标记(marker),后续遍历和清理操作会自动跳过并回收被标记节点。这一设计保证了并发查找的正确性。
模块 4:概率平衡与复杂度分析
4.1 随机层高的数学基础
跳表使用几何分布来生成节点层高。设 p 为“升级概率”(通常 p = 0.5):
- 每个节点在加入跳表时,执行以下“抛硬币”过程:
- 起始层高 = 1
- 以概率 p 决定是否增加一层(硬币正面:升级;反面:停止)
- 重复直到第一次出现“反面”或达到最大层高
层高分布律:
P(level = k) = p^(k-1) × (1-p),k ≥ 1
验证:所有概率之和 = (1-p) + p(1-p) + p²(1-p) + ... = (1-p)/(1-p) = 1 ✓
期望层高:
E[level] = Σ k × P(level = k) = Σ k × (1-p) × p^(k-1) = 1/(1-p)
当 p = 0.5 时,E[level] = 2。这意味着平均每个节点在 2 层中存在(第 0 层 + 期望 1 层索引)。
4.2 时间复杂度推导
引理 1:第 k 层节点数的期望
第 0 层:n 个节点(所有节点) 第 1 层:n × p 个节点(约) 第 2 层:n × p² 个节点 第 k 层:n × p^k 个节点
当 n × p^k < 1 时,该层不再需要。解 n × p^k ≥ 1 得 k ≤ log₁/p(n)。
因此,最大层数期望值 = ⌈log₁/p(n)⌉。当 p=0.5, n=10⁶ 时,期望最大层数 ≈ log₂(10⁶) ≈ 20。
引理 2:每层查找的期望比较次数
在任意层 i,从一个节点开始向右移动,直到找到 ≥ 目标键的节点。由于该层是第 i+1 层中节点的“折射”,每次向右移动,目标与当前位置之间的节点数期望递减。
严格分析(参考 Pugh 论文):
- 在第 i 层,期望比较次数 = 1/p(几何分布的无记忆性)
总时间复杂度
总查找成本 = Σ(每层的比较次数) × (层数)
= O(1/p × log₁/p(n))
当 p=0.5,总查找成本 = O(2 × log₂(n)) = O(log n),常数因子约 2。
最坏情况分析:
- 理论上,如果所有节点的随机层高都是 1(概率 = (1-p)^n),跳表退化为一条普通链表,查找复杂度 O(n)。
- 对于 n=10⁶ 个节点同时 layer=1 的概率 = (0.5)^(10⁶) ≈ 10^(-301030),这是真正的“宇宙热寂”级概率,工程上不可能发生。
- Java
ConcurrentSkipListMap还通过限制最大层高(31 或 64)来硬性防止过度退化。
4.3 空间复杂度
额外空间来自索引节点。期望额外节点数:
额外节点数 = n × (p + p² + p³ + ...) = n × p/(1-p)
当 p=0.5 时,额外节点数 = n,总节点数期望 = 2n = O(n)。
不同 p 值的权衡:
| p 值 | 期望层高 | 期望最大层数 (n=10⁶) | 查找常数因子 | 额外空间 |
|---|---|---|---|---|
| 0.25 | 1.33 | ~10 | ~4 | 0.33n |
| 0.5 | 2 | ~20 | ~2 | n |
| 0.75 | 4 | ~40 | ~1.33 | 3n |
p = 0.5 是查找性能和空间开销的甜蜜点,大多数实现(包括 Java、Redis)都使用此值。
4.4 概率平衡 vs 确定性平衡
为什么跳表选择随机化而非确定性平衡?
| 维度 | 确定性平衡(红黑树) | 概率平衡(跳表) |
|---|---|---|
| 维护代价 | 每次插入/删除后需检查并执行旋转/变色,最坏 O(log n) 次旋转 | 插入仅需在随机层数内修改指针,无需任何检查 |
| 实现复杂度 | 红黑树约 400 行核心代码(含 5 种旋转场景) | 跳表约 200 行核心代码 |
| 并发友好度 | 旋转影响祖先路径,锁范围大 | 仅修改局部指针,天然细粒度 |
| 最坏情况保证 | 确定的 O(log n) | 概率的 O(log n),退化概率极低 |
| 调试难度 | 高(旋转后的红黑性质需严格维护) | 低(结构直观,错误影响局部) |
核心哲学:跳表用“随机化的运气”替代“确定性的复杂”。这类似于快速排序(随机化选主元)vs 归并排序的哲学差异——前者在概率上很好,实现简单;后者确定性地好,但需要额外空间。
模块 5:并发设计深度解读
5.1 为什么跳表天然适合并发
在并发数据结构设计中,锁粒度是关键指标。越细的锁粒度意味着越少的竞争和越高的并行度。
- 红黑树的并发困境:插入一个节点可能触发旋转,旋转影响的范围从插入节点一直到根节点。若用全局锁保护整棵树,并发吞吐量等于单线程吞吐量;若用细粒度锁(如锁耦合),实现极其复杂且容易死锁。
- 跳表的并发优势:插入一个节点只影响 O(log n) 个前驱节点的指针,且这些指针的修改是独立的——修改 Level 2 的指针不依赖 Level 1 的修改结果。因此,可以用 CAS(Compare-And-Swap)无锁实现这些修改。
5.2 ConcurrentSkipListMap 的无锁实现机制
CAS 操作
ConcurrentSkipListMap 使用 sun.misc.Unsafe 提供的 CAS 操作来原子地修改引用:
// 原子地将 node.next 从 cmp 更新为 val
boolean casNext(Node<K,V> cmp, Node<K,V> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
CAS 是无锁编程的基石:如果当前值与期望值一致,则更新为新值;否则说明其他线程已修改,需要重试。
插入的并发实现
sequenceDiagram
participant T1 as 线程1 (插入30)
participant T2 as 线程2 (插入52)
participant L0 as Level 0 链表
participant L1 as Level 1 索引
Note over T1,T2: 两个线程并发执行插入
T1->>L0: 查找30的前驱节点(27)
T2->>L0: 查找52的前驱节点(47)
T1->>L0: CAS: 27.next 从31改为30
L0-->>T1: CAS 成功!
T2->>L0: CAS: 47.next 从63改为52
L0-->>T2: CAS 成功!
Note over T1,T2: 两个插入互不影响,因前驱不同
T1->>L1: lazy插入Level 1索引
T2->>L1: lazy插入Level 1索引
Note over T1,L1: 即使索引插入失败,数据已正确存储
图表说明:
- 图表主旨:演示两个线程并发插入不同键时,各自的 CAS 操作互不干扰,实现真正的并行。
- 逐层分解:
- 查找阶段:线程 1 和线程 2 分别独立查找各自键的前驱节点。由于键不同(30 vs 52),它们的前驱节点完全不同(27 vs 47),不存在竞争。
- CAS 插入阶段:线程 1 使用 CAS 将节点 27 的
next从 31 改为 30;线程 2 同时将节点 47 的next从 63 改为 52。这两个 CAS 操作针对不同的内存地址,可以在硬件层面真正并行执行。 - 索引更新阶段:数据节点插入后,两个线程分别“懒加载”地在更高层插入索引节点。即使某个线程的索引插入因竞争失败,也不影响数据正确性——这也是跳表与平衡树的关键区别。
- 原理映射:CAS 的哲学是“乐观操作”——假设没有竞争,直接修改;如果发现有人抢先,就重试。配合跳表的局部修改特性,CAS 失败的可能性很低。
- 关键结论:跳表 + CAS = 几乎完美的无锁有序映射。在写密集型场景,ConcurrentSkipListMap 的吞吐量可以比全局锁保护的 TreeMap 高 3-10 倍,且随线程数增加几乎线性扩展。
删除的并发实现:标记节点
ConcurrentSkipListMap 的删除采用三段式:
- 标记(Mark):将目标节点的
valueCAS 设为null,表示此节点已逻辑删除。 - 插入标记节点(Marker):在目标节点后插入一个特殊的标记节点(其
value指向自己)。 - 物理删除:CAS 将前驱节点的
next跳过目标和标记节点。
查找操作在遇到 value == null 的节点时,会自动协助清理(help delete),这是典型的 Lock-Free 协作模式。
5.3 并发红黑树 vs 并发跳表
| 对比维度 | 全局锁红黑树 | 细粒度锁红黑树 | 跳表 (CAS) |
|---|---|---|---|
| 读并发度 | 1(共享锁可能多个) | 高(读写锁) | 极高(完全无锁读) |
| 写并发度 | 1 | 中(路径锁耦合) | 高(仅局部 CAS 竞争) |
| 实现复杂度 | 低 | 极高 | 中 |
| 死锁风险 | 无 | 有(锁排序) | 无(无锁) |
| 旋转影响 | 全局等待 | 路径锁定 | 无旋转 |
| 范围操作 | 需锁保护 | 需锁保护 | 弱一致性,无锁遍历 |
核心结论:在单线程或低并发场景,红黑树的常数优势使其略快;但当线程数 ≥ 4 且存在写入时,跳表几乎总是更优选择。
模块 6:工程实现与最佳实践
6.1 ConcurrentSkipListMap 核心用法
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.Map;
public class SkipListDemo {
public static void main(String[] args) {
// 1. 创建跳表(自然顺序或自定义比较器)
ConcurrentSkipListMap<String, Double> leaderboard =
new ConcurrentSkipListMap<>();
// 2. 基本操作
leaderboard.put("Alice", 9850.0);
leaderboard.put("Bob", 10200.0);
leaderboard.put("Charlie", 8700.0);
leaderboard.put("Diana", 10200.0); // 相同分数,按名字排序
leaderboard.put("Eve", 9100.0);
// 3. 有序操作——跳表的核心价值
System.out.println("=== 排行榜 ===");
// firstEntry: 分数最低(按自然序)
System.out.println("最低分: " + leaderboard.firstEntry());
// lastEntry: 分数最高
System.out.println("最高分: " + leaderboard.lastEntry());
// 4. 范围查询——subMap
System.out.println("\n=== 9000-10000 分段 ===");
Map<String, Double> midRange = leaderboard.subMap("9000", "10000");
midRange.forEach((k, v) -> System.out.println(k + ": " + v));
// 5. 导航操作:高于指定键的最小键
System.out.println("\nBob 的下一位: " +
leaderboard.higherKey("Bob"));
System.out.println("分数刚好低于 10000 的: " +
leaderboard.lowerEntry("10000"));
// 注意:由于键是 String,范围查询按字典序而非数值序!
// 如果需要按数值排序,应使用自定义 Comparator
}
}
并发环境下的弱一致性迭代
import java.util.Iterator;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.CountDownLatch;
public class WeakConsistencyDemo {
public static void main(String[] args) throws InterruptedException {
ConcurrentSkipListMap<Integer, String> map =
new ConcurrentSkipListMap<>();
// 预填充数据
for (int i = 0; i < 10; i++) {
map.put(i, "value-" + i);
}
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(2);
// 线程1:在遍历过程中插入新元素
Thread writer = new Thread(() -> {
try {
startLatch.await();
for (int i = 10; i < 200; i++) {
map.put(i, "new-value-" + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
endLatch.countDown();
}
});
// 线程2:遍历跳表
Thread reader = new Thread(() -> {
try {
startLatch.await();
int count = 0;
Iterator<Integer> it = map.keySet().iterator();
while (it.hasNext()) {
it.next();
count++;
Thread.sleep(1); // 模拟慢速遍历
}
System.out.println("遍历到的元素数: " + count);
System.out.println("实际元素数: " + map.size());
System.out.println("注意:遍历数可能 < 实际数(弱一致性)");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
endLatch.countDown();
}
});
writer.start();
reader.start();
startLatch.countDown();
endLatch.await();
}
}
运行结果解读:遍历过程中插入的新元素可能被看到,也可能被忽略,这体现了弱一致性——迭代器返回的是迭代器创建时某个时间点的快照,且不抛 ConcurrentModificationException。
6.2 跳表 vs TreeMap vs ConcurrentHashMap 选型决策
flowchart TD
START["需要键值存储"] --> Q1{"是否需要有序操作?"}
Q1 -->|"否"| HASH["使用 HashMap<br/>(单线程)或<br/>ConcurrentHashMap(多线程)"]
Q1 -->|"是(范围查询、排名等)"| Q2{"是否多线程环境?"}
Q2 -->|"否,单线程"| Q3{"写操作是否频繁?"}
Q3 -->|"以读为主"| TM_READ["TreeMap<br/>(常数因子更优,缓存友好)"]
Q3 -->|"读写均衡或写多"| TM_WRITE["TreeMap<br/>(确定性的 O(log n),<br/>单线程下无竞争开销)"]
Q2 -->|"是,多线程"| Q4{"读/写比例如何?"}
Q4 -->|"读 >> 写"| Q5{"范围查询是否频繁?"}
Q5 -->|"否"| CHM["ConcurrentHashMap<br/>+ 定期排序(如果可接受)"]
Q5 -->|"是"| CSLM_READ["ConcurrentSkipListMap<br/>(完全无锁读,范围操作自然支持)"]
Q4 -->|"写密集或均衡"| CSLM_WRITE["ConcurrentSkipListMap<br/>(几乎线性扩展的写吞吐量)"]
style CSLM_READ fill:#4CAF50,color:#fff
style CSLM_WRITE fill:#4CAF50,color:#fff
style HASH fill:#2196F3,color:#fff
style CHM fill:#2196F3,color:#fff
style TM_READ fill:#FF9800,color:#fff
style TM_WRITE fill:#FF9800,color:#fff
图表说明:
- 图表主旨:提供一个基于“线程安全需求”和“有序性需求”的选型决策树,帮助在
TreeMap、ConcurrentHashMap、ConcurrentSkipListMap之间做出选择。 - 逐层分解:
- 第一层判断——“是否需要有序”:这是最根本的区分。无序场景直接导向哈希表系列。
- 第二层判断——“是否多线程”:单线程场景下
TreeMap的常数优势使其成为更优选择。 - 第三层判断——“读写比例”:写密集型多线程场景是
ConcurrentSkipListMap的绝对优势区。
- 关键结论:ConcurrentSkipListMap 的黄金场景 = 需要有序 + 多线程 + 存在写入。三者缺一都应重新评估选型。
6.3 与 TreeMap 的性能对比
以下是一个简单的并发写入性能对比框架(实际精确测试应使用 JMH):
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class PerformanceComparison {
static final int THREAD_COUNT = 8;
static final int OPERATIONS_PER_THREAD = 100_000;
public static void main(String[] args) throws Exception {
// 测试 ConcurrentSkipListMap
ConcurrentSkipListMap<Integer, String> skipList =
new ConcurrentSkipListMap<>();
long skipTime = benchmark(skipList);
// 测试 全局锁 TreeMap
NavigableMap<Integer, String> syncTreeMap =
Collections.synchronizedNavigableMap(new TreeMap<>());
long treeTime = benchmark(syncTreeMap);
System.out.println("ConcurrentSkipListMap 耗时: " + skipTime + "ms");
System.out.println("Synchronized TreeMap 耗时: " + treeTime + "ms");
System.out.printf("跳表吞吐量是 TreeMap 的 %.1f 倍%n",
(double) treeTime / skipTime);
}
static long benchmark(Map<Integer, String> map) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
AtomicInteger counter = new AtomicInteger();
long start = System.currentTimeMillis();
for (int t = 0; t < THREAD_COUNT; t++) {
executor.submit(() -> {
for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
int key = counter.incrementAndGet();
map.put(key, "value-" + key);
// 混合 20% 读取
if (i % 5 == 0) {
map.get(key / 2);
}
}
latch.countDown();
});
}
latch.await();
executor.shutdown();
return System.currentTimeMillis() - start;
}
}
典型结果参考(4 核 CPU):
- ConcurrentSkipListMap: ~800ms
- Synchronized TreeMap: ~3500ms
- 跳表吞吐量约为全局锁 TreeMap 的 4 倍
6.4 Redis ZSet 的跳表实现点睛
Redis 有序集合在以下条件时使用跳表:
- 元素数量 > 128 或
- 任一元素长度 > 64 字节
Redis 跳表的关键增强——span 字段:
// Redis 源码中的跳表节点(简化版)
typedef struct zskiplistNode {
sds ele; // 元素成员(字符串)
double score; // 分值
struct zskiplistNode *backward; // 后退指针(双向链表)
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 跨度
} level[]; // 柔性数组,每层一个
} zskiplistNode;
span 字段的作用:记录从当前节点到本层下一个节点之间,在 Level 0 上跨越的节点数。这使得 ZRANK(获取排名)操作成为 O(log n):从顶层向下查找目标节点,累计各层的 span 值即为排名。
例如,查找排名时:
rank = 0
从顶层 head 开始
每向右移动一步,rank += current.level[i].span // 跳过 span 个节点
下潜时 rank 不变
最终 rank + 1 即为目标元素的排名
这展示了跳表的强大扩展能力——只需在索引节点中增加少量元数据,即可支持新的有序操作,而平衡树要实现同样的扩展可能涉及复杂的子树大小维护。
6.5 工程避坑清单
| 陷阱 | 表现 | 原因 | 解决方案 |
|---|---|---|---|
| 单线程滥用跳表 | 性能不如 TreeMap | 跳表的多层索引访问 + 随机数生成开销 | 单线程场景使用 TreeMap |
| 误依赖迭代器强一致性 | size()、isEmpty() 返回值变化,迭代器可能遗漏近期插入 | 跳表迭代器是弱一致性的 | 理解弱一致性语义,必要时使用显式快照或加锁 |
| 忽略 CAS 自旋的 CPU 消耗 | 极高写入竞争时 CPU 飙升 | CAS 失败 → 自旋重试 → 循环 | 识别热点键,业务层分散写入(如添加随机前缀后聚合) |
| 自定义比较器不一致 | 元素无法查找或重复 | compare(a,b) != -compare(b,a) 或与 equals 不一致 | 确保 Comparator 满足反对称性和传递性 |
| 将跳表用作队列/栈 | 频繁头尾操作性能退化 | 跳表优化的是随机查找,不是头尾操作 | 使用 ConcurrentLinkedQueue/Deque 等专用结构 |
| 忽略内存开销 | 内存占用远超预期 | p=0.5 时额外索引节点 ≈ n,每个 Index 对象有对象头开销 | 评估内存预算,考虑 Chronicle Map 等更紧凑的替代方案 |
模块 7:面试高频专题
7.1 什么是跳表?它的基本结构是怎样的?
标准回答:跳表是一种基于多层有序链表的概率性数据结构,通过在原始有序链表上建立多层稀疏索引来加速查找。第 0 层包含所有元素的完整有序链表,每向上一层都是下一层的子集抽样(通常每 2 个抽 1 个),查找时从最高层向右下潜,将时间复杂度从 O(n) 降至期望 O(log n)。
追问 1:为什么层数越高越稀疏?层高是如何确定的? 回答:每个节点在插入时通过随机函数独立决定自己的层高(p=0.5 的几何分布)。期望层高为 2,因此第 1 层约有 n/2 个节点,第 2 层 n/4 个,自然形成稀疏结构。不需要全局维护层间比例。
追问 2:如果所有节点的随机层高都是 1,会发生什么? 回答:跳表退化为普通有序链表,查找复杂度 O(n)。但概率极低——对 100 万个节点,概率约 10⁻³⁰¹⁰³⁰,宇宙年龄内不可能发生。
加分回答:跳表的层高分布与“抛硬币直到出现反面”等价,这是几何分布的无记忆性在数据结构设计中的直接应用。William Pugh 在 1990 年的论文中证明了这种随机化方法在期望上等价于完美平衡的 B 树。
7.2 如何实现跳表的查找、插入、删除?时间复杂度是多少?
标准回答:
- 查找:从最高层头节点出发,若右邻节点的键 < 目标键则向右移动,否则向下移动,直到第 0 层找到目标或确认不存在。时间 O(log n)。
- 插入:先执行查找记录各层前驱节点,随机生成新节点的层高,创建节点后在 0 到 level 层的各层前驱之后插入新节点(或索引节点)。时间 O(log n)。
- 删除:在各层找到目标节点的前驱,将它们的目标后继(目标节点或索引节点)从链表中摘除。时间 O(log n)。
追问 1:插入时为什么从底层向上插入? 回答:目的是保证查找的正确性。如果先插入上层索引后插入底层数据,并发查找可能从上层索引指向一个尚未存在的底层节点,导致空指针异常。从底层向上插入保证了“向下引用永远指向已存在的节点”。
追问 2:层高有上限吗?为什么? 回答:有,通常设为 31 或 64。理由有二:① 对于任何可处理的数据规模(如 n ≤ 2³²),log₁/p(n) 都不会超过此上限;② 防止恶意数据触发极端层高导致内存溢出。
加分回答:在并发实现(Java ConcurrentSkipListMap)中,索引节点的插入是“懒”且“后置”的——底层数据节点通过 CAS 插入成功后,逐层尝试 CAS 插入索引节点。如果某层 CAS 失败,说明其他线程已修改,直接放弃该层索引(不影响正确性,仅略影响查找性能)。这种“尽力而为而非保证”的设计是无锁编程的典型哲学。
7.3 跳表的“概率平衡”是什么意思?
标准回答:跳表不通过旋转等确定性操作来保证树形结构的平衡,而是依赖随机层高算法使节点层高服从几何分布。在期望意义上,第 i 层有 n×pⁱ 个节点,跳表高度约为 log₁/p(n),查找时需要比较的次数期望为 O(log n)。它不是保证“每次操作都是 O(log n)”,而是保证“每次操作的期望是 O(log n),且方差很小”。
追问 1:为什么不直接维护完美平衡(如每 2 个抽 1 个)? 回答:维护完美平衡需要在每次插入/删除后调整所有相关节点的层高和索引,代价与重建相当。随机化的优势在于每个节点的层高决策完全独立,插入时只需修改局部指针,不需要检查或调整其他节点的层高。
追问 2:p 值的选择有什么影响? 回答:p 越小,额外空间越小,但查找路径中的层数虽少、每层比较次数增多;p 越大则相反。p=0.5 是查找性能与空间开销的最优折中(也是 Pugh 论文推荐值)。
加分回答:跳表是“拉斯维加斯算法”(结果正确、运行时间随机)在数据结构领域的经典应用。与快速排序类似,它用随机化换取了简洁性和平均性能,但提供了极强的概率保证——退化概率随 n 指数级下降。
7.4 跳表和红黑树相比各有什么优缺点?为什么跳表更适合并发?
标准回答:
| 维度 | 跳表 | 红黑树 |
|---|---|---|
| 平均时间复杂度 | O(log n) | O(log n) |
| 最坏时间复杂度 | 概率 O(log n)(退化概率趋近 0) | 确定 O(log n) |
| 实现复杂度 | 低(~200 行核心代码) | 高(~400 行核心代码,5 种旋转场景) |
| 范围查询 | 天然高效(底层链表遍历) | 中序遍历(需递归/栈) |
| 空间开销 | O(n)(额外索引节点) | O(n)(仅数据节点) |
| 并发友好度 | 极高(局部修改,可无锁) | 低(旋转影响路径,锁范围大) |
| 单线程性能 | 略低(多级指针跳转) | 略高(缓存局部性好) |
跳表更适合并发的根本原因:插入和删除只修改局部指针,无需全局结构调整,天然适配 CAS 无锁实现;红黑树的旋转会影响从插入点到根的路径,难以无锁实现。
追问 1:如果业务确定是单线程,选哪个?
回答:TreeMap(红黑树)略优。单线程下无锁开销,且红黑树常数因子稍小。但对于需要频繁范围查询的场景,跳表的链表遍历仍有优势。
加分回答:Doug Lea 在设计 Java 并发包时,曾评估过并发红黑树的实现可能性,结论是细粒度锁红黑树的复杂度远高于跳表,且正确性难以保证。ConcurrentSkipListMap 最终成为 JDK 1.6 加入的并发有序容器。
7.5 ConcurrentSkipListMap 如何实现无锁并发?CAS 在哪些环节使用?
标准回答:ConcurrentSkipListMap 使用 Unsafe 类的 CAS 操作实现所有引用修改,主要包括:
- 数据节点插入:CAS 更新第 0 层前驱节点的
next引用。 - 索引节点插入:CAS 更新各层索引节点的
right引用。 - 逻辑删除:CAS 将节点的
value设为null。 - 物理删除:CAS 将前驱的
next跳过已标记节点。 - 节点值更新:CAS 更新已存在节点的
value。
追问 1:如果 CAS 失败怎么办? 回答:采用自旋重试(或在更高层放弃索引插入)。由于跳表的局部性,CAS 失败的场景有限(仅当其他线程修改同一前驱的后继),通常 1-2 次重试即成功。
追问 2:删除时为什么先标记再摘除? 回答:这是为了并发查找的正确性。查找线程可能正在遍历被标记的节点,此时若直接摘除,查找线程会丢失引用。标记节点充当“墓碑”,告知后续线程跳过此节点,物理摘除可以稍后安全执行。
加分回答:ConcurrentSkipListMap 还实现了一种“帮助删除”机制:查找过程中发现标记节点时,会主动协助完成物理删除。这减少了标记节点存留时间,也体现了 Lock-Free 算法的“协作”哲学——线程不仅完成自己的工作,还顺手帮助其他线程的未完成工作。
7.6 跳表在 Redis ZSet 中如何使用?如何实现 ZRANK?
标准回答:Redis 有序集合在元素较多时使用跳表作为核心数据结构。Redis 跳表的关键增强是在每个索引节点的每层添加 span 字段,记录从当前节点到本层下一个节点之间在底层跨越的节点数。执行 ZRANK 时,从顶层头节点开始,累计路径上各层的 span 值,找到目标元素后,累计的 span 即为该元素排名(从 0 开始)。
追问 1:为什么 Redis 选择跳表而非红黑树? 回答:三原因:① 实现简单(Redis 是 C 语言,跳表约 200 行,红黑树 400+ 行);② 范围查询高效(底层双向链表,ZRANGE 直接遍历即可);③ 易于扩展排名功能(添加 span 字段即可,红黑树扩展开销大)。
追问 2:Redis ZSet 在元素较少时用什么结构? 回答:使用压缩列表,这是一种紧凑的连续内存结构,在小数据量下内存效率和缓存局部性更好。当元素数量超过 128 或任一元素长度超过 64 字节时,自动转换为跳表。
加分回答:Redis 跳表的底层是双向链表(有 backward 指针),这使其支持反向遍历,如 ZREVRANGE(倒序获取范围)。这是通用跳表的设计增强,体现了数据结构在工程中的灵活适应。
7.7 跳表的空间复杂度是多少?额外索引节点影响大吗?
标准回答:跳表期望总节点数 = n(数据节点)+ n×p/(1-p)(索引节点)。当 p=0.5 时,额外索引节点约等于 n,总空间 O(2n) = O(n)。影响评估:
- 相比链表(O(n)),多约 1 倍空间
- 相比红黑树(每个节点两个子指针 + 颜色 + 父指针),实际相差不大(Java 中
TreeMap.Entry有 5 个字段,ConcurrentSkipListMap.Node + Index平均也约 5-6 个字段) - 对于百万级数据,额外内存约 16-32 MB,在现代硬件下可接受
追问:如何减少空间开销? 回答:降低 p 值(如 p=0.25)可减少索引节点,但会增大查找常数。另一思路是将索引节点数组化(每个数据节点内嵌固定长度索引数组),但会损失灵活性。
7.8 什么情况用跳表而非哈希表或平衡树?
标准回答:
- 选跳表而非哈希表:需要有序操作(范围查询、排名、前驱/后继查找)时。哈希表在这些操作上是 O(n) 的。
- 选跳表而非平衡树:多线程并发环境,特别是存在写入的场景。跳表的无锁实现可提供近乎线性扩展的吞吐量。
- 具体场景:实时排行榜、有序事件时间线、带过期的缓存、分布式协调的顺序节点存储。
追问:如果是单线程 + 需要有序,选跳表还是 TreeMap? 回答:TreeMap。单线程下无并发竞争,TreeMap 的常数因子和缓存局部性更好。跳表的优势在于并发,单线程场景无法发挥。
7.9 跳表的迭代器是强一致的吗?
标准回答:不是。ConcurrentSkipListMap 的迭代器是弱一致性的,它反映的是迭代器创建时某个时间点的快照,且不会抛出 ConcurrentModificationException。在遍历过程中,其他线程的插入可能被看到,也可能被忽略;删除的节点如果已被标记但未物理摘除,可能仍被遍历。
追问:如果需要强一致性快照怎么办? 回答:① 自行加读锁保护整个遍历(丧失并发优势);② 使用 CopyOnWrite 思想,在遍历前复制整个跳表(空间开销大);③ 设计上接受弱一致性(大多数实时系统可容忍)。
加分回答:弱一致性是高性能并发容器的共性设计选择——ConcurrentHashMap、ConcurrentLinkedQueue 等均采用了类似语义。这不是 bug,而是设计权衡:牺牲遍历时刻的完全准确性,换取无锁的高吞吐量。
7.10 系统设计:实时股票排名系统
题目:设计一个支持以下操作的实时股票交易系统:
- 查询某股票当前价格在全部股票中的排名
- 实时更新股票最新成交价(每秒数百次写入)
- 快速获取 Top 10 最活跃股票(按成交量排序)
分析跳表、堆、排序数组的优劣,并说明最终选择。
标准回答:
方案对比:
| 方案 | 更新排名 | 查询排名 | 获取 Top 10 | 并发支持 |
|---|---|---|---|---|
| 排序数组 + 二分 | O(n)(插入移动元素) | O(log n) | O(1)(取前 10) | 差(全局锁) |
| 二叉堆 | O(log n)(仅能维护堆顶) | 不支持(堆无序) | O(1)(堆顶)+ O(n) 取 10 | 差 |
| 跳表 | O(log n) | O(log n) | O(1)+O(10) | 极好(无锁) |
| 红黑树 | O(log n) | O(log n) | O(log n)+O(10) | 差(全局锁) |
最终选择跳表,理由:
- 高并发写入:每秒数百次价格更新,跳表的 CAS 无锁设计使其吞吐量随核数线性增长。
- 排名查询:通过索引节点存储 span(类似 Redis ZSet),可实现 O(log n) 排名查询。
- Top 10:获取最大 10 个只需从尾部向前遍历 10 个节点,O(1) 定位尾部 + O(10) 遍历。
- 实现可行性:可直接使用 Java
ConcurrentSkipListMap(自定义 Comparator 按成交量排序),无需从头实现。
追问 1:如何处理同一股票多次更新?
回答:使用 put 操作的覆盖语义,新成交量直接替换旧值。如果需要记录历史,可以将跳表值设为交易记录列表。
追问 2:如果数据量极大(数万股票),如何扩展? 回答:可以按行业或交易所分片,每个分片独立跳表,查询全局排名时合并各分片结果(类似 MapReduce 思想)。
加分回答:实际系统中还可以结合 优先队列 + 跳表 的混合方案:跳表维护全量排名,优先队列缓存 Top 10 热点(降低频繁 Top 10 查询对跳表的压力)。另外,如果价格更新过于频繁(如高频交易场景),可考虑使用 环形缓冲区 + 批量更新 减少跳表的 CAS 竞争次数。
延伸阅读
-
William Pugh. Skip Lists: A Probabilistic Alternative to Balanced Trees. Communications of the ACM, 1990.
跳表的原始论文,必读经典。清晰阐述了概率平衡的哲学、随机层高的几何分布证明,以及与平衡树的对比实验。论文仅 12 页,但信息密度极高。 -
Doug Lea. ConcurrentSkipListMap 源码注释(JDK 源码
java.util.concurrent包).ConcurrentSkipListMap.java中的 Javadoc 和内部注释是学习无锁跳表实现的最佳材料。Doug Lea 在注释中详细解释了设计决策(如为何使用标记节点、索引节点与数据节点的分离等),是 Lock-Free 数据结构的教科书级实现。 -
黄健宏. Redis 设计与实现(第 4 章:跳表). 机械工业出版社, 2014. 深入剖析 Redis 跳表的 C 语言实现,包括 span 字段的排名计算、插入/删除操作的代码级讲解。学习跳表如何在工程中被“魔鬼细节”优化。
-
Maurice Herlihy, Nir Shavit. The Art of Multiprocessor Programming(第 14 章:Skiplist-Based Synchronization). Morgan Kaufmann, 2012. 从多处理器编程理论角度分析跳表,涵盖了基于锁的并发跳表和 Lock-Free 跳表的完整实现,并提供了正确性证明框架。是并发数据结构领域的权威参考。
-
Fomitchev, Ruppert. Lock-Free Linked Lists and Skip Lists. Proceedings of PODC, 2004. 实现了一个完全无锁的跳表(包含无锁的索引插入/删除),相比 JDK 的“索引懒更新”更进了一步。适合对 Lock-Free 算法有深入研究兴趣的读者。
结语
跳表是数据结构领域“少即是多”哲学的完美体现。它没有红黑树那般精巧繁复的平衡规则,却以概率平衡化繁为简;它没有哈希表 O(1) 的直接,却在有序性上提供了不可替代的价值。更重要的是,在并发成为常态的今天,跳表以局部修改的天性,优雅地拥抱了无锁并发的浪潮。
从 William Pugh 1990 年的论文,到 Doug Lea 在 JDK 中的精心实现,再到 Redis、LevelDB 等基础设施的广泛采用,跳表用三十多年的工程实践证明:有时,接受一点点不确定性,换来的是架构上的极大简化与性能上的巨大飞跃。