集合-Set-ConcurrentSkipListSet

3 阅读28分钟

概述

在 Java 并发集合框架中,ConcurrentSkipListSet 是一个独具魅力的存在。它既不是通过“悲观锁”来保证线程安全,也不像 CopyOnWriteArraySet 那样通过“写时复制”牺牲写性能,而是在底层炼就了一座精密的 跳表 (SkipList) 结构,并全权委托 ConcurrentSkipListMap 实现了一套 无锁的、有序的、支持高并发的集合。本文将为您揭开从跳表层索引的跳跃式查找到 CAS 原子插入的全链路面纱,让您彻底吃透这个在并发有序场景下无可替代的利器。

核心知识点速览:

  • 基于跳表 (SkipList) 的高并发有序实现:底层委托 ConcurrentSkipListMap,以多层索引链表实现 O(log n) 的查找、插入、删除,并通过 CAS 保证并发安全。
  • 无锁并发与弱一致性:读操作完全无锁,写操作通过 CAS 自旋实现原子性,迭代器基于数据快照,不抛 ConcurrentModificationException
  • 有序性与导航方法:元素按自然顺序或比较器排序,支持 lowerfloorceilinghigher 等导航方法及范围视图,适合并发有序遍历和范围查找。
  • 与 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 核心原理篇 涵盖了 addremove/contains、导航与范围操作三大核心流程,它们是理解无锁有序并发集合的绝对主线Part 5 对比与陷阱篇 则囊括与 TreeSetCopyOnWriteArraySetConcurrentHashMap.newKeySet() 的横向对比以及常见陷阱,直接服务于工程选型和避坑。

  • 第三层:图表与源码的映射关系
    阅读时,您可将每一个模块看作对特定源码方法族的深度解剖。例如模块 5 对应 ConcurrentSkipListSet.add()ConcurrentSkipListMap.putIfAbsent()doPut();模块 8 对应 IteratorConcurrentSkipListMap.KeySet 提供。所有流程图、类图、时序图都将与这些具体方法一一对应


Part 1:基础认知篇

模块 1:定义、核心特性与适用场景

定义
ConcurrentSkipListSet<E>基于跳表 (SkipList) 实现 的并发有序集合,它实现了 NavigableSet<E> 接口,底层完全委托给 ConcurrentSkipListMap(一个 key 为集合元素、value 为固定哑元 Boolean.TRUE 的并发映射)。它提供了 O(log n) 的平均时间复杂度 进行插入、删除、包含性检查,且所有操作都是 线程安全 的,不需要外部同步

核心特性列表:

  1. 高并发有序性:元素按照自然顺序或构造时提供的 Comparator 严格排序,任何遍历、子集视图均保持此顺序。
  2. 无锁读 & CAS 写:读操作(如 containsiterator)完全 无锁,借助 volatile 保证可见性;写操作(如 addremove)通过 CAS(Compare-And-Swap)原子指令 修改底层链表的指针,失败则自旋重试,绝无全局锁。
  3. 弱一致性迭代器:迭代器在创建时获取一份 底层数据结构的逻辑快照,不抛出 ConcurrentModificationException,但可能看不到迭代器创建之后插入或删除的元素。
  4. 丰富的导航支持:实现 NavigableSet,提供 lowerfloorceilinghigherpollFirstpollLast 以及子集、头集、尾集等视图。
  5. 元素不允许 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 更优。

  • 第二层:是否大量范围查询
    如果 需要有序,则进一步判断是否存在 大量范围查询(如 subSetheadSet)或导航操作(如找小于某元素的最大值)。这些操作恰好是跳表发挥 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 操作的基本骨架(如 equalshashCode)。同时它实现了 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 接口,提供了线程安全的 lowerKeyfloorKey 等方法,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 内部主要包含三个静态内部类:NodeIndexHeadIndexNode 构成底层有序单向链表,每个节点包含 key、value 以及 volatile Node<K,V> next 指针。Index跳表的索引节点,它持有三个引用:down(指向下一层索引)、right(指向同层右侧索引)、node(指向对应的底层 Node)。HeadIndex 继承自 Index,额外记录了当前层级,每一层索引链表都有一个 HeadIndex 作为头节点。

  • 第二层:跳跃式查找的原理
    查找时从 顶层 HeadIndex 开始,向右尽可能前进直到下一个节点的 key 大于等于目标值,然后下沉到下一层(down)继续向右,直到最底层链表。例如在一个包含百万数据的跳表中,顶层索引可以一次跨越数千个节点,使复杂度降为 O(log n)。这种 多层索引 + 底层链表 的结构天然避免了红黑树在并发旋转和染色时的大范围结构变更,更适合 CAS 操作。

  • 第三层:并发友好的设计
    Nodenext 字段和 Indexright 字段都是 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;
}

它调用了 ConcurrentSkipListMapputIfAbsent,如果 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 导航方法如 lowerfloorceilinghigher 都是线程安全的,实现原理完全基于跳表查找。

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 实现了 SerializableCloneable

  • 序列化:内部将集合元素写出,以及比较器(若为 Comparator 实现 Serializable),反序列化时重建一个 ConcurrentSkipListSet 并填充元素。
  • 克隆clone() 返回一个浅拷贝的新 ConcurrentSkipListSet,它与原集合共享元素引用,但与底层 ConcurrentSkipListMap 是独立的。

这部分细节与 HashSet 类似,不再赘述。


Part 5:对比与陷阱篇

模块 10:ConcurrentSkipListSet vs TreeSet——并发有序与非并发有序

特性ConcurrentSkipListSetTreeSet (非线程安全)
线程安全内置,无需额外同步无,并发访问必须外部同步或用 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()

特性ConcurrentSkipListSetCopyOnWriteArraySetConcurrentHashMap.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:性能总结与注意事项

  • 时间复杂度addremovecontainslower 等方法均是平均 O(log n),最坏情况下可能退化到 O(n)(极其罕见,索引层随机构建保证概率平衡)。
  • 空间复杂度:相较 TreeSet,由于额外的 Index 节点,空间开销大约增加 30%~50%(平均每节点约 1.33 个索引指针)。数据量极大时需评估内存。
  • 适用场景推荐
    • 高并发、需要实时有序遍历范围查找(如在线排行榜、价格区间订阅)。
    • 延迟队列中维护有序任务(配合 pollFirstpollLast 实现优先级)。
    • 替代 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 修饰保证可见性。写操作(addremove)采用 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 好?”
    回答:通常是的。红黑树常数因子更小,且无需额外索引节点,TreeSetadd/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)。流程:

    1. findPredecessor(e) 无锁查找前驱节点 b
    2. 检查 b.next 是否已有相等 key,有则返回旧值(add 失败)。
    3. 创建新节点 n,使用 b.compareAndSetNext(oldNext, n)(CAS 操作)将 n 链入链表。
    4. 若 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 天然支持有序性,范围查询(subSetheadSettailSet)基于跳表的索引可以在 O(log n) 定位起点,然后沿着底层链表顺序遍历,速度和范围大小呈线性,同时整个过程无锁,不会被其他写操作阻塞。其他并发 Set 如 ConcurrentHashMap.newKeySet() 不提供有序,需要额外排序,且范围查询极难高效实现。

  • 追问模拟:“如果范围查询的跨度极大(百万数据),性能如何?”
    回答:遍历所有元素是 O(n),但这是输出所有结果的必要代价,任何数据结构都无法避免。跳表的优势在于找起始点非常快,并且遍历过程与写入并发无冲突,吞吐依然优秀。

  • 加分回答
    可以延伸讨论在该场景下搭配 Spliterator 进行并行范围遍历,利用跳表的有序特性可以轻松切分子任务,实现高效并发处理。这是 TreeSet 无法安全做到的。


结语

ConcurrentSkipListSet 是 Java 并发编程中解决“高并发、有序集合”需求的瑰宝。它透过巧妙的跳表结构和精细的 CAS 控制,在没有使用任何一处 synchronized 块的情况下实现了线程安全,把空间换时间、概率平衡发挥到了极致。读完本文,相信您不仅能自如运用它,更能从底层原理中汲取无锁并发的设计思想。