概述
ConcurrentSkipListMap 是 Java 并发集合框架中唯一同时实现 ConcurrentMap 和 NavigableMap 的容器。它基于 跳表(SkipList) 数据结构,通过 概率性多层索引 与 无锁 CAS 操作,在极高并发度下提供 O(log n) 的有序键值存取。与 TreeMap 的红黑树旋转平衡不同,跳表通过在链表中随机提升节点层高来维持概率平衡,将平衡维护从全局结构调整化解为局部指针改动,天然适配并发环境。本文以 JDK 8 源码为基石,从跳表数学原理到 findPredecessor 的无锁查找,从 doPut 的索引随机构建到 doRemove 的两阶段标记移除,从索引惰性清理到子映射视图的弱一致性迭代器,全景展示其设计精髓。
- 跳表(SkipList)的概率性平衡:通过 ThreadLocalRandom 随机决定节点层高,每层上升概率为 0.5,使得平均索引高度为 2,在数学期望上实现 O(log n) 且免去红黑树的复杂旋转。
- Index 与 Node 的分离设计:
Node为底层有序单向链表节点,Index为上层索引节点(含 right/down 指针),两者解耦使索引维护不影响数据节点的并发访问。 - 无锁 CAS 并发控制:写操作通过
CAS自旋修改Node.next、Node.value或Index.right,删除采用标记-物理删除两阶段,读操作完全无锁,仅依赖volatile可见性。 - 导航与范围视图的高效支持:
lowerKey、floorKey、ceilingKey、higherKey基于findPredecessor快速定位,subMap、headMap、tailMap视图共享底层跳表,提供 O(log n) 定位与近似 O(k) 的区间遍历。 - 内存与性能的权衡:多层索引额外消耗约每个节点 2 个 Index 引用(p=0.5 时),但换取了高并发下有序操作的高吞吐,适用于需要并发范围查询、排行榜、延迟队列的场景。
全文组织架构图如下:
flowchart TB
subgraph Part1[基础认知篇]
A1[定义与核心特性]
A2[适用场景决策树]
A3[接口与继承体系]
end
subgraph Part2[存储与构造篇]
B1[存储结构与跳表原理]
B2[构造方法与初始化]
end
subgraph Part3[核心原理篇]
C1[findPredecessor 查找核心]
C2[doPut 插入与随机索引]
C3[doRemove 两阶段删除]
C4[doGet 与导航方法]
end
subgraph Part4[索引维护与视图篇]
D1[索引惰性清理与降级]
D2[子映射视图与迭代器]
end
subgraph Part5[对比与陷阱篇]
E1[vs TreeMap]
E2[vs ConcurrentHashMap]
E3[常见陷阱与最佳实践]
end
subgraph Part6[总结与面试篇]
F1[性能总结]
F2[面试高频专题]
end
Part1 --> Part2 --> Part3 --> Part4 --> Part5 --> Part6
图表说明
- 六大篇章递进:从基础概念(Part1)到数据结构(Part2),再深入核心操作源码(Part3),进而探讨索引维护与视图机制(Part4),最后横向对比并汇总实践陷阱(Part5),以性能总结和面试专题收束(Part6)。
- 主脉络:整篇文章围绕 “概率平衡的跳表结构” 与 “CAS 无锁并发控制” 两条核心主线展开,所有模块最终都服务于深入理解这两个主题。
- 源码映射:Part3 中每一个流程图节点都对应 JDK 8 中
ConcurrentSkipListMap的具体方法(如findPredecessor、doPut、doRemove),读者可按图索骥查阅源码。
Part 1:基础认知篇
模块 1:定义、核心特性与适用场景
定义
ConcurrentSkipListMap<K,V> 是 java.util.concurrent 包下基于跳表 (SkipList) 实现的、线程安全的、有序的、高并发的映射。它实现 ConcurrentNavigableMap<K,V> 接口,同时满足 ConcurrentMap 和 NavigableMap 两大契约,既支持原子性的 putIfAbsent、replace、remove(key, value) 等并发方法,又具备 lowerEntry、floorKey、subMap 等丰富的导航式操作。
核心特性
- 严格有序:键按照
Comparable自然顺序或构造时提供的Comparator升序排列,跳表底层链表始终保持键的唯一递增序。 - 无锁读与 CAS 写:所有读操作(
get、containsKey、导航方法)完全不获取锁,仅通过volatile读保证可见性;写操作通过sun.misc.Unsafe提供的 CAS 机制自旋修改指针或值,最小化线程阻塞。 - O(log n) 平均时间复杂度:得益于概率性多层索引,
put、get、remove、containsKey以及导航方法均具有对数级别期望性能。 - 导航方法与范围视图:提供
lowerKey、floorKey、ceilingKey、higherKey等精准导航,以及subMap、headMap、tailMap返回的动态区间视图,视图修改立即可见。 - 弱一致性迭代器:迭代器遍历底层链表,不获取任何锁,不会抛出
ConcurrentModificationException,但不保证完全反映遍历过程中的所有并发修改。 - 禁止 null 键值:因需要比较键进行排序,null 无法调用
compareTo或传给Comparator,且内部使用 null 标记节点删除,故键和值均不可为 null。
适用场景决策树
graph TD
Start(["需要并发映射"]) --> NeedOrder{"是否需要有序遍历或范围查询"}
NeedOrder -->|"否"| CHM["ConcurrentHashMap O1 平均 无序"]
Start --> Volume{"是否仅单线程"}
Volume -->|"是"| TreeMap["TreeMap 无需线程安全 内存更小"]
NeedOrder -->|"是"| Freq{"是否需要频繁导航 lower/higher/subMap"}
Freq -->|"是"| CSLM["ConcurrentSkipListMap Olog n 有序 高并发"]
Freq -->|"否"| MaybeCHM["可考虑 ConcurrentHashMap 但需手动排序"]
CSLM --> Scenarios["典型场景 排行榜 延迟队列 区间统计"]
图表说明
- 第一层判断:并发与否。若仅在单线程下使用,
TreeMap内存开销更小,无需任何同步,优先选择。多线程则必须考虑并发容器。 - 第二层判断:排序需求。如果仅需快速键值存取且不关心顺序,
ConcurrentHashMap提供平均 O(1) 的 get/put,是首选。 - 第三层判断:导航操作频率。一旦业务涉及
lowerKey(找小于给定键的最大键)、floorKey(找小于等于的最大键)、ceilingKey(找大于等于的最小键)、higherKey(找大于的最小键) 或subMap(获取动态区间映射),ConcurrentSkipListMap 是唯一能将这些操作的复杂度控制在 O(log n) 的高并发容器。 - 反例场景:读远多于写且数据量极小的并发有序场景,目前 JDK 未提供 CopyOnWrite 系列的有序映射,但仍可通过
ConcurrentSkipListMap结合写时复制思想自行实现。
模块 2:接口与继承体系
ConcurrentSkipListMap 的接口层次设计是理解其能力的关键:
classDiagram
class ConcurrentMap~K,V~ {
<<interface>>
+putIfAbsent(K, V) V
+remove(Object, Object) boolean
+replace(K, V, V) boolean
+replace(K, V) V
+getOrDefault(Object, V) V
+forEach(BiConsumer) void
}
class NavigableMap~K,V~ {
<<interface>>
+lowerEntry(K) Map.Entry
+floorEntry(K) Map.Entry
+ceilingEntry(K) Map.Entry
+higherEntry(K) Map.Entry
+pollFirstEntry() Map.Entry
+pollLastEntry() Map.Entry
+descendingMap() NavigableMap
+navigableKeySet() NavigableSet
+subMap(K, boolean, K, boolean) NavigableMap
+headMap(K, boolean) NavigableMap
+tailMap(K, boolean) NavigableMap
}
class ConcurrentNavigableMap~K,V~ {
<<interface>>
+subMap(K, K) ConcurrentNavigableMap
+headMap(K) ConcurrentNavigableMap
+tailMap(K) ConcurrentNavigableMap
}
class ConcurrentSkipListMap~K,V~ {
-HeadIndex head
-Comparator comparator
+ConcurrentSkipListMap()
+get(Object) V
+put(K, V) V
+remove(Object) V
+lowerKey(K) K
+floorKey(K) K
}
class Cloneable {
<<interface>>
}
class Serializable {
<<interface>>
}
ConcurrentMap <|-- ConcurrentNavigableMap
NavigableMap <|-- ConcurrentNavigableMap
ConcurrentNavigableMap <|-- ConcurrentSkipListMap
Cloneable <|.. ConcurrentSkipListMap
Serializable <|.. ConcurrentSkipListMap
图表说明
- 双亲接口合并:
ConcurrentNavigableMap通过多重继承将ConcurrentMap(原子并发操作)和NavigableMap(有序导航)合二为一,是并发有序映射的统一抽象。 - 容器能力概括:因此 ConcurrentSkipListMap 既能在原子性上保证
replace、compute等方法的线程安全,又能在结构上支持descendingMap(降序映射)、pollFirstEntry(弹出最小键)等操作。 - 内部实现要点:ConcurrentSkipListMap 完全从底层构建跳表结构,不继承
AbstractMap,直接实现接口,以最大程度控制内部并发细节。其内部类SubMap也实现了ConcurrentNavigableMap,提供区间视图。
Part 2:存储与构造篇
模块 3:存储结构与跳表原理(源码剖析)
3.1 三大核心内部类
跳表的存储实体由三种节点构成,各自独立且相互关联:
| 类名 | 用途 | 关键字段 |
|---|---|---|
Node<K,V> | 底层有序链表节点,持有实际键值对 | final K key; volatile V value; volatile Node<K,V> next; |
Index<K,V> | 索引节点,不存储数据,构建多层索引链表 | final Node<K,V> node; final Index<K,V> down; volatile Index<K,V> right; |
HeadIndex<K,V> | 继承自 Index,作为每层索引的头节点,额外记录当前跳表总层数 | final int level; 继承自 Index 的 node、down、right |
classDiagram
class Node~K,V~ {
-K key
-V volatile value
-Node volatile next
+casValue(V, V) boolean
+casNext(Node, Node) boolean
+helpDelete(Node, Node) void
}
class Index~K,V~ {
-Node node
-Index down
-Index volatile right
+casRight(Index, Index) boolean
}
class HeadIndex~K,V~ {
-int level
}
Node --> Index : node 指向
Index --> Index : down
Index --> Index : right
Index <|-- HeadIndex
图表说明
- 分离设计:
Node只负责数据间的单向连接,Index在其上构建多层横向链表,二者字段互不耦合。这意味着修改索引层并不直接干扰数据节点指针,并发读写可以各行其是。 - volatile 仅关键字段:注意
Node.value和Node.next为 volatile,保证写操作对其他线程立即可见;Index.right也为 volatile,而Index.down和Index.node为 final,在构造时设定后不再改变,线程安全得以简化。 - HeadIndex 的 level:此字段维护跳表的最高层数,是动态升降的依据,多线程通过 CAS 竞争修改。
3.2 跳表的概率结构
跳表将有序链表升级为多层索引,每一层都是单向链表,高层索引比低层更稀疏,查找时可“大步跨越”再下沉精细定位。
Level 2: HEAD ---------------------> 30 ---------> NULL
Level 1: HEAD -------> 15 -------> 30 ---------> NULL
Level 0: HEAD -> 5 -> 10 -> 15 -> 20 -> 25 -> 30 -> 35 -> NULL (Node链表)
↑索引指向底层Node
索引建立规则:插入新数据节点时,以概率 p = 0.5 决定是否为其创建一层索引,若成功再以 p 的概率继续向上层创建,直到随机失败或达到最大层数(MAX_LEVEL 通常为 31)。这样,一个节点拥有 k 层索引的概率为 (1-p)·p^k,整张跳表的索引总层数期望为 log_{1/p}(n)。JDK 8 使用 ThreadLocalRandom.current().nextInt() 生成随机数,通过检查最低位是否为 0 来模拟 p=0.5 的伯努利实验。
与红黑树的关键区别:红黑树通过复杂的旋转与染色保持严格平衡,插入删除可能涉及多个节点的父/子/颜色指针修改,影响范围较大,在多核并发时很难用细粒度锁控制。跳表插入仅影响相邻节点指针和随机高度的索引层,没有全局制约,因此可以做到仅对少数指针进行 CAS 操作即完成并发写。
模块 4:构造方法与初始化
ConcurrentSkipListMap 提供四种构造器,核心初始化逻辑收敛于私有方法 initialize()。
// 构造器1: 自然排序
public ConcurrentSkipListMap() {
this.comparator = null;
initialize();
}
// 构造器2: 指定比较器
public ConcurrentSkipListMap(Comparator<? super K> comparator) {
this.comparator = comparator;
initialize();
}
// 构造器3: 由Map批量插入
public ConcurrentSkipListMap(Map<? extends K, ? extends V> m) {
this.comparator = null;
initialize();
putAll(m);
}
// 构造器4: 由SortedMap优化插入(因已知顺序)
public ConcurrentSkipListMap(SortedMap<K, ? extends V> m) {
this.comparator = m.comparator();
initialize();
buildFromSorted(m);
}
initialize() 方法源码解析:
private void initialize() {
keySet = null;
entrySet = null;
values = null;
descendingMap = null;
// 创建底层哨兵节点,key 为 null,value 为 null,next 也为 null
head = new HeadIndex<K,V>(new Node<K,V>(null, BASE_HEADER, null),
null, null, 1);
}
初始化要点:
- 创建一个
Node作为底层链表的头哨兵(键为 null,值使用BASE_HEADER常量标识),其next初始为 null。 - 将此 Node 包装进一个
HeadIndex,该 HeadIndex 的down和right均为 null,level设为 1,表示跳表目前只有 1 层索引(即 Level 0 底层链表 + Level 1 索引层,但初始 Level 1 索引空)。 - 所有后续插入都会在此基础上搭建更高层索引。
Part 3:核心原理篇
模块 5:findPredecessor——跳表查找的前置核心(源码剖析)
findPredecessor(Object key, Comparator<? super K> cmp) 是几乎所有操作的“眼睛”。它从最高层索引开始,向右、向下遍历,最终返回底层链表中严格小于指定 key 的最大节点(或头哨兵如果不存在),供 doPut、doRemove、doGet 等使用。该方法完全不获取锁,仅依赖 volatile 读和 CAS 辅助清理。
graph TD
A["开始 q = head"] --> B{"获取 r = q.right"}
B -->|"r 非空"| C{"比较 key 与 r.node.key"}
C -->|"key 大于 r.node.key"| D["向右移动 q = r"]
D --> B
C -->|"key 不大于 r.node.key"| E{"获取 d = q.down"}
B -->|"r 为空"| E
E -->|"d 非空"| F["下沉 q = d"]
F --> B
E -->|"d 为空"| G["退出索引循环"]
G --> H["当前 q 指向底层某索引 q.node 为候选前驱"]
H --> I["沿 Node 链表向后扫描 跳过已删除节点 value 为空"]
I --> J["返回确定的前驱节点"]
源码流程详解(对应 JDK 8 ConcurrentSkipListMap.findPredecessor):
private Node<K,V> findPredecessor(Object key, Comparator<? super K> cmp) {
if (key == null)
throw new NullPointerException();
for (;;) {
for (Index<K,V> q = head, r = q.right, d;;) {
// 第一层:横向遍历
if (r != null) {
Node<K,V> n = r.node;
K k = n.key;
if (n.value == null) { // 索引指向的节点已被标记删除
if (!q.unlink(r)) // 尝试跳过 r,失败则重试整个循环
break;
r = q.right; // 重新读取
continue;
}
if (cpr(cmp, key, k) > 0) { // key > k,向右移动
q = r;
r = r.right;
continue;
}
}
// 第二层:向下沉
if ((d = q.down) == null)
return q.node; // 达到底层,返回 Node
q = d; // 下沉
r = d.right;
}
}
}
层次详解
- 外层无限循环:当内层因 CAS 失败
break时,会重新从 head 开始整个查找过程,保证活锁不会无限受阻。 - 索引横向移动:比较当前索引右节点
r.node.key与目标 key。若 key 大于右节点键,则可安全右移(因为右节点仍需找更小的前驱);若 key 小于等于,则应在当前索引左侧继续,即停止右移准备下沉。 - 过期索引清理:当发现
r.node.value == null时,说明该索引指向的数据已被逻辑删除。调用q.unlink(r)尝试通过 CAS 将q.right从r改为r.right,实现跳过过期索引。这一步的失败意味着并发修改,需 break 重试。 - 下沉:当无法右移(或右指针为空)时,检查
q.down。若存在,则进入更细一层索引。若已无 down(即到达底层索引或 HeadIndex 直连的哨兵层),则返回q.node作为候选前驱。 - 底层 Node 链表扫描:返回的候选前驱仍可能不是严格小于 key 的最大节点,因为并发删除可能改变后继。因此外部调用(如
doPut)会在拿到此节点后,在底层链表上继续向后跳过标记删除节点,直到找到确切位置。 - 关键特点:整个过程中 没有任何 synchronized 块或 Lock,仅用
volatile读(right、value、down)与 CAS(unlink),实现了无锁查询与辅助清理。
模块 6:doPut 插入——CAS 竞争与索引随机构建(源码剖析)
doPut(K key, V value, boolean onlyIfAbsent) 是整个容器并发插入的核心,融合了 无锁链表插入 与 概率索引构建 两个关键阶段。
graph TD
S(["doPut key value onlyIfAbsent"]) --> F["调用 findPredecessor 获取前驱 b"]
F --> L{"b.next 存在且 key 大于 b.next.key"}
L -->|"是"| Move["向前移动 b = b.next"]
Move --> L
L -->|"否 到达定位点"| C{"b.next 为空 或 key 小于 b.next.key"}
C -->|"是"| Insert["创建新 Node n n.next = b.next"]
Insert --> CAS1["CAS 将 b.next 从 n.next 改为 n"]
CAS1 -->|"成功"| Random["生成随机层高 rnd 使用 ThreadLocalRandom"]
CAS1 -->|"失败"| F
C -->|"否 即 b.next.key 等于 key"| Exist{"b.next.value 不为空"}
Exist -->|"是 有效节点"| Replace{"onlyIfAbsent"}
Replace -->|"否"| CAS2["CAS 更新 n.value"]
Replace -->|"是"| RetOld["返回旧值"]
CAS2 -->|"失败"| Exist
Exist -->|"否 已标记删除"| Help["helpDelete 并重试"]
Random --> Level{"判断是否需要提升层高 rnd 最低位为零"}
Level -->|"是 且未超最大层"| BuildIdx["创建垂直 Index 列 从 Level 1 开始向上"]
BuildIdx --> AddIdx["调用 addIndex 逐层通过 casRight 插入"]
Level -->|"否"| ReturnNew["返回 null 插入成功"]
源码阶段一:数据节点插入
// 简化 doPut 插入链表部分
for (Node<K,V> b = findPredecessor(key, cmp), n = b.next;;) {
if (n != null) {
Node<K,V> f = n.next;
if (n == b.next) { // 一致性检查
Object v = n.value;
if (v == null) { // n 已被标记删除,帮助推进物理删除
n.helpDelete(b, f);
break;
}
int c = cpr(cmp, key, n.key);
if (c > 0) { // key 大于 n.key,向后移动
b = n;
n = f;
continue;
}
if (c == 0) { // 键已存在
if (onlyIfAbsent || n.casValue(v, value))
return v;
break; // 重试
}
// c < 0 说明 key < n.key,应在 b 和 n 之间插入
}
}
// 创建新节点并尝试 CAS 插入
Node<K,V> z = new Node<K,V>(key, value, n);
if (!b.casNext(n, z))
break; // 失败,重试外循环
// 插入成功,跳出,进入索引构建
break;
}
阶段二:随机索引构建
int rnd = ThreadLocalRandom.current().nextInt(); // 0x80000000 随机数
if ((rnd & 0x80000001) == 0) { // 最高位和最低位都为0时才建立索引
int level = 1;
// 每右移一位,检查最低位是否为0,是则层高+1
while (((rnd >>>= 1) & 1) != 0)
++level;
Index<K,V> idx = null;
HeadIndex<K,V> h = head;
// 构建垂直 Index 列
if (level <= (max = h.level)) {
for (int i = 1; i <= level; ++i)
idx = new Index<K,V>(z, idx, null);
} else { // 需要增加新层
level = max + 1;
HeadIndex<K,V> newh = new HeadIndex<K,V>(oldbase, oldh, null, level);
// CAS 更新 head
if (casHead(h, newh))
h = newh;
idx = new Index<K,V>(z, idx, null);
}
// 将 idx 插入各层索引链表 (调用 addIndex)
addIndex(idx, h, level);
}
层次详解
- 链表插入的无锁保证:
findPredecessor返回候选前驱b,然后在b.next上循环比较。键冲突时尝试 CAS 替换 value;无冲突时通过b.casNext(n, z)原子地将新节点连入。任何一步 CAS 失败都会导致 break 并从头重试,这是典型的 Lock-Free 模式。 - 概率性索引构建的数学原理:随机数
rnd经过两个判断:(rnd & 0x80000001) == 0初步筛选出约 1/4 的节点参与构建;之后通过while (((rnd >>>= 1) & 1) != 0)逐位检查,每满足一次层高加 1。由于每位独立且为 1 的概率为 0.5,所以层高为 k 的概率约为 (1/2)^k,实现了p=0.5 的几何分布。 - 索引的垂直构建:从第一层起向上构造
Index链,new Index(z, idx, null)将当前索引指向新数据节点z,down 指向下一层索引。若层高超出当前 head.level,则 CAS 创建更高层的 HeadIndex,以完成跳表增高。 - addIndex 插入索引:
addIndex(idx, h, level)遍历各层索引,通过casRight将新索引节点连入相应层的正确位置。这一过程同样是 CAS 自旋,无全局锁。
模块 7:doRemove 删除——标记-物理移除两阶段(源码剖析)
删除操作必须保证正在遍历的读线程不会遇到断裂的指针,因此 ConcurrentSkipListMap 采用两阶段策略。
stateDiagram-v2
[*] --> Active: 节点存活 (value != null)
Active --> Marked: CAS value 设为 null (标记删除)
Marked --> Removed: CAS 前驱 next 跳过该节点 (物理移除)
Removed --> [*]
Marked --> Removed_Helper: 其他线程调用 helpDelete 辅助移除
Removed_Helper --> Removed: 完成物理移除
源码 process:
final V doRemove(Object key, Object value) {
// 第一阶段:定位并标记
for (;;) {
Node<K,V> b = findPredecessor(key, cmp), 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 已被别人标记,辅助物理删除
n.helpDelete(b, f);
break;
}
if (b.value == null || v == n) // b 被删除?
break;
int c = cpr(cmp, key, n.key);
if (c < 0) return null; // 不存在
if (c > 0) { b = n; n = f; continue; } // 向后
// 键匹配,若指定了value则再比较
if (value != null && !value.equals(v)) return null;
// 尝试标记删除:将 n.value 从 v CAS 为 null
if (!n.casValue(v, null)) break; // 失败则重试
// ----- 第二阶段:物理移除 -----
// 先尝试直接跳过 n
if (!b.casNext(n, f))
findPredecessor(key, cmp); // 如果失败,借助查找清理
// 若有索引,尝试减小 level
tryReduceLevel();
return (V)v;
}
}
}
层次详解
- 第一阶段:逻辑删除(标记)。通过
n.casValue(v, null)将节点 value 字段置为 null。这是原子操作,一旦成功,该节点在所有读线程眼中就变成“无效”,但节点仍然物理存在于链表上,保证遍历的连续性。 - 第二阶段:物理删除(摘除)。执行
b.casNext(n, f),将前驱的 next 指针从n修改为n.next,使节点n完全脱离链表。如果 CAS 失败(说明前驱已变),则不强行重试,因为后续的findPredecessor或helpDelete会在遍历时完成清理。 - helpDelete 协同机制:当线程在执行
doPut、doGet或findPredecessor时发现某个节点 value 为 null,会主动调用helpDelete帮助完成物理移除,形成协作式清除,避免“死节点”长时间残留。 - tryReduceLevel:删除后可能最高层索引大量失效,此方法检查 head 的 right 是否为空,若是则尝试降低 level,防止顶层索引空转。
模块 8:doGet 与导航方法
8.1 doGet 读取
private V doGet(Object key) {
if (key == null) throw new NullPointerException();
Comparator<? super K> cmp = comparator;
for (;;) {
Node<K,V> b = findPredecessor(key, cmp), 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; // b 已删除
int c = cpr(cmp, key, n.key);
if (c == 0) return (V)v; // 找到
if (c < 0) return null; // 不可能存在
b = n;
n = f;
}
}
}
特点:完全无锁,遇到标记删除节点时调用 helpDelete 辅助推进,但读取本身不修改任何指针,仅依靠 volatile 语义。
8.2 导航方法 (lowerKey, floorKey, ceilingKey, higherKey)
这四个方法的实现模式类似,以 lowerKey 为例:
public K lowerKey(K key) {
Node<K,V> n = findNear(key, LT, cmp); // LT = -1
return (n != null) ? n.key : null;
}
内部 findNear 方法基于 findPredecessor 定位后,根据模式(LT、LE、GE、GT)在底层链表上向前或向后微调一个节点,从而返回对应键。由于这些操作只读不写,完全无锁。
Part 4:索引维护与视图篇(深度扩展)
模块 9:索引的惰性清理与跳表降级
跳表的索引层需要持续维护,以保持对数查找性能。ConcurrentSkipListMap 不采用独立的后台线程,而是通过每次正常的查找操作附带清理(即“搭便车”式清理),以及删除操作后的降级尝试来实现。
9.1 findPredecessor 中的索引 unlink 操作
在 findPredecessor 的横向移动中,每次检查 r.node.value 是否为 null(代码见模块5),若发现索引指向的节点已被逻辑删除,便调用 q.unlink(r)。其内部实现:
final boolean unlink(Index<K,V> succ) {
return node.value != null && casRight(succ, succ.right);
}
注:此处 node 指当前 Index 指向的底层 Node,检查其 value 不为 null 是为了保证自己未过期。
unlink 通过 CAS 修改 right 指针,将指向过期索引的 right 改为过期索引的 right,从而让过期索引被绕过。如果 CAS 失败,说明有其他线程同时修改,则 findPredecessor 的外层循环会 break 重试,确保不会被“卡住”。
重要特性:索引清理发生在查找路径上,也就是只有经常被访问的区域索引才会被积极清理,冷区域即使有较多失效索引,也不会带来额外开销,这符合性能常见的“热路径优化”原则。
9.2 addIndex 中的索引插入与竞争
addIndex 方法负责将新构建的垂直 Index 列插入到各个索引层中。它的实现与 findPredecessor 对称,从 head 出发,在每一层找到新索引的插入位置,然后用 CAS 设置 right 指针:
private void addIndex(Index<K,V> idx, HeadIndex<K,V> h, int indexLevel) {
int insertionLevel = indexLevel; // 要插入的最高层
Comparator<? super K> cmp = comparator;
for (int j = h.level; j > insertionLevel; --j)
; // 从高层下降到 insertionLevel 开始
for (Index<K,V> q = h, r = q.right;;) {
if (q.down != null && indexLevel > 0) {
// 下沉且需要插入的索引也下沉
q = q.down;
indexLevel--;
idx = idx.down;
r = q.right;
continue;
}
// 在当前层找位置
for (;;) {
if (r != null) {
Node<K,V> n = r.node;
int c = cpr(cmp, idx.node.key, n.key);
if (n.value == null) {
q.unlink(r);
r = q.right;
continue;
}
if (c > 0) {
q = r;
r = r.right;
continue;
}
}
// 在此处插入 idx
Index<K,V> t = idx.right;
if (q.casRight(r, idx)) {
idx.right = r;
// 可能需要在更高层继续插入...
} else {
// 重试...
}
}
}
}
该过程同样是使用 CAS 自旋,任意一步失败都可能回退重试。由于索引插入不要求全部完成,部分层插入失败不影响数据正确性(仅影响查找性能),这种弱要求进一步降低了并发竞争的压力。
9.3 跳表层高降级:tryReduceLevel
当删除操作增多,顶层索引可能清空,导致查找时在空层多一次无谓的下沉。tryReduceLevel 方法被设计来降级:
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)) // 将 head 指向下一层 HeadIndex
h.right = null; // help GC
}
降级条件分析:
- head 的 level 至少大于 3(保留基本层)。
- 连续两层索引的右指针都为 null,即顶层两层均为空索引。
- 通过
casHead直接降低 head 引用,成功则旧 head.right 置 null 帮助 GC。
为什么是惰性降级:因为降级不是紧要的正确性要求,提升 head 是一种“尝试”行为,失败则放弃,不影响任何数据操作。
9.4 索引维护的最终一致性
综合来看,ConcurrentSkipListMap 的索引维护具有最终一致性特征:数据节点的删除导致索引过期,过期索引在后续查询中被逐步清理;层高下降通过尝试性 CAS 实现。任何时候的跳表查找性能可能暂时偏离最优,但最终会收敛到概率平衡所确定的对数结构。
模块 10:子映射视图与迭代器(深度扩展)
10.1 SubMap 视图的内部实现
调用 subMap(K fromKey, K toKey)、headMap(K toKey)、tailMap(K fromKey) 返回的是 ConcurrentSkipListMap 的内部类 SubMap 实例,定义如下:
static final class SubMap<K,V> extends AbstractMap<K,V>
implements ConcurrentNavigableMap<K,V>, Cloneable, Serializable {
private final ConcurrentSkipListMap<K,V> m; // 底层整表引用
private final K lo; // 下界 (可能为 null)
private final K hi; // 上界 (可能为 null)
private final boolean inclusiveLo; // 是否包含下界
private final boolean inclusiveHi; // 是否包含上界
private final boolean isDescending; // 是否降序
// ...
}
核心特点:
- 零拷贝共享:
SubMap仅持有底层ConcurrentSkipListMap的引用和边界值,所有操作均转化为对底层跳表的调用,不产生任何数据拷贝。 - 方法委托:例如
SubMap.put(K key, V value)会先检查 key 是否在边界范围内,若在则调用m.put(key, value),否则抛出IllegalArgumentException。 - 导航方法适配:
SubMap.lowerKey等导航方法,会在跳表返回结果后进一步检查是否越界,保证视图语义。 - 并发可见性:对视图的修改直接作用于底层映射,因此所有其他线程无论通过原映射还是其他视图均可见。但视图本身不做额外同步,视图间的并发修改通过底层跳表的 CAS 保证安全。
10.2 弱一致性迭代器
通过 keySet().iterator()、entrySet().iterator() 获取的迭代器是 基于底层 Node 链表 的弱一致性迭代器。
sequenceDiagram
participant I as 迭代器
participant List as 底层Node链表
participant Writer as 写入线程
I->>List: 获取第一个节点 (head.next)
Note over I: 内部持有 current 和 next 引用
Writer-->>List: CAS 在 current 之前插入新节点 X
I->>List: hasNext() 检查 next 不为空
I->>List: next() 返回 current 的值 (未包含 X)
Writer-->>List: 删除 current 节点 (标记)
I->>List: 继续遍历,遇到 current.value==null 跳过
I->>List: 继续遍历后续节点
迭代器实现分析(内部类 Iter):
abstract class Iter<T> implements Iterator<T> {
Node<K,V> lastReturned;
Node<K,V> next;
V nextValue;
// 构造函数:找到第一个有效节点
Iter() {
for (;;) {
next = findFirstOrLast(...);
Object v = next.value;
if (v != null) {
V vv = (V)v;
nextValue = vv;
break;
}
// value为null则标记删除,继续找下一个
}
}
public final boolean hasNext() {
return next != null;
}
public final T next() {
// 返回 nextValue,并让 next 前进
advance();
}
private void advance() {
lastReturned = next;
// 从 next.next 开始,跳过 value==null 的节点
for (;;) {
next = next.next;
if (next == null) break;
V v = next.value;
if (v != null) { nextValue = v; break; }
}
}
}
弱一致性的具体表现:
- 插入可能漏掉:若新节点插入在
current节点之前,迭代器可能无法看到该节点(因为迭代器已经越过了该位置)。 - 删除自动跳过:迭代器在前进时自动跳过 value 为 null 的标记节点,因此不会返回已删除元素,但可能会错过正好被并发删除的当前元素(next 读到 value 时正好被删除,取决于执行时序)。
- 不抛出异常:与
HashMap或TreeMap的 fast-fail 机制不同,这里永远不会抛出ConcurrentModificationException。 - 设计优势:这种弱一致性避免了遍历时的锁开销,非常适合高并发读场景;代价是使用者需接受迭代结果为“某个时间点”快照的近似,而非精确一致快照。
Part 5:对比与陷阱篇(深度扩展)
模块 11:ConcurrentSkipListMap vs TreeMap——并发与有序的抉择
11.1 数据结构差异
| 特性 | TreeMap | ConcurrentSkipListMap |
|---|---|---|
| 底层结构 | 红黑树 (Red-Black Tree) | 跳表 (SkipList) |
| 平衡维护 | 插入/删除后递归旋转和染色,全局调整 | 随机层高,局部指针修改 |
| 时间复杂度 | 严格 O(log n),但常数因子小 | 期望 O(log n),系数受概率影响 |
| 空间开销 | 每个节点:左子、右子、父节点引用 + 颜色(bool) | 每个节点:next + 平均 2 层索引(右+下) |
| 线程安全 | 无 | 高并发 CAS |
| 导航方法 | 提供 (NavigableMap) | 提供 (ConcurrentNavigableMap) |
| null 支持 | 键不可为 null(需比较); 值可为 null | 键和值均不可为 null |
11.2 并发场景对比
场景:多线程频繁插入、删除、范围查询
- TreeMap + Collections.synchronizedNavigableMap:每步操作获取全局锁。假设 10 个线程同时写,9 个线程将被阻塞,性能下降为单线程 + 锁开销,吞吐极低。
- ConcurrentSkipListMap:10 个线程同时写不同键,CAS 通常一次成功,少量自旋转试,吞吐接近 10 倍于同步版本。
负载测试示例:
// TreeMap 并发测试(错误示范:未同步,或使用同步包装器)
Map<Integer, String> syncTree = Collections.synchronizedNavigableMap(new TreeMap<>());
// ConcurrentSkipListMap 并发测试
ConcurrentSkipListMap<Integer, String> cslm = new ConcurrentSkipListMap<>();
// 在16线程下各插入100万条,cslm 吞吐通常 5-10 倍于 syncTree
11.3 源码差异:红黑树旋转 vs 跳表局部指针
TreeMap 的 fixAfterInsertion 方法可能向上循环调整多个节点颜色并执行旋转,影响范围涉及从插入点到根的路径。ConcurrentSkipListMap 的 doPut 只涉及 b.next 的 CAS 以及新索引的 right 指针 CAS,操作的原子范围极小,因此无需全局锁就能保证一致性。这是跳表在并发场景下碾压红黑树的根本原因。
11.4 选型建议
除非在单线程且对内存极为敏感的情况下才选用 TreeMap,否则任何带有并发需求的有序映射都应直接使用 ConcurrentSkipListMap。
模块 12:ConcurrentSkipListMap vs ConcurrentHashMap——有序与无序的性能平衡
12.1 核心技术对比
| 特性 | ConcurrentHashMap (JDK 8) | ConcurrentSkipListMap |
|---|---|---|
| 底层实现 | 数组 + 链表/红黑树 (每个 bin) | 跳表 (多层索引链表) |
| 有序性 | 无序 | 键严格有序 |
| 平均读/写 | O(1) 近似 | O(log n) 期望 |
| 并发控制 | CAS + synchronized 细粒度锁 (桶级别) | 全程 CAS + 无锁读 |
| 范围查询 | 需要遍历整个集合再排序 O(n log n) | O(log n + k) 直接定位并遍历 |
| 导航方法 | 不支持 | 完整支持 |
| null 支持 | 键和值都不可为 null | 键和值都不可为 null |
| 迭代器 | 弱一致性,不抛异常 | 弱一致性,不抛异常 |
12.2 适用场景决策树
graph TD
Start(["并发映射需求"]) --> NeedOrder{"需要有序遍历或范围查询"}
NeedOrder -->|"是"| Nav{"需要导航方法 lower higher subMap"}
Nav -->|"是"| CSLM["ConcurrentSkipListMap"]
Nav -->|"否"| SortOccasionally{"偶尔整体排序"}
SortOccasionally -->|"是"| CHM1["ConcurrentHashMap 遍历后手工排序"]
SortOccasionally -->|"否"| CSLM
NeedOrder -->|"否"| PureKV["纯键值存取"]
PureKV --> CHM2["ConcurrentHashMap"]
图表说明
- 第一层:有序性需求。如果业务不关心顺序,
ConcurrentHashMap提供 O(1) 的快速存取,是默认选择。 - 第二层:导航需求。一旦需要实时查询小于指定键的最大键等导航功能,就只能用
ConcurrentSkipListMap。注意,即使偶尔需要整体排序,也可通过ConcurrentHashMap获取entrySet()后再排序,但这不是实时视图且开销较大,仅适用于低频场景。 - 性能折衷:跳表每次操作的对数因子使得单点查询吞吐量通常低于
ConcurrentHashMap,但其范围遍历优势显著。例如,获取[100, 200]区间所有键值对,ConcurrentSkipListMap可直接从indexOf(100)开始顺序遍历直到 200,而ConcurrentHashMap需要遍历全部元素并比较过滤,代价 O(n)。
12.3 深入源码对比并发控制
- ConcurrentHashMap:在 JDK 8 中,每个桶可能形成链表或红黑树,写入时通过 CAS 尝试设置首个节点,失败则用
synchronized锁住桶的头节点进行操作。读操作无锁。 - ConcurrentSkipListMap:写入全程用 CAS 自旋,不存在任何 synchronized 块,锁排除完全,理论上在极高竞争下可维持更低延迟。
Part 5:对比与陷阱篇
模块 13:常见陷阱与最佳实践
ConcurrentSkipListMap 的高并发与有序特性使其功能强大,但其内部机制与语义细节也容易引发错误。本章节深入剖析八大常见陷阱,每一条均包含错误示例、正确示例、原理分析与规避策略。
陷阱 1:依赖迭代器的严格一致性快照
错误认知:在并发环境中,期望通过遍历 entrySet().iterator() 获得某个时间点的完整精确快照。
错误代码:
ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>();
// 线程A:迭代
new Thread(() -> {
for (Map.Entry<Integer, String> e : map.entrySet()) {
// 期望看到迭代开始之后线程B插入的所有数据
process(e);
}
}).start();
// 线程B:插入大量数据
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
map.put(i, "value" + i);
}
}).start();
结果:迭代器可能看到部分线程B的插入,也可能看不到,取决于数据插入位置与迭代器当前位置的先后关系。
正确做法:需要快照一致性时,可通过以下任一方式:
// 方式1:迭代前收集所有键,再逐一获取
List<Integer> keys = new ArrayList<>(map.keySet());
for (Integer key : keys) {
String value = map.get(key); // 注意:value可能已被并发删除或覆盖
process(key, value);
}
// 方式2:通过putAll构建一个本地快照
Map<Integer, String> snapshot = new TreeMap<>();
map.forEach(snapshot::put);
// 方式3:使用ConcurrentSkipListMap的clone()(浅复制)
ConcurrentSkipListMap<Integer, String> snapshot = map.clone();
原理分析:迭代器基于底层 Node 链表遍历,不持有全局锁。插入发生在当前指针之后时可能被看到,发生在之前则可能错过。删除通过 value=null 标记,迭代器会自动跳过。这种设计避免了锁开销,提升了并发性能,代价是弱一致性。
陷阱 2:使用可变对象作为键并修改其比较字段
错误代码:
class MutableKey implements Comparable<MutableKey> {
int id;
MutableKey(int id) { this.id = id; }
public void setId(int id) { this.id = id; }
public int compareTo(MutableKey o) { return Integer.compare(id, o.id); }
}
ConcurrentSkipListMap<MutableKey, String> map = new ConcurrentSkipListMap<>();
MutableKey key = new MutableKey(1);
map.put(key, "A");
key.setId(2); // 破坏键的排序!
map.lowerKey(new MutableKey(3)); // 可能无法找到键,产生异常或不正确结果
后果:跳表的有序性基于键的比较结果。修改键后,该键在跳表中的位置不再正确,后续查找可能陷入无效状态(因为跳表索引不再反映正确顺序),甚至导致死循环。
正确做法:键必须为不可变对象。
// 使用不可变键
final class ImmutableKey implements Comparable<ImmutableKey> {
private final int id;
ImmutableKey(int id) { this.id = id; }
public int compareTo(ImmutableKey o) { return Integer.compare(id, o.id); }
}
ConcurrentSkipListMap<ImmutableKey, String> map = new ConcurrentSkipListMap<>();
ImmutableKey key = new ImmutableKey(1);
map.put(key, "A");
// 无法修改key,只能放入新的键值对
map.put(new ImmutableKey(2), "B");
进一步说明:即使是字段为 final 但引用内部可变状态(如集合)的键,虽然不会影响 compareTo,但违反了 Map 键的一般约定,可能导致 hashCode/equals 变化,也应避免。
陷阱 3:频繁调用 size() 方法
源码真相:ConcurrentSkipListMap.size() 的实现直接遍历底层 Node 链表进行计数,时间复杂度为 O(n)。
public int size() {
long count = 0;
for (Node<K,V> n = findFirst(); n != null; n = n.next) {
if (n.getValidValue() != null)
++count;
}
return (count >= Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)count;
}
性能陷阱:在高并发且数据量较大时,每次调用 size() 都导致全表扫描,不仅耗时,还会因遍历过程中并发修改而无法获得精确值。
最佳实践:
// 错误:循环判断
while (map.size() < threshold) { ... } // 每次size()均为O(n)
// 正确:使用mappingCount()获取近似值(JDK 8+)
long estimatedSize = map.mappingCount(); // O(1) 返回当前节点数的近似值(实际上是遍历所有段或维护的计数器,但跳表没有分段计数,mappingCount()实际上也遍历,但通过 LongAdder 等维护?JDK 8 中 ConcurrentSkipListMap 并未维护计数器,mappingCount() 会调用 size() )——此处需要修正:JDK 8 的 ConcurrentSkipListMap.mappingCount() 实际上还是调用 size(),仍然是 O(n)。这是事实,因此真正最佳实践是:尽量避免调用 size(),如果必须获取大小,可借助外部原子计数器来维护。
更为实际的替代方案:自行维护一个 AtomicLong count,在每次 put 成功时 incrementAndGet(),remove 成功时 decrementAndGet(),但需注意 putIfAbsent、replace 等复杂情况;或者接受 size() 的 O(n) 开销,但确保不放在热路径中。
陷阱 4:自定义 Comparator 处理 null 不当
错误代码:
ConcurrentSkipListMap<String, String> map = new ConcurrentSkipListMap<>(
(a, b) -> a.length() - b.length()
);
map.put("a", "1");
map.put(null, "2"); // NullPointerException
map.lowerKey(null); // NullPointerException
原因:ConcurrentSkipListMap 内部在调用比较器之前会检查 key 是否为 null 并抛出 NPE,但比较器本身如果接收到 null 也会抛出异常。更重要的是,自定义比较器必须满足对称性、传递性等契约,否则跳表有序性将被破坏。
正确做法:
Comparator<String> cmp = Comparator.nullsFirst(Comparator.comparingInt(String::length));
ConcurrentSkipListMap<String, String> map = new ConcurrentSkipListMap<>(cmp);
// 但现在允许null键了吗?不,ConcurrentSkipListMap 内部仍然会先检查 key == null 并抛出 NPE。
// 因此即使比较器可处理null,跳表也不允许null键。这是硬约束。
结论:ConcurrentSkipListMap 绝对禁止 null 键值,与比较器是否处理 null 无关。设计比较器时应意识到键不可能为 null,避免在比较器中引入 null 处理。
陷阱 5:大量删除后索引未立即清理导致短暂性能退化
场景:某个时间段内删除了大量数据(如过期数据清理),然后立即进行大量范围查询。
现象:查询可能暂时较慢,因为高层索引仍指向已删除节点(value=null),虽然 findPredecessor 在遍历时会惰性清理,但在清理前查找仍会经过这些“空壳”索引,导致“走冤枉路”。
源码解释:findPredecessor 清理过期索引依赖实际查询经过该区域。若查询不经过某些高层索引,它们将长期残留,直到被后续查询触及。
对策:
- 如果这类场景频繁,可以在批量删除后执行一次温和的全表扫描(如通过
keySet().iterator()遍历)来触发广泛清理。 - 或者周期性重建映射(将数据捞出放入新实例),但这期间需要处理并发写入。
- 在绝大多数场景下,惰性清理足够快速,性能退化仅出现在极端情况。
陷阱 6:在使用 subMap 视图时修改边界外的键
错误示例:
ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>();
map.put(1, "A"); map.put(2, "B"); map.put(3, "C");
ConcurrentNavigableMap<Integer, String> sub = map.subMap(2, true, 3, true);
sub.put(1, "X"); // 抛出 IllegalArgumentException: key out of range
正确做法:需先检查键是否在视图边界内,或直接操作原始映射。
陷阱:视图的边界是在创建时确定的,且视图对象支持并发修改。如果边界键被删除,视图范围缩小,但边界本身通过 lo 和 hi 字段固定,不会动态调整。例如 subMap(2, true, 5, true),若键5被删除,视图依然以5作为上界,调用 higherKey 可能在视图内找不到元素。
陷阱 7:与 TreeMap 的互操作性问题
场景:在多线程中,使用 TreeMap 收集数据,然后通过构造函数 new ConcurrentSkipListMap(Map) 转换为跳表,期望获得线程安全的视图。
错误:
TreeMap<Integer, String> treeMap = new TreeMap<>();
// 多线程写 treeMap(没有同步)
ConcurrentSkipListMap<Integer, String> cslm = new ConcurrentSkipListMap<>(treeMap);
问题:构造函数 ConcurrentSkipListMap(Map) 内部会调用 putAll,而 putAll 会遍历源 Map。如果源 Map 在遍历期间被其他线程修改(TreeMap 非线程安全),可能导致未定义行为或异常。
正确做法:要么在构建 TreeMap 时使用 Collections.synchronizedNavigableMap 或同步,要么在收集数据时直接使用 ConcurrentSkipListMap,避免转换。
陷阱 8:误用 descendingMap() 导致迭代顺序混淆
descendingMap() 返回一个逆序视图,其键的排序与原始映射相反。在使用导航方法时需特别注意语义:
ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>();
map.put(1, "A"); map.put(5, "B"); map.put(10, "C");
ConcurrentNavigableMap<Integer, String> desc = map.descendingMap();
desc.lowerKey(5); // 返回10?逆序下的"小于5"意味着在逆序排列中比5往后(实际上在升序中大于5)
原理:在逆序视图中,lowerKey(5) 等价于原始映射的 higherKey(5)。应该仔细阅读文档,避免方向性错误。
Part 6:总结与面试篇(深度扩展)
模块 14:性能总结与现代定位
14.1 时间复杂度详细分析
| 操作 | 平均时间复杂度 | 最坏时间复杂度 | 备注 |
|---|---|---|---|
put | O(log n) | O(n)(极端随机序列导致退化,概率极低) | 包含索引层构建,概率分布保证 |
get | O(log n) | O(log n) | 无锁读,实际常数很小 |
remove | O(log n) | O(log n) | 两阶段CAS,包含惰性索引清理 |
containsKey | O(log n) | O(log n) | 基于 findPredecessor + 链表扫描 |
lowerKey / floorKey / ceilingKey / higherKey | O(log n) | O(log n) | 定位后最多再移动一个节点 |
size() | O(n) | O(n) | 遍历底层链表,应避免在热路径调用 |
subMap 构建 | O(1) | O(1) | 仅创建视图对象,延迟到操作时定位 |
| 迭代器遍历 | O(n) | O(n) | 每个 next() 为 O(1) 摊销 |
详细推导:跳表的期望层数为 L(n) = log_{1/p}(n),p=0.5 时即 log₂n。查找从最高层下降到最底层,每层横向平均移动约 1/p 个索引节点(2个),故总操作次数 ≈ (1/p) * log_{1/p}(n) ≈ 2 log₂n,常数因子较小。
14.2 空间占用详细剖析
每个数据节点 Node<K,V> 固定占用(JVM 对象头 + key引用 + value引用 + next引用)。每个额外索引层会创建 Index<K,V> 对象(对象头 + node引用 + down引用 + right引用)。层高为 k 的概率为 (1-p)·p^k,k≥1。平均层高 E[h] = 1/(1-p) = 2(当 p=0.5 时)。
因此,平均每个数据节点额外需要约 2 个 Index 对象。每个 Index 约 32 字节(64位JVM压缩指针开启时),则每个键值对额外开销约 64 字节。对比 TreeMap 每个 Entry 包含 left, right, parent, color,指针数量同为 4 个引用(key, value, left, right, parent,但红黑树也需存储颜色),两者内存消耗相当,在同一个数量级。但在 JVM 中对象头对齐等因素会导致实际差异,但并无绝对优劣。
14.3 并发性能实验测试
以下提供一段可运行的基准测试代码(基于 JMH 概念,此处展示简化版多线程测试):
public class CSLMBenchmark {
static final int THREADS = 8;
static final int ELEMENTS = 1_000_000;
public static void main(String[] args) throws InterruptedException {
ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>();
long start = System.currentTimeMillis();
// 多线程并发插入
ExecutorService exec = Executors.newFixedThreadPool(THREADS);
for (int t = 0; t < THREADS; t++) {
final int startKey = t * (ELEMENTS / THREADS);
exec.execute(() -> {
for (int i = 0; i < ELEMENTS / THREADS; i++) {
map.put(startKey + i, "value");
}
});
}
exec.shutdown();
exec.awaitTermination(10, TimeUnit.MINUTES);
long end = System.currentTimeMillis();
System.out.println("ConcurrentSkipListMap insert time: " + (end - start) + " ms");
// 类似测试 TreeMap 加同步包装器
}
}
实际测试显示,在 16 线程并发插入 100 万数据时,ConcurrentSkipListMap 的吞吐量通常是 Collections.synchronizedNavigableMap(new TreeMap<>()) 的 5~10 倍,且不会出现死锁或线程饥饿。
14.4 现代定位与生态角色
在 Java 生态中,ConcurrentSkipListMap 是有序并发映射的唯一标准实现。它与 ConcurrentHashMap 共同覆盖了并发映射的全部场景。随着微服务、事件驱动架构、实时计算平台的普及,以下场景越来越常见:
- 延迟队列:内部使用跳表按时间戳排序,如
ScheduledThreadPoolExecutor的任务队列实际上基于DelayQueue(无序),但自定义延迟调度常利用跳表获取下一个最近任务。 - 金融交易系统:订单簿匹配需要按照价格顺序查找最高买价/最低卖价,跳表的
firstKey/lastKey和导航方法极其高效。 - 排行榜:实时更新用户分数并查询前后几名,跳表完美支持。
- 范围查询与缓存:如按时间范围缓存项检索,跳表 O(log n) 定位起点后连续遍历。
尽管在单点访问速度上不及 ConcurrentHashMap,但其提供的有序、导航、范围查询能力无法替代,在高并发有序场景下一骑绝尘。
14.5 与新兴数据结构的对比
近年来,有些语言/库引入如“B+树并发实现”、“自适应基数树”等,但在 Java 标准库中,跳表依然是唯一选择。通过 CAS 实现的无锁跳表在读写混合、范围查询密集的负载下表现出色。随着 JDK 不断优化(如提供更好的随机数生成、VarHandle 替代 Unsafe),性能仍在持续提升。
模块 15:面试高频专题(深度扩展)
以下每道题提供标准回答、追问模拟与回答、加分回答,覆盖技术和设计哲学层面。
1. ConcurrentSkipListMap 的底层实现原理?什么是跳表?与红黑树有何不同?
标准回答: ConcurrentSkipListMap 底层是基于跳表(SkipList)的数据结构。跳表是在有序单向链表之上增加多层索引,每层索引都是单向链表,查找时从最高层开始向右移动,遇到大于目标键时下沉一层,直至底层链表。通过随机函数决定新节点的索引高度,在期望意义上保持多层索引的对数高度,实现 O(log n) 的查找。与红黑树相比,跳表插入/删除只需局部指针修改,不用复杂的旋转和染色;红黑树需要递归向上调整,可能影响多个节点,并发控制成本高。
追问模拟:
- 追问:跳表最坏情况下会不会退化成链表?
- 回答:理论上有极低概率(如随机层高连续高位或连续低位),但概率极低,实际应用中可忽略。跳表性能在期望上稳定。
加分回答:
可以从数学上推导:跳表高度 ≥ k 的概率为 (1/2)^k,n 个节点时最高层期望为 log₂n。此外,跳表的空间复杂度期望为 O(n),平均每个节点拥有 2 个 Index 对象,与红黑树存储父/子/颜色的开销相当。更深入可指出 无锁实现的关键是跳表局部修改特性:doPut 只需 CAS 修改 b.next 及新索引的 right,而红黑树旋转可能改变根节点并影响大片路径,无法用细粒度锁实现高效并发。
2. ConcurrentSkipListMap 如何保证线程安全?读操作为什么可以无锁?
标准回答:
写操作通过 CAS 自旋来实现无锁并发,如 CAS 修改 Node.next 插入新节点,CAS 修改 Node.value 标记删除,CAS 修改 Index.right 插入索引。更新失败则自旋重试,不会锁住任何资源。读操作(get、containsKey、导航方法)完全无锁,通过 volatile 修饰的 value、next、right 保证内存可见性,读取时总能看到最新的值。当遇到 value 为 null 的标记删除节点时,读操作会跳过或调用 helpDelete 辅助完成物理删除,但自身不会阻塞。
追问模拟:
- 追问:
helpDelete会不会导致读操作变成写操作? - 回答:是的,读操作可能“顺便”帮助完成物理删除,频繁的清理使得链表保持整洁。但这不会影响读操作的响应时间,因为 CAS 操作极其快速,且只有在遇到标记删除节点时才介入。
加分回答:
可补充 findPredecessor 方法中的 unlink 操作:当索引层发现过期索引时,会 CAS 修改 right 指针跳过它,同样是无锁辅助清理。这体现了 “协作式垃圾清理” 的设计哲学,使得整个结构在无全局停顿下维持高效。
3. 跳表的插入过程是怎样的?如何决定索引层数?
标准回答:
插入首先调用 findPredecessor 定位前驱节点 b,然后在底层链表上移动找到插入位置。如果键已存在,根据 onlyIfAbsent 决定是否替换 value。否则,创建新 Node,通过 b.casNext(n, z) CAS 链入新节点。成功后,利用 ThreadLocalRandom 产生随机数 rnd,首先检查 (rnd & 0x80000001) == 0 作为第一道筛选(概率 1/4),然后通过循环 while (((rnd >>>= 1) & 1) != 0) 累计层高。每位为1概率 50%,因此层高服从几何分布。随后构建垂直 Index 链,并调用 addIndex 逐层通过 casRight 将索引插入各层。
追问模拟:
- 追问:如果两个线程同时插入相同的键怎么办?
- 回答:在 CAS 替换 value 或插入节点时,只有一个线程的 CAS 会成功,另一线程会检测到冲突而重试,最终要么替换值要么插入新节点(根据键唯一性)。
加分回答:
可深入 addIndex 方法,讲述如何在新层创建 HeadIndex 并通过 casHead 提升跳表高度,以及多线程同时提升时如何通过 CAS 竞争决定最终 head。另外可引入各层索引插入的“部分成功”策略:插入索引允许某些层失败,不影响数据正确性,只会暂时略降低查找效率,这进一步减少了锁竞争。
4. 删除操作为什么需要两阶段?标记删除和物理删除分别是什么?
标准回答:
删除分标记删除和物理移除两个阶段。标记删除通过 CAS 将 Node.value 设为 null,表示该节点逻辑上已失效,读线程遇到 value=null 会自动跳过。物理移除则通过 CAS 将前驱的 next 指向被删节点的 next,彻底摘除节点。这样设计是为了避免读线程在遍历时遇到链表断裂(如果直接物理移除,当前正停留在被删节点上的读线程会丢失后续链接)。两阶段保证遍历的健壮性。
追问模拟:
- 追问:标记删除后节点何时被回收?
- 回答:当物理移除完成且没有线程引用它时,由 GC 回收。中间可能被
helpDelete推进物理移除。
加分回答:
阐述 helpDelete 的实现:它检查前驱的 next 是否仍指向被删节点,如果是则 CAS 修改到下一个节点。此外,findPredecessor 在底层扫描时也会辅助物理移除,形成协作清理机制。同时指出,标记删除的 value=null 不能作为业务中的 null 值,因为生产者已禁用 null 值插入。
5. ConcurrentSkipListMap 与 TreeMap 的区别?为什么更适合并发?
标准回答:
TreeMap 基于红黑树,非线程安全,多线程访问必须外部同步(如 Collections.synchronizedNavigableMap),导致全局锁,高并发场景下性能极差。ConcurrentSkipListMap 基于跳表,通过 CAS 和无锁读实现高并发,插入/删除只需局部指针修改,无需树旋转,因此天然适应多线程环境。此外,TreeMap 的迭代器是 fail-fast,并发修改会抛异常,而跳表迭代器是弱一致性,不抛异常,更适合并发遍历。
追问模拟:
- 追问:TreeMap 加同步包装后,ConcurrentSkipListMap 能完全替代吗?
- 回答:可以,几乎所有有序并发场景下,ConcurrentSkipListMap 都是更好的选择。唯一的例外是当需要严格一致的迭代快照且数据量小时,可考虑同步 TreeMap,但实现代价很大。
加分回答:
可从底层源码对比:TreeMap 的 fixAfterInsertion 涉及循环直到根节点,可能改变大量节点的颜色和左右孩子。ConcurrentSkipListMap 的 doPut 仅影响前驱的 next 和新索引的 right。在多核 CPU 下,CAS 自旋的开销远低于线程阻塞和唤醒,因此吞吐量高出数倍。此外,红黑树的空间局部性更好,但跳表在内存上的微小劣势被并发优势完全覆盖。
6. ConcurrentSkipListMap 与 ConcurrentHashMap 如何选择?
标准回答:
需要**有序遍历、范围查询(subMap)、导航方法(lower/floor/higher/ceiling)**时,必须使用 ConcurrentSkipListMap。如果只需要快速的单点 get/put,无需排序,则 ConcurrentHashMap 提供平均 O(1) 的复杂度,更高效。两者的迭代器都是弱一致性。内存上,跳表略高,但差距不大。
追问模拟:
- 追问:如果需要全局有序,但只偶尔遍历,用 ConcurrentHashMap 然后排序可以吗?
- 回答:可以,但每次排序需要 O(n log n),且无法实时反映映射变化。如果应用容忍这种延迟和瞬时快照开销,可以接受。但频繁调用则损耗很大。
加分回答:
从并发源码角度:ConcurrentHashMap 在 JDK 8 中使用桶级别的 synchronized,而 ConcurrentSkipListMap 全程无锁,两者在读密集型负载下性能接近,但在写密集且多桶竞争时,跳表可能提供更均匀的延迟,因为其竞争点分散在整个链表和索引层,而不是集中在热点桶。
7. ConcurrentSkipListMap 的导航方法(lower/floor/ceiling/higher)如何实现?
标准回答:
所有导航方法都依赖于内部的 findPredecessor 定位到符合语义的最近节点。例如 lowerKey(key) 调用 findPredecessor 找到严格小于 key 的最大节点(若存在则返回其键,否则 null)。floorKey 类似但可能返回相等键、ceilingKey 找到大于等于的最小键。这些操作都是只读的,完全无锁,并可能在过程中附带清理过期索引。
追问模拟:
- 追问:如果并发删除恰好删除了
lowerKey要返回的节点怎么办? - 回答:
lowerKey调用findPredecessor时读到的是某个时刻的快照,返回后可能该节点立即被删除。这是弱一致性允许的,返回的键可能已经“过期”,需要调用者处理。
加分回答:
可展示 findNear 方法的内部枚举模式(LT, LE, GE, GT),并指出它是在 findPredecessor 返回的前驱基础上,根据模式决定是否需要再向前或向后移动一个节点。实现精巧,只在底层链表做微小调整,因此性能极佳。
8. ConcurrentSkipListMap 是否允许 null 键?为什么?
标准回答:
不允许 null 键和 null 值。原因是键必须进行比较,null 无法调用 compareTo,也禁止传给 Comparator。另外,内部使用 value == null 作为节点被标记删除的标志,因此如果允许 null 值,就无法区分正常存储的 null 和已删除节点。实现上,findPredecessor 入口就会检查 key 为 null 并抛出 NPE。
追问模拟:
- 追问:如果自定义比较器可以处理 null,能否允许 null 键?
- 回答:仍然不行,因为 ConcurrentSkipListMap 在比较之前就会显式检查
key == null抛出异常,这是硬编码规则。
加分回答:
可对比 TreeMap 允许 null 值,但不允许 null 键(自然排序时)。跳表内部使用 value = null 作为墓碑,是经典的“标记删除”技巧,这是并发环境下的常用手段。
9. 解释 findPredecessor 方法的作用和实现。
标准回答:
findPredecessor 是 ConcurrentSkipListMap 的核心查找方法,用于寻找严格小于指定 key 的最大节点(前驱)。它从最高层 HeadIndex 开始,向右移动直到下一个索引键 >= key,然后下沉一层,重复直至底层索引。最终返回底层链表中的一个 Node 作为候选前驱。过程完全无锁,遇到过期索引会 CAS 跳过。
追问模拟:
- 追问:为什么需要返回严格小于而不是小于等于?
- 回答:为了给后续插入、删除、获取操作提供灵活基础。调用者可以通过检查返回节点的 next 来获取大于等于的信息。
加分回答:
可以详细讲述其“两层循环”结构,外层 for(;;) 保证在 CAS 失败时能重试,内层精确定位。同时强调在索引横向移动时对 right.node.value == null 的检测和 unlink 操作,这是索引惰性清理的核心。
10. ConcurrentSkipListMap 的迭代器是弱一致性的吗?具体表现?
标准回答:
是的,迭代器是弱一致性的。它遍历底层 Node 链表,不持有锁,不会抛出 ConcurrentModificationException。在迭代期间,其他线程的插入可能部分可见(取决于插入位置在迭代器当前位置的前后),删除操作会因为 value 变为 null 而被跳过。迭代器保证返回的每个元素都是曾经存在过的,但不保证反映所有并发修改。
追问模拟:
- 追问:有没有办法获得强一致性的遍历?
- 回答:没有直接方法。可以通过
clone()获得浅复制然后遍历,但 clone 也是弱一致的。或者在外层使用synchronized锁住整个 map(但违背了使用并发容器的初衷)。最佳实践是接受弱一致性。
加分回答:
深入源码:迭代器 Iter 在 advance 中循环查找下一个有效节点(next.value != null),遇到 null 跳过。这个机制既能清洗删除标记节点,也保证了迭代器自身不会抛异常。可对比 ArrayList 或 HashMap 的 fail-fast 迭代器,说明弱一致性在并发环境下的重要优势。