概述
在 Java 集合框架的版图中,Set 接口是对数学中"集合"概念的精确工程化抽象------一个不允许包含重复元素 的容器。它不仅承载了离散数学的无序性、互异性,更通过精巧的接口设计与实现类体系,为开发者提供了从散列查找、插入顺序维护、有序导航到高并发读写的完整解决方案。本文将带你深入 HashSet、LinkedHashSet、TreeSet、CopyOnWriteArraySet 及 ConcurrentSkipListSet 的底层源码,以详尽的流程图与时序图 揭示它们与对应 Map 实现之间的委托关系 ,剖析去重机制、排序原理、并发原语以及红黑树与跳表的博弈。同时,我们将特别引入对并发安全性的深度分析,对比不同实现在多线程环境下的行为与选型策略。阅读本文后,你将不再停留在"Set 去重"的表层认知,而是能够从数学契约、数据结构选型、性能权衡和 JDK 演进的全维度掌握 Set 分支的精髓。文末专设内容翔实的面试专题,助你从容应对技术深谈。
模块 1:Set 接口设计------无重复元素的数学集合抽象
Set 接口位于 java.util 包,其核心契约可归结为三点:
- 无重复元素 :集合中任意两个元素
e1和e2均满足!e1.equals(e2)。 - 最多包含一个
null元素 (TreeSet因涉及比较而禁止null)。 - 判断相等依赖
equals与hashCode:对于基于散列的 Set(如HashSet),两个元素相等的充分必要条件是(e1.hashCode() == e2.hashCode()) && e1.equals(e2);对于基于比较的 Set(如TreeSet),相等则由比较器或自然顺序决定。
Set 接口本身并未在 Collection 之上增加新方法,但其契约对实现者提出了严格的去重约束。为了减轻实现负担,JDK 提供了抽象骨架实现类 AbstractSet,它实现了 equals、hashCode 和 removeAll 等方法。此外,JDK 1.2 引入的 SortedSet 接口及后续的 NavigableSet,进一步为有序集合定义了范围视图、导航查找等丰富操作。
图表说明 :该 classDiagram 展示了 Set 家族的继承与实现层级。Set 接口自 Collection 派生;AbstractSet 作为抽象骨架,降低了自定义 Set 的门槛。SortedSet 扩展了排序相关方法,而 NavigableSet 在 Java 6 中进一步增强,提供了 lower、floor、ceiling 等导航操作。TreeSet 与 ConcurrentSkipListSet 均实现了 NavigableSet,分别基于红黑树和跳表提供有序集合能力。HashSet 和其子类 LinkedHashSet 则专注于散列表的高效存取。
模块 2:HashSet 深度剖析------基于 HashMap 的散列集合
HashSet 是最常用的 Set 实现,它提供了常数时间的增删查性能,其内部实现完全委托给 HashMap,是所有委托模式实现 Set 的典型代表。
Demo 代码(JDK 8 可运行)
java
import java.util.HashSet;
import java.util.Iterator;
import java.util.Objects;
import java.util.Set;
public class HashSetDemo {
static class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";
}
}
public static void main(String[] args) {
Set<Person> set = new HashSet<>();
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Bob", 25);
Person p3 = new Person("Alice", 30); // 与 p1 逻辑相等
// 添加元素
set.add(p1);
set.add(p2);
boolean added = set.add(p3); // 重复元素,添加失败
System.out.println("添加重复元素 p3 是否成功: " + added);
System.out.println("当前集合大小: " + set.size()); // 2
// 遍历 (顺序无保证)
System.out.println("遍历元素:");
for (Person p : set) {
System.out.println(p);
}
// 包含判断
System.out.println("是否包含 p1: " + set.contains(p1)); // true
System.out.println("是否包含 new Person(\"Alice\",30): " +
set.contains(new Person("Alice", 30))); // true (重写hashCode/equals)
// 删除元素
set.remove(p2);
System.out.println("删除后大小: " + set.size());
// 使用迭代器
Iterator<Person> it = set.iterator();
while (it.hasNext()) {
System.out.println("迭代: " + it.next());
}
}
}
底层原理深入剖析
存储结构:HashMap 委托与哑元对象
HashSet 内部组合了一个 HashMap<E, Object> 实例:
java
private transient HashMap<E,Object> map;
// 所有键映射到同一个哑元值
private static final Object PRESENT = new Object();
操作流程详解(结合源码时序)
1. 插入操作:add(E e)
图文说明 :
上述时序图精确刻画了 HashSet.add 的调用链。核心在于 HashMap.put 方法的返回值:若键已存在,HashMap 会返回与该键关联的旧值(即之前的 PRESENT 对象,非 null),此时 add 返回 false;若键首次插入,put 返回 null,add 返回 true。哈希计算与冲突解决完全由 HashMap 负责,在 Java 8 中,当链表长度超过 8 且桶数组容量 ≥64 时,链表会转化为红黑树以优化最坏情况下的查找性能。源码分析:HashSet.add 就一行 return map.put(e, PRESENT)==null;,毫无额外逻辑。
2. 删除操作:remove(Object o)
图文说明 :
删除操作同样直接委托给 HashMap.remove。HashSet 仅判断 map.remove(o) 的返回值是否等于 PRESENT(即是否成功删除了一个存在的键)。源码中 public boolean remove(Object o) { return map.remove(o)==PRESENT; },逻辑极其简洁。
3. 查询操作:contains(Object o)
图文说明 :
contains 委托给 HashMap.containsKey,该方法通过哈希查找,时间复杂度平均 O(1)。源码实现为 public boolean contains(Object o) { return map.containsKey(o); }。
并发安全性分析
HashSet 不是线程安全的 。在多线程环境下并发修改(如一个线程迭代,另一个线程添加元素)会触发 fail-fast 机制 ,抛出 ConcurrentModificationException。其根源在于 HashMap 内部的 modCount 字段记录了结构性修改次数,迭代器在访问时检查该值是否发生变化。
若需要线程安全,可采用以下三种策略:
| 方案 | 原理 | 并发粒度 |
|---|---|---|
Collections.synchronizedSet(new HashSet<>()) |
所有方法使用 synchronized 互斥锁包装 |
粗粒度,全局锁 |
ConcurrentHashMap.newKeySet() |
基于 ConcurrentHashMap 的 KeySet 视图 |
分段锁 (JDK 7) / CAS + synchronized (JDK 8) |
显式读写锁 ReentrantReadWriteLock |
允许并发读,写互斥 | 自定义控制 |
最佳实践 :高并发写场景推荐 ConcurrentHashMap.newKeySet(),它提供了与 HashSet 相似的 O(1) 性能,且并发度远超同步包装器。
性能分析
- 时间复杂度 :平均情况下,
add、remove、contains均为 O(1);最坏情况下(哈希冲突严重退化为链表或红黑树)退化为 O(log n) 或 O(n)。 - 空间消耗 :每个元素在底层
HashMap中占据一个Node<K,V>对象,包含hash、key、value、next字段,内存开销较大。 - 负载因子与容量 :默认初始容量 16,负载因子 0.75。当元素数量超过
capacity * loadFactor时触发扩容(rehashing),带来一定的性能抖动。
注意事项
- 必须正确重写
hashCode和equals:若自定义对象未重写,则继承自Object的方法基于内存地址判断,导致内容相同的对象被视为不同,去重失效。 - 遍历顺序不确定:顺序取决于哈希桶的分布,且随扩容变化,不可依赖。
- 线程不安全 :并发修改可能抛出
ConcurrentModificationException或造成数据不一致,需使用同步包装或ConcurrentHashMap.newKeySet()替代。
模块 3:LinkedHashSet 深度剖析------维护插入顺序的散列集合
LinkedHashSet 在 HashSet 的基础上增加了可预测的迭代顺序 ,它通过扩展 HashSet 并利用 LinkedHashMap 实现。
Demo 代码(JDK 8 可运行)
java
import java.util.LinkedHashSet;
import java.util.Set;
public class LinkedHashSetDemo {
public static void main(String[] args) {
// 默认插入顺序
Set<String> set = new LinkedHashSet<>();
set.add("banana");
set.add("apple");
set.add("orange");
set.add("apple"); // 重复,不影响顺序
System.out.println("插入顺序遍历: " + set); // [banana, apple, orange]
// 利用 Collections 构建访问顺序 Set(LRU 缓存)
Set<String> lruSet = Collections.newSetFromMap(
new java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(java.util.Map.Entry<String, Boolean> eldest) {
return size() > 3; // 最多保留3个元素
}
});
lruSet.add("A"); lruSet.add("B"); lruSet.add("C");
lruSet.contains("A"); // 访问 A,将其移至链表尾部
lruSet.add("D"); // 触发移除最老元素 B
System.out.println("LRU Set: " + lruSet); // 可能输出 [A, C, D] 或类似
}
}
底层原理深入剖析
LinkedHashSet 的插入、删除、查询流程与 HashSet 完全一致,差异在于底层使用了 LinkedHashMap,它在 HashMap.Node 基础上增加了双向链表指针。
1. 插入流程与双向链表维护
图文说明 :
LinkedHashSet 继承了 HashSet 的所有方法,但构造时通过特定构造器将底层 map 初始化为 LinkedHashMap。LinkedHashMap 的 Entry 类在 HashMap.Node 基础上增加了 before 和 after 两个指针,从而将散列桶中的所有节点串联成一个双向链表。上图展示了 add 操作时,新节点默认被追加到链表尾部(维护插入顺序);若构造函数指定 accessOrder=true,则访问(包括 put 更新、get)会将节点移至尾部,以实现 LRU 顺序。源码片段:
java
// LinkedHashSet 构造器
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
// LinkedHashMap.Entry
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
}
// LinkedHashMap 的 afterNodeInsertion 钩子,put 后回调以维护链表
2. 删除与查询
- 删除 :
LinkedHashSet.remove同样委托LinkedHashMap.remove。LinkedHashMap在删除节点时,除从桶中移除节点外,还会调用afterNodeRemoval钩子将其从双向链表中摘除,保证链表完整性。 - 查询 :
contains执行与HashSet相同的哈希查找,双向链表不参与查询路径,仅用于迭代。
并发安全性分析
与 HashSet 一致,LinkedHashSet 非线程安全 。其 fail-fast 行为同样源自底层 LinkedHashMap 的 modCount 检测。若需线程安全的顺序集合,可结合 Collections.synchronizedSet 与 LinkedHashSet,或使用 ConcurrentSkipListSet(若需排序)或 ConcurrentHashMap.newKeySet()(不关心顺序但需并发安全)。
性能分析
- 时间复杂度 :增删查 O(1)(平均),与
HashSet相同。 - 空间开销:每个节点额外存储两个引用(约 16 字节),但迭代遍历时无需遍历空桶,效率更高。
注意事项
- 若需访问顺序特性,应使用
Collections.newSetFromMap(new LinkedHashMap<>(16, 0.75f, true))。
模块 4:TreeSet 深度剖析------基于红黑树的有序集合
TreeSet 不仅保证元素唯一,还提供了有序性 。底层通过委托给 TreeMap(红黑树实现)来完成所有操作。
Demo 代码(JDK 8 可运行)
java
import java.util.Comparator;
import java.util.TreeSet;
public class TreeSetDemo {
public static void main(String[] args) {
// 自然顺序
TreeSet<Integer> numbers = new TreeSet<>();
numbers.add(5);
numbers.add(2);
numbers.add(8);
System.out.println("有序集合: " + numbers); // [2, 5, 8]
// 导航操作
System.out.println("lower(5): " + numbers.lower(5)); // 2 (严格小于)
System.out.println("floor(5): " + numbers.floor(5)); // 5 (小于等于)
System.out.println("ceiling(6): " + numbers.ceiling(6)); // 8 (大于等于)
System.out.println("higher(6): " + numbers.higher(6)); // 8 (严格大于)
// 自定义比较器 (按长度倒序)
TreeSet<String> strSet = new TreeSet<>(Comparator.comparingInt(String::length).reversed());
strSet.add("apple");
strSet.add("dog");
strSet.add("banana");
System.out.println("自定义排序: " + strSet); // [banana, apple, dog]
}
}
底层原理深入剖析:红黑树交互
1. 插入操作:add(E e) 与红黑树自平衡
图文说明 :
该时序图展示了 TreeSet.add 触发的红黑树插入及自平衡过程。代码路径:
TreeSet.add→TreeMap.put(e, PRESENT)。TreeMap.put从根节点开始,通过比较器确定方向;若找到相同键(compare == 0),直接替换值并返回旧值,TreeSet返回false。- 若未找到,新建节点作为叶子(默认红色),然后调用
fixAfterInsertion进行自平衡:检查父节点颜色,若父红则可能通过变色(叔红)或旋转(叔黑)恢复红黑树性质。 - 最终返回
null表示插入成功。
源码片段:
java
// TreeMap.put 部分逻辑
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0) t = t.left;
else if (cmp > 0) t = t.right;
else return t.setValue(value);
} while (t != null);
// 插入后调用 fixAfterInsertion(e)
2. 删除操作:remove(Object o) 与复杂调整
图文说明 :
删除流程较插入更为复杂:
- 第一步:在红黑树中通过比较器定位待删除节点
p。 - 第二步:若
p有两个子节点,则找到它的后继节点(右子树的最小节点)s,将s的关键字段复制到p,之后实际删除的是s(因为s至多只有一个右子节点)。 - 第三步:判断被删除节点的颜色。若为黑色,则需要进行复杂的平衡调整(
fixAfterDeletion),包括向兄弟节点借一个黑色节点、旋转、变色等操作,以维持"任意路径黑色节点数量相等"的红黑树性质。 - 第四步:物理删除节点(解除引用),返回旧值。
3. 查询操作:contains(Object o) 直接调用 TreeMap.containsKey(o),利用比较器在红黑树上二分查找,时间复杂度 O(log n)。
并发安全性分析
TreeSet 非线程安全 。底层 TreeMap 没有任何同步机制,并发写入可能导致树结构损坏(如节点丢失、循环引用)或丢数据。即使是并发读,若存在并发写,迭代器也可能因 modCount 变化而抛出 ConcurrentModificationException。
并发替代方案:
| 需求 | 推荐方案 | 特点 |
|---|---|---|
| 需要排序 + 线程安全 | ConcurrentSkipListSet |
基于跳表,高并发,O(log n) |
| 不需要排序,但需线程安全 | ConcurrentHashMap.newKeySet() |
O(1),无序 |
| 只需同步,数据量小,并发竞争不高 | Collections.synchronizedSortedSet(new TreeSet<>()) |
全局锁,简单但并发度低 |
性能分析
- 时间复杂度:增删查 O(log n)。
- 空间开销 :每个树节点包含左右子、父引用及颜色,约 40 字节/元素,高于
HashMap.Node。
注意事项
- 元素必须可比较,且
null会抛出NullPointerException。 - 比较器应与
equals一致,否则可能违反Set契约。 - 多线程环境下请选用
ConcurrentSkipListSet。
模块 5:并发场景下的 Set 全景对比与选型策略
在深入分析 CopyOnWriteArraySet 和 ConcurrentSkipListSet 之前,我们先建立一个多线程环境下 Set 选型的宏观视角。以下表格汇总了各实现在并发读写下的行为与适用边界:
| 实现类 | 线程安全 | 读并发度 | 写并发度 | 有序性 | 适用数据量 | 典型场景 |
|---|---|---|---|---|---|---|
HashSet / LinkedHashSet |
否 | - | - | 无序/插入 | 任意 | 单线程程序 |
Collections.synchronizedSet |
是 | 互斥 (一次一个读) | 互斥 | 同原集合 | 任意 | 低并发改造老旧代码 |
CopyOnWriteArraySet |
是 | 完全无锁,快照读 | 全局锁 + 复制数组 | 插入顺序 | 极小 (< 100) | 配置信息、监听器列表、黑名单 |
ConcurrentSkipListSet |
是 | 完全无锁 (搜索) | 局部 CAS + 少量锁 | 排序 | 中大 | 高并发排序场景,如排行榜、时序数据 |
ConcurrentHashMap.newKeySet() |
是 | 高并发读 (无锁) | 高并发写 (分段/CAS) | 无序 | 中大 | 高并发写场景,如实时去重、会话ID管理 |
接下来,我们逐个深入剖析两个并发 Set 的实现原理。
模块 6:CopyOnWriteArraySet 深度剖析------读多写少的并发集合
CopyOnWriteArraySet 位于 java.util.concurrent 包,是 写时复制(Copy-On-Write) 思想在 Set 接口上的体现。它适用于读操作频率远远高于写操作、且数据规模较小的场景。
Demo 代码(JDK 8 可运行)
java
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArraySet;
public class CopyOnWriteArraySetDemo {
public static void main(String[] args) throws InterruptedException {
CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<>();
set.add("A");
set.add("B");
set.add("C");
// 写线程:每秒添加一个元素
Thread writer = new Thread(() -> {
for (char ch = 'D'; ch <= 'Z'; ch++) {
set.add(String.valueOf(ch));
System.out.println("[写] 添加: " + ch);
try { Thread.sleep(500); } catch (InterruptedException e) { break; }
}
});
writer.start();
// 读线程:每 300ms 遍历一次 (快照,不受写影响)
Thread reader = new Thread(() -> {
while (writer.isAlive()) {
System.out.print("[读] 当前快照: ");
Iterator<String> it = set.iterator();
while (it.hasNext()) {
System.out.print(it.next() + " ");
}
System.out.println();
try { Thread.sleep(300); } catch (InterruptedException e) { break; }
}
});
reader.start();
Thread.sleep(3000);
writer.interrupt();
reader.interrupt();
}
}
底层原理深入剖析:写时复制与去重
CopyOnWriteArraySet 直接委托给 CopyOnWriteArrayList,没有任何额外的数据结构。
插入操作:加锁、复制、检查重复
图文说明 :
核心在于 addIfAbsent 的原子性实现:
- 获取锁(
ReentrantLock),保证同一时刻只有一个写线程执行。 - 在锁保护下,获取当前数组快照
elements。 - 调用
indexOf线性扫描判断元素是否已存在(O(n))。 - 若不存在,则通过
Arrays.copyOf复制出一个长度 +1 的新数组,尾部放入新元素。 - 最后通过
setArray将内部volatile引用指向新数组,读线程自此可见新版本。
源码关键段:
java
// CopyOnWriteArrayList.addIfAbsent
public boolean addIfAbsent(E e) {
Object[] snapshot = getArray();
return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
addIfAbsent(e, snapshot);
}
删除与查询流程
- 删除:与添加逻辑类似,加锁后复制一个长度减少的新数组,跳过待删除元素,然后替换引用。
- 查询 :
contains直接调用indexOf(e) >= 0,在不加锁 的情况下遍历当前数组,因此可与其他写操作并发执行,但具有弱一致性(可能看到旧数据)。
迭代器快照特性
CopyOnWriteArraySet 的迭代器持有创建时的数组快照,因此在遍历过程中:
- 不会抛出
ConcurrentModificationException。 - 对写操作完全透明:迭代期间即使有元素被添加或删除,迭代器依然遍历旧快照。
并发安全性深度分析
- 写操作的互斥 :依赖
ReentrantLock确保同一时刻只有一个写线程修改数组,避免了并发写导致的数据损坏。 - 读写分离 :读操作完全无锁,通过
volatile保证内存可见性,写线程完成数组替换后,所有后续读操作自动看到新数组。 - 弱一致性问题:这意味着一个读操作可能无法立即看到刚刚完成的写操作(若它已经拿到了旧数组的引用),但这在配置信息、监听器等场景下是可接受的,甚至带来了极高的读取吞吐量。
性能分析
- 读操作 :
contains需线性扫描 O(n),但无锁,并发吞吐量极高。 - 写操作:加锁且复制整个数组 O(n),内存临时翻倍,写频繁将导致严重的 GC 压力。
- 适用场景:数据规模小(通常 < 100),写频率极低(如每小时几次),读频繁(如每次请求都要遍历检查)。
注意事项
- 严禁大数据量:假设集合有 10 万元素,每次添加都需复制 10 万长度的新数组,性能和内存均无法接受。
- 写性能瓶颈:高并发写时,所有写线程在锁上排队,且复制数组开销巨大。
- 迭代器不支持 remove() :调用会抛出
UnsupportedOperationException。
模块 7:ConcurrentSkipListSet 深度剖析------高并发有序集合
ConcurrentSkipListSet 是高并发场景下的有序 Set 首选,它基于**跳表(SkipList)**数据结构,实现了无锁或细粒度锁的并发控制,在提供 O(log n) 操作复杂度的同时,支持多线程高吞吐。
Demo 代码(JDK 8 可运行)
java
import java.util.concurrent.ConcurrentSkipListSet;
public class ConcurrentSkipListSetDemo {
public static void main(String[] args) throws InterruptedException {
ConcurrentSkipListSet<Integer> set = new ConcurrentSkipListSet<>();
set.add(10);
set.add(5);
set.add(15);
System.out.println("有序集合: " + set); // [5, 10, 15]
// 导航操作
System.out.println("lower(10): " + set.lower(10)); // 5
System.out.println("floor(10): " + set.floor(10)); // 10
System.out.println("ceiling(13): " + set.ceiling(13)); // 15
System.out.println("higher(13): " + set.higher(13)); // 15
// 并发测试:100 个线程各插入 1000 个元素
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
set.add(Thread.currentThread().getId() * 10000 + i);
}
};
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("并发插入后集合大小: " + set.size());
}
}
底层原理深入剖析:跳表与无锁并发
ConcurrentSkipListSet 完全委托给 ConcurrentSkipListMap,后者是并发跳表的实现。
跳表数据结构简介
跳表是一种多层链表索引结构:
- Level 0(底层):包含所有元素的有序单向链表。
- Level 1 ~ N:每一层都是下一层的稀疏索引,节点数约为下层的一半(通过随机概率决定是否提升)。
查找时从最高层开始,向右移动直到超过目标值,然后下降一层,重复此过程。这种"高层跳、低层走"的策略将查找复杂度降为 O(log n)。由于没有复杂的旋转操作,并发控制比红黑树更易实现。
插入操作:无锁 CAS 构建多层索引
图文说明 :
ConcurrentSkipListSet 的操作均转发给 ConcurrentSkipListMap。跳表插入分为三步:
- 查找前驱 :
findPredecessor从最高层索引头节点开始,向右找到小于插入键的最大节点,然后下降一层继续,直到底层链表。整个过程通过compareAndSwap无锁更新next指针,若出现竞争则重新尝试。 - 底层插入:在底层链表中,通过 CAS 将新节点链接到前驱之后。若 CAS 失败(被其他线程抢先),则重新查找前驱并重试。
- 建立索引:通过随机函数决定新节点的层高(例如抛硬币,连续正面则加一层,概率 50%)。然后创建垂直索引节点,并从第 1 层到第 level 层依次通过 CAS 插入到各层链表中。索引层的插入也可能因 CAS 失败而重试。
源码片段(简化):
java
// ConcurrentSkipListMap.doPut 部分逻辑
for (;;) {
Node<K,V> b = findPredecessor(key, cmp); // 查找前驱
for (;;) {
Node<K,V> n = b.next;
// ... 检查是否需协助删除等
if (b.casNext(n, z)) { // CAS 插入新节点
// 随机计算层级
int rnd = ThreadLocalRandom.nextSecondarySeed();
if ((rnd & 0x80000001) == 0) {
int level = 1;
while (((rnd >>>= 1) & 1) != 0) ++level;
// 建立索引
Skiplist.buildIndex(z, level);
}
return null;
}
// 若 CAS 失败,重试
}
}
删除操作:逻辑删除 + 物理移除
删除分为两个阶段:
- 逻辑删除 :将目标节点的
value字段通过 CAS 置为null,并设置删除标记。 - 物理删除 :后续的遍历或插入操作会"协助"将被标记的节点从链表中摘除(
helpDelete),实现懒删除。
这种设计避免了加锁,同时保证了并发正确性。
查询操作:contains(Object o)
通过 findPredecessor 类似的查找过程,在跳表索引层加速定位到底层链表,然后扫描比较,复杂度 O(log n)。
并发安全性深度分析
ConcurrentSkipListSet 的并发控制精妙之处在于:
- 细粒度锁定 :仅在极少数需要修改多层索引结构时使用少量
synchronized块(JDK 8 对synchronized做了大量优化),多数操作依赖 CAS 循环。 - 无锁读 :所有读操作(
contains、iterator)均不用锁,通过volatile变量保证内存可见性。 - 并发协助:遍历过程中若遇到被标记删除的节点,会主动帮助将其移除,加速删除操作的完成。
与 TreeSet + 同步包装器的对比测试结果(多核并发写场景):
ConcurrentSkipListSet吞吐量是synchronizedSortedSet(new TreeSet())的 5~10 倍。- 原因在于同步包装器在所有方法上使用了全局互斥锁,导致读写全部串行化。
性能分析
- 时间复杂度 :增删查平均 O(log n),最坏 O(n)(概率极低,与随机数质量有关)。
- 空间开销 :节点需存储多层
next指针(层数随机),平均额外空间约 1.33 倍,比红黑树略高,但在并发收益面前可接受。 - 并发度:近乎无锁,适用于高并发读写场景。
注意事项
- 元素必须可比较 :与
TreeSet相同,必须实现Comparable或提供Comparator,否则抛出ClassCastException。 null处理 :一般不允许null元素(与TreeSet一致)。- 弱一致性迭代 :迭代器是弱一致的,不会抛出
ConcurrentModificationException,但可能不反映迭代开始后的更新。
模块 8:面试专题------Set 高频考题深度解析
本章节是 Set 面试内容的总集成,涵盖从基础概念到并发进阶的 15 道高频题目。每道题均包含标准回答、深度追问及加分回答,旨在帮你构建完整的应答体系。
面试官:我们先从最常用的开始:HashSet 如何保证元素唯一性?
标准回答 :
HashSet 内部基于 HashMap 实现,元素作为 Map 的键存储,值是一个共享的 Object 常量 PRESENT。当调用 add(E e) 时,实际执行 map.put(e, PRESENT) == null。HashMap 的 put 方法通过 hashCode 定位桶,然后遍历桶内节点,若通过 equals 找到相同键则替换旧值并返回旧值(非 null),add 返回 false;若未找到则插入新节点并返回 null,add 返回 true。因此,元素的唯一性由 HashMap 键的唯一性保证。
追问 1 :HashMap 的 put 方法返回值机制具体是怎样的?
回答 :put方法返回该键之前关联的旧值,若首次插入则返回null。HashSet中所有键的值都是PRESENT(非null),所以返回null必定代表插入新键。
追问 2 :如果在多线程环境下同时往 HashSet 添加同一个元素,会发生什么?
回答 :HashSet 非线程安全,可能造成数据不一致甚至结构损坏。两个线程可能同时判断元素不存在,然后各自执行put,但由于HashMap的put并非原子操作,可能导致一个线程覆盖另一个线程的修改,或者引发死循环/链表环(JDK 7 扩容时常见)。在高并发下应使用ConcurrentHashMap.newKeySet()。
加分回答 :Java 8 中当链表长度超过 8 且桶数组容量 ≥64 时,桶内结构会转为红黑树,此时键的比较会涉及compareTo辅助排序,但相等性仍然依赖equals。
面试官:HashSet 与 TreeSet 的去重依据有何不同?
标准回答 :
HashSet 依据 hashCode 和 equals 判断元素是否重复;而 TreeSet 仅依据比较器(Comparator)或自然顺序(Comparable)的 compare/compareTo 方法返回值是否为 0 来判断重复,与 equals 无关。
追问 1 :若 compare 返回 0 但 equals 返回 false,TreeSet 会如何处理?
回答 :TreeSet会认为这两个元素相同,拒绝添加第二个。这违反了Set接口基于equals定义的契约(虽然文档允许这种不一致),因此在设计自定义比较器时应尽量保证一致性。
追问 2 :如果要在 TreeSet 中存储自定义对象,但不想实现 Comparable,怎么办?
回答 :通过TreeSet(Comparator<? super E> comparator)构造器传入一个外部比较器。比较器须满足比较逻辑的传递性、自反性和对称性。
加分回答 :比较器还应考虑与序列化相关的特性,因为TreeSet本身是可序列化的,内部比较器如果不是Serializable会在序列化时抛出异常。
面试官:LinkedHashSet 如何维护插入顺序?
标准回答 :
LinkedHashSet 继承 HashSet,但通过构造器钩子将底层 HashMap 替换为 LinkedHashMap。LinkedHashMap 在 Node 基础上增加了 before、after 两个指针,构建了一个双向链表。默认链表记录插入顺序;若设置 accessOrder=true,则每次访问会将节点移至链表尾部,实现访问顺序。
追问 1 :如何基于 LinkedHashSet 实现一个固定大小的 LRU 缓存?
回答 :LinkedHashSet不直接支持,但可通过Collections.newSetFromMap(new LinkedHashMap<K,Boolean>(16,0.75f,true){ protected boolean removeEldestEntry(Map.Entry<K,Boolean> eldest) { return size() > maxSize; }})。
追问 2 :LinkedHashSet 的迭代器在并发修改时会抛出异常吗?
回答 :是的,与HashSet一样非线程安全,并发修改会触发ConcurrentModificationException。线程安全版本可用Collections.synchronizedSet包装。
面试官:TreeSet 为什么不允许 null?
标准回答 :
因为插入元素时需要调用 compareTo 或 Comparator.compare,若元素为 null 则抛出 NullPointerException。即使自定义比较器能处理 null,JDK 源码在构造器或 add 中仍有显式的 NPE 检查,故不允许 null。
追问 1 :如果一定要在有序集合中存储 null,有什么办法?
回答 :可以使用Collections.singleton(null)但那是不可变单元素集。若需要可变有序且允许 null,可自定义比较器并在调用TreeSet方法前做特殊处理,但 JDK 的TreeSet无法绕过 NPE 检查。替代方案是使用ArrayList排序,但会牺牲去重功能。
追问 2 :Comparable 和 Comparator 在 Java 中能处理 null 吗?
回答 :Comparable不能;Comparator可以通过Comparator.nullsFirst或nullsLast包装,但如上所述,TreeSet依然会主动抛 NPE。
面试官:CopyOnWriteArraySet 的适用场景与局限性?
标准回答 :
适用于读多写极少、数据量小的场景,如系统配置、监听器列表。局限性在于写操作需加锁并复制整个数组 O(n),数据量过大或写频繁会导致严重性能问题和 GC 压力。
追问 1 :数据量多大算"过大"?
回答:没有绝对阈值,通常当元素超过几百时,写操作延时和内存抖动即开始显著增加。生产环境中普遍建议不超过 100~200。
追问 2 :它的 contains 方法时间复杂度 O(n),为何还强调适合读多写少?
回答 :此处的"读"主要指迭代遍历 。在配置管理等场景中,主要操作是遍历所有元素执行逻辑,而非大量调用contains。遍历基于快照且完全无锁,吞吐量极高。若contains操作占多数且数据量大,应改用ConcurrentHashMap.newKeySet()。
追问 3 :它的迭代器支持 remove 吗?
回答 :不支持,调用会抛出UnsupportedOperationException。
面试官:Set 如何实现交集、并集、差集操作?
标准回答 :
Set 从 Collection 继承了 retainAll(交集)、addAll(并集)、removeAll(差集)。这些方法通过迭代和 contains 实现,时间复杂度通常 O(n×m),具体因实现而异。
追问 1 :
HashSet的retainAll底层如何优化?
回答 :HashSet的containsO(1),因此retainAll接近 O(n)。但AbstractCollection的默认实现是双重循环,性能较差。具体可看源码。
追问 2 :如果两个 Set 都很大,如何更高效地求交集?
回答 :可遍历较小的 Set,对另一个 Set 调用contains,以减少遍历次数。或者使用 Java 8 Stream API:set1.stream().filter(set2::contains).collect(Collectors.toSet())。
面试官:EnumSet 的实现原理与性能优势?
标准回答 :
EnumSet 内部采用位向量存储,每个枚举常量对应一个位。操作通过位运算完成,O(1) 极快,空间极省。
追问 1 :它的迭代顺序是什么?
回答 :枚举常量的声明顺序,与ordinal()值一致。
追问 2 :它线程安全吗?
回答 :否,与HashSet一样非线程安全。
面试官:如何选择合适的 Set 实现类?
标准回答:
- 是否需要排序? → 是且需并发 →
ConcurrentSkipListSet;是单线程 →TreeSet。 - 是否需预测迭代顺序? → 插入顺序 →
LinkedHashSet;枚举 →EnumSet;否则 →HashSet。 - 是否线程安全? → 读多写极少小数据量 →
CopyOnWriteArraySet;高并发读写 →ConcurrentHashMap.newKeySet();简单同步 →Collections.synchronizedSet。
追问 1 :
ConcurrentHashMap.newKeySet()与ConcurrentSkipListSet如何抉择?
回答 :需要排序或导航方法(如ceiling)时用后者;否则前者提供 O(1) 性能,更适合高频写入。
追问 2 :CopyOnWriteArraySet与ConcurrentSkipListSet的 contains 性能对比?
回答 :数据量小时差异不大;超过数百后,ConcurrentSkipListSet的 O(log n) 显著优于 O(n)。
面试官:HashSet 的容量和负载因子如何影响性能?能否直接设置?
标准回答 :
可通过 HashSet(int initialCapacity, float loadFactor) 构造器设置。容量过小导致频繁扩容,负载因子过高加剧冲突。建议根据预期元素数量预估容量:new HashSet<>(expectedSize / 0.75f + 1)。
追问 :已经存在大量元素的 HashSet 如何优化?
回答 :无法原地调整,只能新建一个预估好容量的 HashSet 并调用addAll迁移。
面试官:为什么重写 equals 必须重写 hashCode?
标准回答 :
Java 对象契约规定:若 a.equals(b) 为 true,则 a.hashCode() 必须等于 b.hashCode()。HashSet/HashMap 依赖 hashCode 先定位桶,若不等则不会调用 equals,导致内容相同对象被视为不同。
追问 :若只重写 hashCode 不重写 equals 会怎样?
回答 :能定位到相同桶,但equals默认比较内存地址,仍然认为两个对象不同,去重可能失效(除非两个引用指向同一对象)。
追问 :为什么 hashCode 每次返回常数 1 也能工作?
回答:可以,但所有元素会落入同一桶,链表/红黑树过长,性能退化为 O(n) 或 O(log n),失去散列优势。
面试官:LinkedHashSet 的迭代性能比 HashSet 好吗?
标准回答 :
在元素较少或稀疏时,LinkedHashSet 沿双向链表遍历,避开了空桶扫描,迭代速度更快;填充紧密时两者均为 O(n),差异不明显。选择主要看是否需要顺序。
面试官:TreeSet 如何实现范围视图?视图修改会影响原集合吗?
标准回答 :
subSet、headSet、tailSet 返回原集合的视图,不复制元素。视图修改会写回原集合,反之亦然。视图会检查插入元素的边界。
追问 :遍历视图时向原集合添加越界元素会怎样?
回答 :原集合可以添加,但视图迭代器不会看到该元素(若在边界外),若在边界内可能抛出ConcurrentModificationException。
面试官:CopyOnWriteArraySet 在迭代期间添加元素,迭代器会看到吗?为什么?
标准回答 :
不会。因为迭代器持有创建时刻的数组快照,后续写操作复制了新数组,不影响迭代器持有的旧数组。这是弱一致性的表现,也是其无锁读的关键。
面试官:ConcurrentSkipListSet 与 CopyOnWriteArraySet 的 contains 性能对比?
标准回答 :
前者 O(log n),后者 O(n)。当数据量大于几百时,前者远快于后者。同时前者支持高并发写,后者写操作昂贵。
面试官:Java 9 引入的 Set.of() 创建的集合有何特性?
标准回答 :
不可变,不允许 null,内存占用极小,线程安全。与 Collections.unmodifiableSet 不同,后者只是视图,原集合变更视图会变。
追问 :Set.of() 的顺序有保证吗?
回答 :无,取决于 JDK 实现和元素数量。需顺序可用List.of()。
模块 9:实战陷阱与最佳实践(附完整 Demo)
陷阱 1:自定义对象存入 HashSet 未重写 hashCode/equals → 去重失效
错误 Demo:
java
class BadKey {
String name;
BadKey(String name) { this.name = name; }
}
Set<BadKey> set = new HashSet<>();
set.add(new BadKey("a"));
set.add(new BadKey("a"));
System.out.println(set.size()); // 2 (错误)
正确 :使用 IDE 重写 equals 和 hashCode。
陷阱 2:TreeSet 中修改已存入对象的比较字段
错误 Demo:
java
class Mutable implements Comparable<Mutable> {
int id;
// ... compareTo 和 setter
}
TreeSet<Mutable> set = new TreeSet<>();
Mutable m = new Mutable(5);
set.add(m);
m.setId(1); // 破坏树顺序
System.out.println(set.contains(m)); // 可能 false
正确:存入 TreeSet 的对象应不可变,或保证参与比较的字段在生命周期内不变。
陷阱 3:并发场景使用 HashSet → 数据损坏或异常
错误 Demo:
java
Set<Integer> set = new HashSet<>();
new Thread(() -> { for (int i=0;i<1000;i++) set.add(i); }).start();
new Thread(() -> { for (int i=0;i<1000;i++) set.add(i); }).start();
正确 :使用 ConcurrentHashMap.newKeySet()。
陷阱 4:需要原顺序返回时误用 HashSet → 应改用 LinkedHashSet
java
Set<String> set = new HashSet<>();
set.add("z"); set.add("a"); // 顺序不定
正确 :new LinkedHashSet<>()
陷阱 5:大数据量 CopyOnWriteArraySet 频繁写入
java
CopyOnWriteArraySet<Integer> set = new CopyOnWriteArraySet<>();
for (int i=0;i<100_000;i++) { set.add(i); } // 灾难
正确 :使用 ConcurrentHashMap.newKeySet() 或预填充。
模块 10:时间复杂度总结与 JDK 演进
操作复杂度对照表
| 实现类 | add | remove | contains | 迭代顺序 | 线程安全 |
|---|---|---|---|---|---|
| HashSet | O(1) | O(1) | O(1) | 无序 | 否 |
| LinkedHashSet | O(1) | O(1) | O(1) | 插入/访问顺序 | 否 |
| TreeSet | O(log n) | O(log n) | O(log n) | 自然/比较顺序 | 否 |
| CopyOnWriteArraySet | O(n) | O(n) | O(n) | 插入顺序 | 是 |
| ConcurrentSkipListSet | O(log n) | O(log n) | O(log n) | 自然/比较顺序 | 是 |
| EnumSet | O(1) | O(1) | O(1) | 枚举声明顺序 | 否 |
JDK 演进新增 API
- Java 9 :
Set.of(E... elements)创建不可变 Set。 - Java 10 :
Set.copyOf(Collection<? extends E> coll)。 - Java 17:持续底层优化。
结语
从数学集合的抽象定义到 Java 工程中的多种实现,Set 接口及其派生类展示了数据结构与算法在真实系统中的精妙权衡。HashSet 以 O(1) 的效率满足绝大多数场景;LinkedHashSet 以微小代价换取可预测迭代;TreeSet 用红黑树支撑有序与范围操作;CopyOnWriteArraySet 和 ConcurrentSkipListSet 则在并发世界中各显神通。理解它们底层与 Map 的委托关系,并根据并发需求做出正确选型,是每一位 Java 专家进阶之路上的必修课。希望本文能帮助你不仅答出面试中的标准答案,更能在系统设计时做出精准的选型。