概述
在 Java 并发集合框架中,ConcurrentSkipListSet 是一个独具魅力的存在。它既不是通过“悲观锁”来保证线程安全,也不像 CopyOnWriteArraySet 那样通过“写时复制”牺牲写性能,而是在底层炼就了一座精密的 跳表 (SkipList) 结构,并全权委托 ConcurrentSkipListMap 实现了一套 无锁的、有序的、支持高并发的集合。本文将为您揭开从跳表层索引的跳跃式查找到 CAS 原子插入的全链路面纱,让您彻底吃透这个在并发有序场景下无可替代的利器。
核心知识点速览:
- 基于跳表 (SkipList) 的高并发有序实现:底层委托
ConcurrentSkipListMap,以多层索引链表实现 O(log n) 的查找、插入、删除,并通过 CAS 保证并发安全。 - 无锁并发与弱一致性:读操作完全无锁,写操作通过 CAS 自旋实现原子性,迭代器基于数据快照,不抛
ConcurrentModificationException。 - 有序性与导航方法:元素按自然顺序或比较器排序,支持
lower、floor、ceiling、higher等导航方法及范围视图,适合并发有序遍历和范围查找。 - 与 TreeSet 的本质差异:
TreeSet非线程安全,并发访问需外部同步或包装,而ConcurrentSkipListSet专为并发设计,性能在并发有序场景下远超同步包装的TreeSet。 - 适用边界与内存权衡:适合高并发、需要有序性的场景,但跳表维护多层索引,空间开销大于
TreeSet和普通HashSet。
全文组织架构图:
flowchart TB
subgraph Part1["Part 1: 基础认知篇"]
M1["模块1: 定义 核心特性与适用场景"]
M2["模块2: 接口与继承体系"]
end
subgraph Part2["Part 2: 存储与构造篇"]
M3["模块3: 存储结构与底层依赖"]
M4["模块4: 构造方法"]
end
subgraph Part3["Part 3: 核心原理篇"]
M5["模块5: add 操作 - CAS 插入与去重"]
M6["模块6: remove 与 contains 操作"]
M7["模块7: 导航与范围操作"]
end
subgraph Part4["Part 4: 迭代与一致性篇"]
M8["模块8: 迭代器的弱一致性与有序遍历"]
M9["模块9: 序列化与克隆"]
end
subgraph Part5["Part 5: 对比与陷阱篇"]
M10["模块10: vs TreeSet"]
M11["模块11: vs CopyOnWriteArraySet vs CHM.newKeySet"]
M12["模块12: 常见陷阱与最佳实践"]
end
subgraph Part6["Part 6: 总结与面试篇"]
M13["模块13: 性能总结与注意事项"]
M14["模块14: 面试高频专题"]
end
Part1 --> Part2 --> Part3 --> Part4 --> Part5 --> Part6
图表说明:
-
第一层:全文六大篇章递进关系
上图将全文划分为 六大篇章,沿着 “基础认知 → 存储构造 → 核心原理 → 迭代一致性 → 对比陷阱 → 总结面试” 的认知阶梯推进。先从ConcurrentSkipListSet的定义与接口入手,再深入底层存储结构与构造器,随后聚焦 CAS 并发插入/删除/导航等核心操作,继而讨论迭代器的弱一致性模型,再通过全方位对比和陷阱分析巩固选型能力,最后以性能总结和面试专题收尾。 -
第二层:每一篇章内的核心模块
每个subgraph内部列出了该篇章的关键模块,例如 Part 3 核心原理篇 涵盖了add、remove/contains、导航与范围操作三大核心流程,它们是理解无锁有序并发集合的绝对主线。Part 5 对比与陷阱篇 则囊括与TreeSet、CopyOnWriteArraySet、ConcurrentHashMap.newKeySet()的横向对比以及常见陷阱,直接服务于工程选型和避坑。 -
第三层:图表与源码的映射关系
阅读时,您可将每一个模块看作对特定源码方法族的深度解剖。例如模块 5 对应ConcurrentSkipListSet.add()→ConcurrentSkipListMap.putIfAbsent()→doPut();模块 8 对应Iterator由ConcurrentSkipListMap.KeySet提供。所有流程图、类图、时序图都将与这些具体方法一一对应。
Part 1:基础认知篇
模块 1:定义、核心特性与适用场景
定义:
ConcurrentSkipListSet<E> 是 基于跳表 (SkipList) 实现 的并发有序集合,它实现了 NavigableSet<E> 接口,底层完全委托给 ConcurrentSkipListMap(一个 key 为集合元素、value 为固定哑元 Boolean.TRUE 的并发映射)。它提供了 O(log n) 的平均时间复杂度 进行插入、删除、包含性检查,且所有操作都是 线程安全 的,不需要外部同步。
核心特性列表:
- 高并发有序性:元素按照自然顺序或构造时提供的
Comparator严格排序,任何遍历、子集视图均保持此顺序。 - 无锁读 & CAS 写:读操作(如
contains、iterator)完全 无锁,借助volatile保证可见性;写操作(如add、remove)通过 CAS(Compare-And-Swap)原子指令 修改底层链表的指针,失败则自旋重试,绝无全局锁。 - 弱一致性迭代器:迭代器在创建时获取一份 底层数据结构的逻辑快照,不抛出
ConcurrentModificationException,但可能看不到迭代器创建之后插入或删除的元素。 - 丰富的导航支持:实现
NavigableSet,提供lower、floor、ceiling、higher、pollFirst、pollLast以及子集、头集、尾集等视图。 - 元素不允许 null(依赖比较器):通常不允许插入
null元素,因为null无法与比较器正常合作;除非比较器特殊处理,否则会抛出NullPointerException。
适用场景(决策树):
graph TD
Start[需要线程安全的 Set]
NeedOrder[需要有序吗]
OnlyUniq[仅去重无序]
WriteIntensive[写多]
CHMKeySet[推荐 ConcurrentHashMap.newKeySet]
CopyOnWriteSet[CopyOnWriteArraySet 读快]
RangeQuery[大量范围查询导航]
CSLS[ConcurrentSkipListSet 跳表]
SyncTree[考虑 Collections.synchronizedSet 新TreeSet]
HighCont[高并发争用]
Start --> NeedOrder
NeedOrder --> OnlyUniq
NeedOrder --> RangeQuery
OnlyUniq --> WriteIntensive
WriteIntensive --> CHMKeySet
WriteIntensive --> CopyOnWriteSet
RangeQuery --> CSLS
RangeQuery --> SyncTree
SyncTree --> HighCont
HighCont --> CSLS
HighCont --> SyncTree
图表说明:
-
第一层:是否有序
决策树首先将需求分为 “需要有序” 和 “仅去重无序” 两大分支。如果仅需去重无需排序,马上进入无序并发 Set 选型,其中写多选ConcurrentHashMap.newKeySet(),读远多于写且数据量不大时CopyOnWriteArraySet更优。 -
第二层:是否大量范围查询
如果 需要有序,则进一步判断是否存在 大量范围查询(如subSet、headSet)或导航操作(如找小于某元素的最大值)。这些操作恰好是跳表发挥 O(log n) 优势的场景,ConcurrentSkipListSet是最佳选择。 -
第三层:并发争用考量
即使不需要范围查询,仅仅需要并发安全的排序集合,也应优先考虑ConcurrentSkipListSet,因为Collections.synchronizedSet(new TreeSet<>())所有操作都加全局锁,在高并发争用下吞吐量远低于 CAS 跳表。
反例场景:
- 需要高并发插入但 不需要顺序 的简单去重,应使用
ConcurrentHashMap.newKeySet(),其性能优于跳表。 - 写多且数据量极大,跳表的索引维护会产生额外开销,若无序需求还可用无锁哈希表。
- 读多写少且数据量较小 的有序场景,
CopyOnWriteArraySet并不支持排序(实际无序),故不能满足有序需求;如果是有序但读多写极少,Collections.synchronizedSortedSet搭配不可变数据尚可,但一般仍推荐ConcurrentSkipListSet。
模块 2:接口与继承体系
类图与委托关系:
classDiagram
class AbstractSet~E~ {
<<abstract>>
}
class NavigableSet~E~ {
<<interface>>
lower(E e)
floor(E e)
ceiling(E e)
higher(E e)
pollFirst()
pollLast()
subSet(E from, E to)
headSet(E to)
tailSet(E from)
}
class ConcurrentSkipListSet~E~ {
- ConcurrentNavigableMap m
- static final Object PRESENT
+ ConcurrentSkipListSet()
+ ConcurrentSkipListSet(Comparator E)
+ add(E e) boolean
+ remove(Object o) boolean
+ contains(Object o) boolean
+ lower(E e) E
+ floor(E e) E
...
}
class ConcurrentSkipListMap {
+ putIfAbsent(K key, V value) V
+ remove(Object key) boolean
+ containsKey(Object key) boolean
+ lowerKey(K key) K
...
}
AbstractSet <|-- ConcurrentSkipListSet
NavigableSet <|.. ConcurrentSkipListSet
ConcurrentSkipListSet --> ConcurrentSkipListMap : 委托 (m)
图表说明:
-
第一层:顶级抽象与接口
ConcurrentSkipListSet继承自AbstractSet,这使得它获得了Set操作的基本骨架(如equals、hashCode)。同时它实现了NavigableSet<E>接口,该接口扩展自SortedSet,定义了所有导航方法和范围视图方法。 -
第二层:委托核心——ConcurrentSkipListMap
类图中出现了ConcurrentSkipListMap,它是真正的数据存储实体。ConcurrentSkipListSet的内部维护了一个私有字段m(类型为ConcurrentNavigableMap<E,Object>,运行时实际为ConcurrentSkipListMap)。所有 Set 方法最终都转换为对 Map 的 key 操作,例如add(e)→m.putIfAbsent(e, true)。NavigableSet的导航能力通过映射m的对应导航方法实现(如lower(e)→m.lowerKey(e))。 -
第三层:导航能力的并发移植
正是因为ConcurrentSkipListMap已经实现了ConcurrentNavigableMap接口,提供了线程安全的lowerKey、floorKey等方法,ConcurrentSkipListSet才能 毫不费力地成为并发有序集合。这种组合设计既避免了代码重复,又保证了导航方法的强一致性语义(在 Key 级别是线程安全的)。
Part 2:存储与构造篇
模块 3:存储结构与底层依赖(源码剖析)
核心字段的源码级本质:
public class ConcurrentSkipListSet<E>
extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable {
private final ConcurrentNavigableMap<E,Object> m;
// 用于作为 Map 中所有 key 对应的 value
private static final Object PRESENT = new Object();
// ...
}
m 的实际运行时类型是 ConcurrentSkipListMap<E,Object>。我们将元素作为 Map 的 key,所有 key 共享同一个哑元值 PRESENT(类似 HashSet 使用 HashMap 的技巧)。因此对 ConcurrentSkipListSet 的分析,本质上就是对 ConcurrentSkipListMap 跳表结构及并发算法的分析。
跳表 (SkipList) 存储结构图示:
classDiagram
class HeadIndex {
+Index right
+Node node
+int level
}
class Index {
+Index down
+Index right
+Node node
}
class Node {
+K key
+V value
+Node next
}
HeadIndex --|> Index
Index "1" --> "1" Node : 引用底层节点
Node "1" --> "0..1" Node : next 构成底层单向链表
图表说明:
-
第一层:三层核心内部类
ConcurrentSkipListMap内部主要包含三个静态内部类:Node、Index、HeadIndex。Node构成底层有序单向链表,每个节点包含 key、value 以及volatile Node<K,V> next指针。Index是跳表的索引节点,它持有三个引用:down(指向下一层索引)、right(指向同层右侧索引)、node(指向对应的底层Node)。HeadIndex继承自Index,额外记录了当前层级,每一层索引链表都有一个HeadIndex作为头节点。 -
第二层:跳跃式查找的原理
查找时从 顶层 HeadIndex 开始,向右尽可能前进直到下一个节点的 key 大于等于目标值,然后下沉到下一层(down)继续向右,直到最底层链表。例如在一个包含百万数据的跳表中,顶层索引可以一次跨越数千个节点,使复杂度降为 O(log n)。这种 多层索引 + 底层链表 的结构天然避免了红黑树在并发旋转和染色时的大范围结构变更,更适合 CAS 操作。 -
第三层:并发友好的设计
Node的next字段和Index的right字段都是volatile且通过Unsafe提供的 CAS 方法更新。插入时只需要 CAS 修改底层链表前驱节点的next指针,然后以概率方式决定是否构建上层索引;删除采用“逻辑删除+物理清除”两步走,不会阻塞读线程。这种细粒度的指针操作是跳表能够实现 无锁并发 的关键。
跳表 vs 红黑树在并发中的优劣:红黑树在插入/删除时需要旋转和重新着色,影响范围可能跨越多个节点,难以用细粒度 CAS 安全实现,通常需要全局锁或安全锁子树;而跳表的索引层只是链表的“快捷方式”,插入/删除仅影响局部前驱节点的指针,粒度极细,天然适合 CAS。
模块 4:构造方法(源码剖析)
ConcurrentSkipListSet 提供了四个构造器,均围绕内部的 m 创建 ConcurrentSkipListMap 实例。
// 1. 自然排序
public ConcurrentSkipListSet() {
m = new ConcurrentSkipListMap<E,Object>();
}
// 2. 指定比较器
public ConcurrentSkipListSet(Comparator<? super E> comparator) {
m = new ConcurrentSkipListMap<E,Object>(comparator);
}
// 3. 从普通集合构造
public ConcurrentSkipListSet(Collection<? extends E> c) {
m = new ConcurrentSkipListMap<E,Object>();
addAll(c);
}
// 4. 从有序集合构造(保留顺序)
public ConcurrentSkipListSet(SortedSet<E> s) {
m = new ConcurrentSkipListMap<E,Object>(s.comparator());
addAll(s);
}
关键源码细节:
- 第四个构造器接收
SortedSet<E>,直接复用前一个 Set 的比较器(s.comparator()),因此构造后的顺序完全保留。 - 所有构造器都 在执行构造函数期间显式创建内部 Map,此时不存在竞争,因此构造过程本身线程安全,但一旦发布到多线程中,后续的
addAll等批量初始化需要外部同步或后续操作自身保证安全性(因为addAll非原子)。
Demo 代码:不同的构造方式
import java.util.*;
import java.util.concurrent.*;
public class SkipListSetConstruction {
public static void main(String[] args) {
// 1. 自然排序(元素须实现 Comparable)
ConcurrentSkipListSet<String> set1 = new ConcurrentSkipListSet<>();
set1.add("banana");
set1.add("apple");
System.out.println("自然排序: " + set1); // [apple, banana]
// 2. 自定义比较器(逆序)
ConcurrentSkipListSet<String> set2 =
new ConcurrentSkipListSet<>(Comparator.reverseOrder());
set2.addAll(set1);
System.out.println("逆序比较器: " + set2); // [banana, apple]
// 3. 从 Collection 构造
List<Integer> list = Arrays.asList(5, 3, 9, 1);
ConcurrentSkipListSet<Integer> set3 = new ConcurrentSkipListSet<>(list);
System.out.println("从List构造(自动排序): " + set3); // [1, 3, 5, 9]
// 4. 从 SortedSet 构造(保留比较器)
SortedSet<Integer> sortedSet = new TreeSet<>(Comparator.reverseOrder());
sortedSet.add(10);
sortedSet.add(20);
ConcurrentSkipListSet<Integer> set4 = new ConcurrentSkipListSet<>(sortedSet);
System.out.println("从SortedSet构造(保留逆序): " + set4); // [20, 10]
}
}
Part 3:核心原理篇
模块 5:add 操作——CAS 插入与去重(源码剖析)
add(E e) 方法极为精简:
public boolean add(E e) {
return m.putIfAbsent(e, PRESENT) == null;
}
它调用了 ConcurrentSkipListMap 的 putIfAbsent,如果 key 不存在则插入并返回 null(Set 侧认为添加成功,返回 true);如果 key 已存在则返回旧值(非 null),Set 侧返回 false 表示元素已存在。
深入 ConcurrentSkipListMap.doPut 核心流程:
flowchart TB
A["开始 doPut(key value onlyIfAbsent)"] --> B["findPredecessor(key): 从高层索引跳跃找到底层前驱节点 b"]
B --> C{"b.next 是否存在 且 key 相等?"}
C -->|"是"| D["已存在节点"]
D --> E{"onlyIfAbsent?"}
E -->|"是"| F["返回旧值 (添加失败)"]
E -->|"否"| G["CAS 更新 value"]
C -->|"否"| H["创建新节点 n"]
H --> I["CAS 设置 b.next = n (原子性地将 n 插入链表)"]
I --> J{"CAS 成功?"}
J -->|"失败"| K["自旋重试, 重新查找 b"]
K --> B
J -->|"成功"| L["计算索引层级, 随机决定是否添加 Index"]
L --> M["通过 CAS 在高层索引链表中插入 Index 节点 (失败可能重试)"]
M --> N["返回 null (添加成功)"]
图表说明(结合源码关键方法):
-
第一层:findPredecessor 定位插入位置
add的第一步是调用findPredecessor(key),这是跳表查找的核心。它从最高层headIndex开始,在每一层向右移动到同层下一个节点的 key 仍小于给定 key 的最右位置,然后下沉。最终返回底层链表中 不大于给定 key 的最后一个节点b。整个过程 纯无锁读,仅依赖volatile保证可见性。 -
第二层:去重检查与 CAS 竞争插入
找到b后,遍历b.next检查是否已有相同 key 存在。如果 key 已存在,putIfAbsent直接返回旧值,Set 的add返回 false。
如果 key 不存在,则创建新节点n,需要执行b.casNext(next, n)用 CAS 将b的下一个节点从旧的next更新为n。此时如果有其他线程同时插入,CAS 可能失败,就会 自旋重新执行找前驱 并重试 CAS,直到成功。这种 无锁自旋 是经典的无锁算法设计。 -
第三层:索引层概率性构建
CAS 成功把节点加入底层链表后,方法会通过一个随机数生成器决定新节点应具有的索引层级(类似抛硬币,层级越高概率越小)。然后从 Level 1 向上为每一层创建 Index 节点 并插入到该层的索引链表中,同样使用 CAS 操作right指针。即使索引插入阶段发生竞争导致部分层级未插入,也不会破坏数据正确性,只是暂时降低了查找效率,后续并发操作可修复。 -
关键结论强调:
add 操作是典型的无锁有序插入:纯读定位 + CAS 链入 + 失败重试,全过程没有全局锁,因此高并发下吞吐量远优于加锁的有序集合。
模块 6:remove 与 contains 操作(源码剖析)
remove 源码:
public boolean remove(Object o) {
return m.remove(o, PRESENT);
}
映射到 ConcurrentSkipListMap.remove,其核心流程:
flowchart TB
A["开始 remove(key value)"] --> B["findPredecessor(key) 定位前驱 b"]
B --> C{"b.next 是目标节点?"}
C -->|"否"| D["可能已删除或不存在 返回 false"]
C -->|"是"| E["目标节点 n = b.next"]
E --> F["CAS 设置 n.value = null (标记为逻辑删除)"]
F --> G{"CAS 成功?"}
G -->|"失败"| H["自旋重试"]
H --> B
G -->|"成功"| I["执行 removeNode 物理删除"]
I --> J["CAS 跳过 n: b.casNext(n n.next)"]
J --> K["清理对应的索引节点 (helpDelete)"]
K --> L["返回 true"]
图表说明:
-
第一层:逻辑删除 + 物理删除二阶段
ConcurrentSkipListMap的删除采用 “标记删除” (logical removal) 再 “物理切断” (physical unlink) 的策略。首先通过 CAS 将目标节点的value字段从有效值改为null(或一个特殊标记),代表该节点已无效。此操作 原子性 防止多个线程同时物理删除时产生竞争。 -
第二层:物理删除与 helpDelete 协作
标记成功后,执行removeNode,通过 CAS 将前驱节点的next指针绕过已标记节点,完成物理删除。对应的Index节点也需要被清理,helpDelete机制允许后续遍历的线程“顺手”帮忙解链无效索引节点,体现了 无锁协作设计。 -
第三层:contains 完全无锁
contains(Object o)直接委托m.containsKey(o),后者执行findPredecessor查找,如果找到底层节点且其value != null(未被标记删除)则返回 true。整个过程无任何 CAS 或锁操作,仅是 volatile 读。
关键结论: remove 是唯一会修改结构的写操作,同样基于 CAS,配合逻辑删除机制,保证可见性和安全性;contains 完全无锁,可能读到暂未物理删除但已标记的节点(视为不存在),符合弱一致性。
模块 7:导航与范围操作(源码剖析)
NavigableSet 导航方法如 lower、floor、ceiling、higher 都是线程安全的,实现原理完全基于跳表查找。
lower 方法的查找流程:
flowchart TB
A["lower(E e) 调用 m.lowerKey(e)"] --> B["findPredecessor(e): 找到底层前驱 b"]
B --> C{"b.next 的 key 严格小于 e?"}
C -->|"是"| D["b = b.next 继续验证后续节点"]
D --> C
C -->|"否"| E["b 即为严格小于 e 的最大节点"]
E --> F["返回 b.key (若 b 有效且不等于 base head)"]
F --> G["如果 b 是头节点或无有效节点返回 null"]
图表说明:
-
第一层:利用跳表查找小于给定元素的最大值
lower需要返回 严格小于 e 的最大元素。它调用m.lowerKey(e),内部首先通过findPredecessor(e)定位到e的前驱节点b(即 key 小于 e 或等于 e 的最右节点),然后检查该前驱节点的 key 是否严格小于e,如果相等则继续后退寻找更小的,最终返回那个真实的“小于 e”的节点。 -
第二层:导航方法与索引查找路径
ceiling(e)(返回大于等于 e 的最小元素)类似,但可能直接返回等于 e 的节点。所有导航方法都是 无锁的“读-看-读”模式,依赖跳表的Index加速查找。 -
第三层:范围视图(subSet、headSet、tailSet)的弱一致性
这些方法返回的是ConcurrentSkipListMap的 子视图,通常是ConcurrentSkipListMap.SubMap实例。这些视图与底层跳表共享存储,具有弱一致性:迭代子视图时不会锁住结构,可能看到并发修改的部分结果,但保证不会抛出ConcurrentModificationException。
Demo:导航方法实战
ConcurrentSkipListSet<Integer> set = new ConcurrentSkipListSet<>();
Collections.addAll(set, 10, 20, 30, 40, 50);
System.out.println(set.lower(25)); // 20 (小于25的最大值)
System.out.println(set.floor(25)); // 20 (<=25)
System.out.println(set.ceiling(25));// 30 (>=25)
System.out.println(set.higher(25)); // 30 (>25)
NavigableSet<Integer> subSet = set.subSet(20, true, 40, true);
System.out.println(subSet); // [20, 30, 40]
// 并发修改 subSet 与原始 set 相互可见(弱一致)
Part 4:迭代与一致性篇
模块 8:迭代器的弱一致性与有序遍历
// 迭代器获取
Iterator<E> it = set.iterator();
该迭代器由 ConcurrentSkipListMap.KeySet().iterator() 提供,底层遍历底层 Node 链表。
并发写入下迭代器行为时序图:
sequenceDiagram
participant T1 as 迭代线程
participant Set as ConcurrentSkipListSet
participant T2 as 写线程
T1->>Set: 创建迭代器 (获取first节点引用)
Note over T1,Set: 迭代器持有当前底层链表的起始节点<br>此后不再跟踪结构变化
T2->>Set: add(newElement) 在链表某位置CAS插入
Note over Set: 新节点挂接到链表中
T1->>Set: hasNext() / next()
Note over T1: 由于迭代器沿着旧的next链遍历<br>可能看不到新插入的节点
T2->>Set: remove(someElement) CAS标记删除
T1->>Set: 继续遍历
Note over T1: 若遍历到已标记删除节点<br>则跳过(value为null视为不存在)
图表说明:
-
第一层:迭代器不保证可见最新的写入
ConcurrentSkipListSet的迭代器是 弱一致性 的。它基于迭代器创建时刻底层链表的“快照”,迭代过程中即使其他线程插入或删除元素,迭代器也不会抛出ConcurrentModificationException,但也 不保证遍历到新插入的元素(可能看到也可能看不到),对于删除,它可能会跳过已标记逻辑删除的节点。 -
第二层:有序性的保持
尽管是弱一致性,但迭代器 访问的顺序始终是元素的自然顺序或比较器顺序。因为它沿着底层链表的next指针顺序移动,而底层链表本身始终保持有序性(插入时保证了位置)。 -
第三层:适合的使用模式
在允许读取到稍旧数据的场景(如监控面板、日志分析),这种迭代模式无锁、不阻塞写线程,性能极佳。需要强一致性快照的迭代,应使用Collections.synchronizedSet并在客户端加锁,或者使用CopyOnWriteArraySet(但后者无序且写性能差)。
模块 9:序列化与克隆
ConcurrentSkipListSet 实现了 Serializable 和 Cloneable。
- 序列化:内部将集合元素写出,以及比较器(若为
Comparator实现Serializable),反序列化时重建一个ConcurrentSkipListSet并填充元素。 - 克隆:
clone()返回一个浅拷贝的新ConcurrentSkipListSet,它与原集合共享元素引用,但与底层ConcurrentSkipListMap是独立的。
这部分细节与 HashSet 类似,不再赘述。
Part 5:对比与陷阱篇
模块 10:ConcurrentSkipListSet vs TreeSet——并发有序与非并发有序
| 特性 | ConcurrentSkipListSet | TreeSet (非线程安全) |
|---|---|---|
| 线程安全 | 内置,无需额外同步 | 无,并发访问必须外部同步或用 Collections.synchronizedSortedSet 包装 |
| 底层数据结构 | 跳表 (SkipList) | 红黑树 (Red-Black Tree) |
| 并发读 | 完全无锁 (volatile 读) | 不安全,并发读可能抛出异常或读脏数据 |
| 并发写 | CAS 无锁自旋,高并发吞吐好 | 需全局锁保护,严重竞争时性能下降 |
| 内存开销 | 较大(多层索引节点) | 较小(每个节点左右孩子+颜色) |
| 导航方法 | 支持,O(log n),弱一致性 | 支持,O(log n),强一致性(单线程内) |
| 迭代器行为 | 弱一致性,不抛 CME | 快速失败 (fail-fast),并发修改抛 CME |
有序 Set 并发选型决策流程图:
graph TD
Start["需要并发安全的有序Set"] --> HasNav{"重度使用导航/范围查询?"}
HasNav -->|"是"| CSLS2["ConcurrentSkipListSet"]
HasNav -->|"否"| LowWrite{"写操作频率?"}
LowWrite -->|"极低读极多"| ConsiderImmutable["考虑不可变有序集 如 Guava ImmutableSortedSet"]
LowWrite -->|"中高"| CSLS2
Start --> StrictSnapshot{"需要迭代器强一致性?"}
StrictSnapshot -->|"是"| SyncWrap["Collections.synchronizedSortedSet 并在客户端锁定"]
StrictSnapshot -->|"否"| CSLS2
关键结论: 除非对内存极度敏感且无并发需求,并发有序场景下 ConcurrentSkipListSet 是默认首选。TreeSet 要么被外部同步包装(吞吐量低),要么直接导致线程不安全。
模块 11:ConcurrentSkipListSet vs CopyOnWriteArraySet vs ConcurrentHashMap.newKeySet()
| 特性 | ConcurrentSkipListSet | CopyOnWriteArraySet | ConcurrentHashMap.newKeySet() |
|---|---|---|---|
| 有序性 | 有序(自然/比较器) | 无序 | 无序 |
| 线程安全方式 | CAS 无锁 | 写时复制 (数组快照) | CAS 桶锁 |
| 读性能 | 高(无锁,O(log n)) | 极高(无锁数组遍历,O(n)) | 极高(无锁分段读) |
| 写性能 | 中等(CAS + 索引) | 极低(每次写复制全数组) | 高(CAS 桶操作) |
| 内存开销 | 较高(索引) | 写时高(两份数组) | 中等(哈希表扩容开销) |
| 迭代器一致性 | 弱一致性(快照) | 强一致性(数组快照) | 弱一致性 |
| 导航方法 | 支持(lower/floor 等) | 不支持 | 不支持 |
选型指南:
- 需要有序 →
ConcurrentSkipListSet。 - 无序、写多读多、去重优先 →
ConcurrentHashMap.newKeySet()。 - 无序、读极多写极少(配置数据)、期望迭代强一致性 →
CopyOnWriteArraySet。
注意:CopyOnWriteArraySet 内部完全委托 CopyOnWriteArrayList,每次 add 都复制底层数组,写成本极高,且没有排序能力。
模块 12:常见陷阱与最佳实践
陷阱 1:修改元素属性破坏排序(可变对象作为元素)
class Mutable implements Comparable<Mutable> {
int id;
// ... getters/setters
public int compareTo(Mutable o) { return Integer.compare(this.id, o.id); }
}
ConcurrentSkipListSet<Mutable> set = new ConcurrentSkipListSet<>();
Mutable obj = new Mutable(10);
set.add(obj);
obj.setId(5); // 错误!破坏了排序
System.out.println(set.contains(obj)); // 可能返回 false,但元素存在
正确做法:在并发有序集合中使用不可变对象作为元素,或添加后绝不修改参与比较的字段。
陷阱 2:期望迭代器看到所有最新元素
ConcurrentSkipListSet<Integer> set = new ConcurrentSkipListSet<>();
set.add(1);
Iterator<Integer> it = set.iterator();
set.add(2);
while (it.hasNext()) {
System.out.println(it.next()); // 可能只输出 1
}
解释:迭代器是弱一致性的,并发插入的元素不保证立即可见。对于需要实时性的遍历,应使用 toArray() 或根据业务需求接受部分延迟。
陷阱 3:滥用导致内存膨胀
由于跳表维护多层 Index 节点,对于上千万的大数据量,内存额外开销不可忽视。如果只做 contains 查询,且不需要顺序,务必选 ConcurrentHashMap.newKeySet()。
陷阱 4:比较器未处理 null 导致 NullPointerException
ConcurrentSkipListSet 不允许 null 元素(因为要参与比较),如果传入自定义比较器,比较器自身必须能够处理 null 或者集合拒绝 null。默认自然排序会抛 NPE。
// 错误
ConcurrentSkipListSet<String> set = new ConcurrentSkipListSet<>(Comparator.nullsFirst(String::compareTo));
set.add(null); // 运行正常,但会破坏跳表特定假设,可能引起问题
官方建议 不要插入 null 元素,即使比较器支持,因为 ConcurrentSkipListMap 使用 null 作为特殊标记(删除标记)。
Part 6:总结与面试篇
模块 13:性能总结与注意事项
- 时间复杂度:
add、remove、contains、lower等方法均是平均 O(log n),最坏情况下可能退化到 O(n)(极其罕见,索引层随机构建保证概率平衡)。 - 空间复杂度:相较
TreeSet,由于额外的Index节点,空间开销大约增加 30%~50%(平均每节点约 1.33 个索引指针)。数据量极大时需评估内存。 - 适用场景推荐:
- 高并发、需要实时有序遍历和范围查找(如在线排行榜、价格区间订阅)。
- 延迟队列中维护有序任务(配合
pollFirst、pollLast实现优先级)。 - 替代
Collections.synchronizedSortedSet,获得更高吞吐量。
模块 14:面试高频专题(独立详细)
以下所有面试题均基于 ConcurrentSkipListSet 的核心原理,每一题包含标准回答、追问模拟和加分回答。
1. ConcurrentSkipListSet 的底层实现原理?跳表是什么?为什么不用红黑树?
-
标准回答:
底层委托ConcurrentSkipListMap,它采用跳表 (SkipList) 数据结构。跳表是一种多层索引的有序链表,最高层索引可一次跳过大量节点,查找复杂度 O(log n)。插入和删除只涉及局部前驱节点的指针修改,可通过 CAS 原子指令 实现无锁并发。红黑树在插入删除时需要旋转和染色,影响范围大,难以用细粒度的 CAS 安全实现,通常必须加全局锁。 -
追问模拟:“跳表插入时如何决定节点的索引层级?”
回答:使用一个随机数生成器(类似于抛硬币),通常返回一个层级,层级越高概率指数递减(例如 50% 概率为 level 1,25% 为 level 2…)。这种随机化保证期望的空间开销和平衡性。即使索引层构建失败也不影响正确性。 -
加分回答:
可以提及ConcurrentSkipListMap内部Index节点的right指针使用Unsafe的 CAS 更新,删除前先 CAS 将节点value置为null标记逻辑删除,再物理unlink。并且跳表天然支持 范围查询(如subMap),这比哈希表有本质优势。
2. ConcurrentSkipListSet 如何保证线程安全?读操作需要加锁吗?写操作用了什么同步机制?
-
标准回答:
读操作完全无锁,依靠底层链表节点的volatile修饰保证可见性。写操作(add、remove)采用 CAS (compare-and-swap) 原子操作修改next指针或节点value域,CAS 失败会自旋重试,直至成功。这种机制避免了全局锁,实现高并发。 -
追问模拟:“如果 CAS 一直都失败怎么办?”
回答:会一直自旋重试,但实际中极少发生,因为竞争窗口极小(只是修改一个指针)。Java 的 CAS 底层是 CPU 的原子指令,性能好。在高竞争极端情况下,可结合其他技术(如LongAdder思想),但此处ConcurrentSkipListMap已经过良好测试。 -
加分回答:
remove采用两阶段删除:先标记逻辑删除(CAS 将 value 置 null),再物理摘链,这样即使有线程同时遍历,也能通过检查value是否为空来安全跳过已删除节点,保证无锁遍历的安全性。
3. ConcurrentSkipListSet 和 TreeSet 的区别?为什么 TreeSet 不适合并发?
-
标准回答:
TreeSet底层红黑树,非线程安全,并发访问时会破坏树结构或导致数据不一致。ConcurrentSkipListSet基于跳表,专为并发设计,使用 CAS 保证原子性。若试图用Collections.synchronizedSortedSet包装TreeSet,所有操作都会加全局锁,高并发时性能极差。而ConcurrentSkipListSet的读操作完全无锁,写操作 CAS 无锁,吞吐量通常高出数个数量级。 -
追问模拟:“那在单线程环境下,TreeSet 性能是不是比 ConcurrentSkipListSet 好?”
回答:通常是的。红黑树常数因子更小,且无需额外索引节点,TreeSet的add/remove略快。但差异不大。选择哪种主要取决于是否需要并发。 -
加分回答:
可提及 JDK 8 中ConcurrentSkipListMap引入了findNear优化,减少了部分导航操作中无谓的循环,提高了单线程性能,缩小了与TreeSet的差距。
4. ConcurrentSkipListSet 和 CopyOnWriteArraySet 如何选型?
-
标准回答:
如果 需要有序性(如遍历顺序、范围查询),必然选ConcurrentSkipListSet。在无序情况下,若写操作极少而读操作极多,且数据量不大,CopyOnWriteArraySet的读性能更优且迭代器提供快照一致性。但写操作每次需要复制整个底层数组,开销高昂,不适合写多的场景。 -
追问模拟:“如果数据量很大,CopyOnWriteArraySet 有什么问题?”
回答:写时复制全数组会导致 内存和 CPU 开销急剧增加,甚至导致 GC 压力。因此CopyOnWriteArraySet一般只用于读多写极少、数据量小的配置信息存储。 -
加分回答:
CopyOnWriteArraySet内部基于CopyOnWriteArrayList,其add实际上是通过addIfAbsent实现,需要遍历整个数组检查重复,时间复杂度 O(n),比跳表的 O(log n) 差很多。所以写多时绝不选用。
5. 解释 ConcurrentSkipListSet 的 add 方法执行流程,CAS 在哪里使用?
-
标准回答:
add(e)调用m.putIfAbsent(e, PRESENT)。流程:findPredecessor(e)无锁查找前驱节点b。- 检查
b.next是否已有相等 key,有则返回旧值(add 失败)。 - 创建新节点
n,使用b.compareAndSetNext(oldNext, n)(CAS 操作)将n链入链表。 - 若 CAS 失败则自旋重试。成功后随机决定是否创建上层索引节点,创建时也用 CAS 插入索引链表。
-
追问模拟:“为什么需要自旋重试?”
回答:因为并发插入可能多个线程同时定位到同一个前驱b,只有一个线程的 CAS 能成功,其他竞争失败的线程必须重新定位新的前驱(因为链表已改变)并再次尝试 CAS,这就是无锁算法的标准模式。 -
加分回答:
可以提到findPredecessor过程中会helpDelete协助清理已标记删除的节点,这是一种协作式垃圾回收,提高整体性能,凸显无锁设计的精细之处。
6. ConcurrentSkipListSet 的迭代器是弱一致性的吗?有什么具体表现?
-
标准回答:
是弱一致性的。迭代器创建时获得底层链表起始节点的引用,后续遍历仅沿着next链移动。ConcurrentSkipListSet的迭代器不会抛出ConcurrentModificationException,允许并发修改。表现包括:可能看不到迭代期间新插入的元素;对于删除的元素,如果已经逻辑标记,可能会在遍历时跳过;保证元素按排序顺序出现。 -
追问模拟:“那如何获得一个一致性快照?”
回答:可以通过toArray()获取某一时刻的数组快照,或者使用Collections.synchronizedSet并在外部自己锁定整个集合进行迭代,但会牺牲并发性。 -
加分回答:
弱一致性是并发性能的妥协,也是 CAP 理论中 AP 在 Java 集合中的体现——放弃了强一致性,换取了高可用和分区容忍性(在单 JVM 内体现为无锁高吞吐)。同时可以强调,remove方法采用了 逻辑删除标记,迭代器碰到 value 为 null 的节点会跳过,保证不会读到垃圾数据。
7. ConcurrentSkipListSet 是否允许 null 元素?为什么?
-
标准回答:
不允许。因为底层ConcurrentSkipListMap使用null作为特殊标记(如value=null表示逻辑删除),且元素会参与比较。即使比较器支持 null,ConcurrentSkipListMap的设计仍然拒绝 null key,putIfAbsent内部会通过comparator.compare(key, key)进行自比较检查,若 key 为 null 会抛出 NPE。所以ConcurrentSkipListSet不允许 null 元素。 -
追问模拟:“所有有序并发的 Set 都不允许 null 吗?”
回答:基本如此。NavigableSet的协约建议不允许 null,因为并发环境下 null 与比较器的交互容易出错,且ConcurrentSkipListMap的标记语义需要 null 作为哨兵值。 -
加分回答:
可以提及若一定要使用 null 作为“特殊值”,可以使用包装对象如Optional,或者用一个代表 null 的单例对象,但集合本身不直接存 null。
8. ConcurrentSkipListSet 的导航方法(lower、floor 等)如何实现?
-
标准回答:
这些方法直接委托ConcurrentSkipListMap的对应方法如lowerKey。原理是利用跳表的查找能力:从高层索引开始,找到目标 key 的前驱节点,然后根据语义决定返回值。例如lower(e)返回严格小于 e 的最大节点 key,floor(e)返回小于等于 e 的最大节点 key。整个过程都是无锁读,性能高效。 -
追问模拟:“如果并发下有元素刚好插入到
lower(e)查询涉及的区间,会怎样?”
回答:lower可能看不到新插入的元素(弱一致性),返回那一刻的快照结果。这是可以接受的最终一致性语义。 -
加分回答:
可以指出范围视图subSet返回的是ConcurrentSkipListMap.SubMap,其迭代也是弱一致性的,但使用导航方法单次查询时,由于无锁读瞬间完成,可以视为有较高的瞬时一致性。
9. 为什么说跳表比红黑树更适合并发场景?
-
标准回答:
跳表的结构修改(插入/删除)只涉及 局部的链表指针更新,可以通过单次 CAS 完成链入或链出,且不影响全局结构。红黑树的旋转和变色需要修改多个节点的父/子指针以及颜色,影响范围较大,难以用单个 CAS 保证原子性,通常需要加锁整个树或子树。因此跳表可以轻松实现无锁并发,红黑树则极为困难(JDK 中未提供无锁红黑树实现)。 -
追问模拟:“有没有并发红黑树的实现?”
回答:学术上有通过细粒度锁或事务内存实现的并发红黑树,但极其复杂且性能并不总是优于跳表。Java 选择跳表正是出于简单性与性能的权衡。 -
加分回答:
可补充跳表的关键概率平衡性质:不需要主动维护平衡,通过随机函数自然保持概率上的平衡,避免了复杂的平衡操作,这也是并发友好的原因之一。
10. 如果一个应用需要并发、有序的集合,且有大量范围查询,ConcurrentSkipListSet 是好的选择吗?为什么?
-
标准回答:
是绝对的好选择。ConcurrentSkipListSet天然支持有序性,范围查询(subSet、headSet、tailSet)基于跳表的索引可以在 O(log n) 定位起点,然后沿着底层链表顺序遍历,速度和范围大小呈线性,同时整个过程无锁,不会被其他写操作阻塞。其他并发 Set 如ConcurrentHashMap.newKeySet()不提供有序,需要额外排序,且范围查询极难高效实现。 -
追问模拟:“如果范围查询的跨度极大(百万数据),性能如何?”
回答:遍历所有元素是 O(n),但这是输出所有结果的必要代价,任何数据结构都无法避免。跳表的优势在于找起始点非常快,并且遍历过程与写入并发无冲突,吞吐依然优秀。 -
加分回答:
可以延伸讨论在该场景下搭配Spliterator进行并行范围遍历,利用跳表的有序特性可以轻松切分子任务,实现高效并发处理。这是TreeSet无法安全做到的。
结语
ConcurrentSkipListSet 是 Java 并发编程中解决“高并发、有序集合”需求的瑰宝。它透过巧妙的跳表结构和精细的 CAS 控制,在没有使用任何一处 synchronized 块的情况下实现了线程安全,把空间换时间、概率平衡发挥到了极致。读完本文,相信您不仅能自如运用它,更能从底层原理中汲取无锁并发的设计思想。