概述
CopyOnWriteArraySet 是 Java 并发集合框架中一个极致读优化的 Set 实现。它本质上不是直接管理元素存储,而是通过对 CopyOnWriteArrayList 的封装与 addIfAbsent 委托,在写时复制(Copy-On-Write, COW)的基础上保证了元素的唯一性,从而形成了一套读写完全分离的并发安全模型。本文将为读者揭示从 COW 数组拷贝、线性遍历去重,到迭代器快照、弱一致性认知的全链路原理,并为您提供从源码到选型的全景技术视图。
核心知识点:
- 基于
CopyOnWriteArrayList的委托实现:CopyOnWriteArraySet内部组合了一个CopyOnWriteArrayList,所有元素存储、并发控制均由后者完成,唯一性通过其addIfAbsent方法保证。 - 写时复制的并发安全模型:所有写操作(add、remove)均加
ReentrantLock,复制整个底层数组并在新数组上修改,最后替换引用;读操作完全无锁,不会阻塞,做到读写分离。 - 基于快照的弱一致性迭代器:迭代器在创建时持有底层数组的快照,遍历期间不抛出
ConcurrentModificationException,但无法感知后续的任何写操作,这是一种典型的弱一致性(weakly consistent)。 - 性能特性的两极分化:读操作(如
get(index)间接由数组支持,索引读 O(1))极高吞吐;写操作(add/remove)需要全量复制数组,时间复杂度为 O(n),适合读多写少且集合规模较小的场景。 - 元素唯一性保障方式:通过线性遍历
indexOf检查元素是否存在,而非依赖hashCode/equals的哈希查找,因此contains方法也是 O(n) 复杂度,这是它与其他Set最本质的性能差异。
下面是本文的全文组织架构图,帮助您快速掌握信息流与逻辑递进:
graph TD
subgraph P1["Part 1: 基础认知篇"]
A1["模块1: 定义 核心特性与适用场景"]
A2["模块2: 接口与继承体系"]
end
subgraph P2["Part 2: 存储与构造篇"]
B1["模块3: 存储结构与底层依赖"]
B2["模块4: 构造方法"]
end
subgraph P3["Part 3: 核心原理篇"]
C1["模块5: add操作"]
C2["模块6: remove/contains操作"]
C3["模块7: 批量操作"]
end
subgraph P4["Part 4: 迭代器与一致性篇"]
D1["模块8: 迭代器快照与弱一致性"]
D2["模块9: 序列化机制"]
end
subgraph P5["Part 5: 对比与陷阱篇"]
E1["模块10: vs HashSet/synchronizedSet"]
E2["模块11: vs ConcurrentSkipListSet"]
E3["模块12: 常见陷阱与最佳实践"]
end
subgraph P6["Part 6: 总结与面试篇"]
F1["模块13: 注意事项与性能总结"]
F2["模块14: 面试高频专题"]
end
P1 --> P2 --> P3 --> P4 --> P5 --> P6
图表说明:
- 第一层:全文被划分为六大篇章,从左到右依次递进;基础认知篇 先建立定义与场景直觉,存储与构造篇 揭示内部结构与创建方式,核心原理篇 深入源码拆解关键操作,迭代器与一致性篇 解释弱一致性的由来与影响,对比与陷阱篇 将 CopyOnWriteArraySet 放入实际工程坐标系中进行横向对比与避坑,最后 总结与面试篇 汇总性能特性并解答高频面试点。
- 第二层:每个 subgraph 内包含该篇章的核心模块(如 add 操作、迭代器机制等),模块之间相互关联、层层深入;例如必须先理解存储结构(模块3)才能理解 add 的 copy-on-write 流程(模块5),而迭代器的快照行为又依赖于写时复制时数组引用的替换(模块8)。
- 第三层:箭头表示阅读的推荐顺序,从 Part 1 到 Part 6 对应“是什么→如何构建→如何工作→如何迭代→如何选择→如何总结面试”的完整认知路径,每个篇章既是独立的深度专题,又为下一篇铺路。
Part 1: 基础认知篇
模块 1:定义、核心特性与适用场景
CopyOnWriteArraySet 是一个线程安全的、基于写时复制数组的无序集合,它不允许重复元素,内部完全委托给 CopyOnWriteArrayList。从定义明确几点:
- 线程安全:所有对集合的修改操作(添加、删除)均由内部锁(
ReentrantLock)保护,且每次修改都会生成底层数组的全新拷贝,因此其他线程的读操作不受影响。 - 元素不重复:通过
addIfAbsent方法在写入前进行线性检查,若已存在则放弃添加。 - 无序:不保证元素的迭代顺序,也不支持基于索引的随机位置插入。
- 允许 null 元素:与
HashSet相同,可存储一个 null(因为重复 null 会被去重逻辑过滤)。
核心特性列表(结合源码行为):
- 读无锁:读操作不获取任何锁,完全依赖
volatile语义保证数组引用的可见性,读操作永不阻塞。 - 写时复制保证并发安全:修改时复制整个内部数组,替换引用,写线程之间互斥,写读之间无锁竞争。
- 迭代器快照,弱一致性:迭代器遍历的是创建时的数组快照,后续写操作不可见,也不抛出
ConcurrentModificationException。 - 元素唯一性由线性查找保证:不存在哈希分桶,每次
add调用indexOf扫描数组,时间复杂度 O(n)。 - 允许 null 元素:内部实现支持 null(
indexOf兼容 null 比较)。 - 无顺序保证:迭代顺序依赖于数组存储顺序,该顺序随写操作而改变。
- 写操作开销大,读操作高效:add/remove 引发全量数组拷贝,内存和 CPU 消耗与集合大小成正比。
适用场景决策树(帮助快速判断是否该使用它):
flowchart TD
A["需要一个并发安全的Set"] --> B{"是否要求元素有序?"}
B -->|"是"| C["考虑 ConcurrentSkipListSet"]
B -->|"否"| D{"集合元素数量通常 < 1000?"}
D -->|"否"| E["考虑 ConcurrentHashMap.newKeySet 或 synchronizedSet"]
D -->|"是"| F{"写操作频率极低? 读远远大于写?"}
F -->|"否"| G["写密集场景不适用"]
F -->|"是"| H{"能否容忍迭代器弱一致性?"}
H -->|"否"| I["需要使用锁或同步视图"]
H -->|"是"| J["使用 CopyOnWriteArraySet 是最优解"]
图表说明:
- 第一层判断:如果需要有序集合,
ConcurrentSkipListSet天然基于跳表实现有序,无需考虑CopyOnWriteArraySet。 - 第二层:数据量级——
CopyOnWriteArraySet的写操作 O(n) 复制代价随 n 线性增长,经验阈值约 1000 个元素;若数据量很大,即使读多写少也会因为每次写操作长时间占用 CPU/内存而影响性能,此时ConcurrentHashMap.newKeySet()的并发哈希实现更合适。 - 第三层:读写比例——“读多写少”是核心前提。若写频繁(如每秒上百次 add),全量拷贝将导致 GC 压力和响应延迟暴增。
- 第四层:一致性要求——迭代器快照弱一致性意味着读到的可能是旧数据;如果业务必须实时感知最新元素,则需要用同步锁版本(
Collections.synchronizedSet(new HashSet<>()))或并发哈希集。
适用场景:
- 事件监听器注册/注销集合(注册、移除很少,遍历通知频繁)。
- 全局配置、黑白名单缓存,初始化后极少修改,但高并发读取。
- 小型元数据缓存,例如服务注册中心的少量服务节点列表。
反例场景:
- 需频繁添加/删除的临时工作集。
- 要快速
contains判断的大量数据查询(O(n) 不可接受)。 - 必须强一致性的场景(如转账操作中的账户集合)。
模块 2:接口与继承体系
CopyOnWriteArraySet 的类继承关系极为简洁:
classDiagram
class Set~E~ {
<<interface>>
+add(E e) boolean
+remove(Object o) boolean
+contains(Object o) boolean
+iterator() Iterator~E~
+size() int
}
class AbstractSet~E~ {
<<abstract>>
-AbstractSet()
+equals(Object o) boolean
+hashCode() int
+removeAll(Collection~?~ c) boolean
}
class CopyOnWriteArraySet~E~ {
-CopyOnWriteArrayList~E~ al
+CopyOnWriteArraySet()
+CopyOnWriteArraySet(Collection~E~ c)
+add(E e) boolean
+remove(Object o) boolean
+contains(Object o) boolean
+iterator() Iterator~E~
+size() int
}
class CopyOnWriteArrayList~E~ {
-transient volatile Object[] array
-final ReentrantLock lock = new ReentrantLock()
+addIfAbsent(E e) boolean
+indexOf(Object o) int
+remove(Object o) boolean
+getArray() Object[]
+setArray(Object[] a) void
}
Set <|.. AbstractSet
AbstractSet <|-- CopyOnWriteArraySet
Set <|.. CopyOnWriteArraySet
CopyOnWriteArraySet *-- CopyOnWriteArrayList : "al"
CopyOnWriteArrayList *-- Object : "array(volatile)"
CopyOnWriteArrayList *-- ReentrantLock : "lock"
图表说明:
- 第一层接口与抽象类:
Set接口定义了集合的基本契约;AbstractSet提供了equals、hashCode等通用实现,其中removeAll默认用迭代器遍历,这一点对CopyOnWriteArraySet来说与CopyOnWriteArrayList的快照迭代器兼容。 - 第二层 CopyOnWriteArraySet:它不直接持有元素容器,而是通过组合一个
CopyOnWriteArrayList实例al完成所有功能。继承AbstractSet使它可以复用部分抽象方法,但所有核心方法(add、remove、contains 等)全部重写并直接委托给al。 - 第三层底层依赖:
CopyOnWriteArrayList内部包含一个volatile transient Object[] array,以及一个ReentrantLock lock,这是整个写时复制机制的物理基础。CopyOnWriteArraySet没有 HashMap,没有红黑树,只有一个数组。 - 关键设计结论:CopyOnWriteArraySet 的线程安全、元素唯一性和弱一致性全部继承自 CopyOnWriteArrayList 的数组复制模型,它仅充当一个适配层,用
addIfAbsent语义将 List 转变为 Set。
Part 2: 存储与构造篇
模块 3:存储结构与底层依赖(源码剖析)
从模块 2 的类图可知,CopyOnWriteArraySet 的存储结构间接为一个 Object[] 数组,这个数组位于它的唯一字段 al 内部。
在 JDK 8 中,顶部字段代码如下(简化):
public class CopyOnWriteArraySet<E> extends AbstractSet<E> implements java.io.Serializable {
private final CopyOnWriteArrayList<E> al;
// 所有方法的形式都是: return al.someMethod(...)
}
而 CopyOnWriteArrayList 的核心域:
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, ... {
final transient ReentrantLock lock = new ReentrantLock();
private transient volatile Object[] array;
...
}
为什么不用 HashMap 实现并发 Set?
- 一致性设计:
CopyOnWrite家族的基础是数组拷贝,CopyOnWriteArrayList已经稳定实现了线程安全的动态数组与快照迭代器;只需在其上增加“元素不存在时才插入”的检查即可实现 Set,无需再造一套基于哈希的写时复制结构。 - 避免哈希开销:HashMap 虽然 O(1) 平均查找,但在写时复制模型下,整个哈希表(数组+链表/树)都要复制,结构复制成本比纯数组高很多(要复制哈希桶、重新链接节点等),而且哈希冲突带来的遍历逻辑会使复制编程更复杂。
- 小集合优势:在 COW 的目标场景(元素少,写极少)下,O(n) 的线性扫描反而由于常数极小和缓存友好,可能比哈希计算更快,且无哈希碰撞退化。
- 简单即正确:委托给一个已经成熟的并发 List,代码量极少,不容易出错。
因此,存储结构就是 volatile 修饰的 Object[],多线程可见性由 volatile 保证,锁保护写操作的原子性。
存储结构可视化:
classDiagram
direction LR
class CopyOnWriteArraySet {
-CopyOnWriteArrayList al
}
class CopyOnWriteArrayList {
-ReentrantLock lock
-transient volatile Object[] array
+addIfAbsent(Object) boolean
}
class ObjectArray {
+elementData: Object[]
}
CopyOnWriteArraySet *-- CopyOnWriteArrayList : "组合委托"
CopyOnWriteArrayList *-- ObjectArray : "volatile array"
CopyOnWriteArrayList *-- ReentrantLock : "lock"
图表说明:
- 第一层:
CopyOnWriteArraySet直接引用一个CopyOnWriteArrayList实例,所有操作(包括序列化)都委托给它。 - 第二层:
CopyOnWriteArrayList通过锁与 volatile 数组实现 COW;array字段的volatile关键字确保写线程setArray后,读线程立即看到新数组引用。 - 第三层:实际元素存储在
Object[]中,是一个连续内存结构,没有链表指针。元素唯一性靠 indexOf 扫描数组保证;无锁读取则是通过直接读取 volatile 引用得到当前数组快照来实现。
模块 4:构造方法(源码剖析)
JDK 8 中 CopyOnWriteArraySet 提供了两个构造器:
-
无参构造:创建一个空的集合。
public CopyOnWriteArraySet() { al = new CopyOnWriteArrayList<E>(); }说明:初始化一个空的
CopyOnWriteArrayList,底层数组为空(长度为 0)。 -
基于现有集合的构造:
public CopyOnWriteArraySet(Collection<? extends E> c) { if (c.getClass() == CopyOnWriteArraySet.class) { @SuppressWarnings("unchecked") CopyOnWriteArraySet<E> cc = (CopyOnWriteArraySet<E>) c; al = new CopyOnWriteArrayList<E>(cc.al); } else { al = new CopyOnWriteArrayList<E>(); al.addAllAbsent(c); } }逻辑分析:
- 若传入集合已经是
CopyOnWriteArraySet,直接复用其内部的CopyOnWriteArrayList状态(即创建新的 COWList 但使用相同的数组快照拷贝,保证后续修改互不影响)。 - 否则,创建一个空的 COWList,再调用
addAllAbsent批量添加元素,该方法会遍历c并对每个元素执行addIfAbsent,保证去重。addAllAbsent内部只是在一次锁内对每个元素执行 indexOf 检查后复制插入,减少了锁的获取次数(对比多次 add)。
- 若传入集合已经是
Demo 代码:演示构造与元素唯一性。
import java.util.*;
import java.util.concurrent.CopyOnWriteArraySet;
public class ConstructorDemo {
public static void main(String[] args) {
// 包含重复的初始化集合
List<String> raw = Arrays.asList("A", "B", "A", null, "C", null);
CopyOnWriteArraySet<String> set = new CopyOnWriteArraySet<>(raw);
System.out.println("Set: " + set); // [A, B, null, C] 顺序可能按添加保持
System.out.println("Size: " + set.size()); // 4
}
}
输出解释:源 list 有 6 个元素,addAllAbsent 会自动跳过重复的 "A" 和第二个 null,最终集合大小为 4。注意:顺序取决于遍历原始集合的顺序,由于是数组追加,通常保持第一次出现的位置。
Part 3: 核心原理篇
模块 5:add 操作——写时复制与唯一性保证(源码剖析)
add(E e) 是 CopyOnWriteArraySet 的灵魂,其实现完全委托给 CopyOnWriteArrayList.addIfAbsent:
public boolean add(E e) {
return al.addIfAbsent(e);
}
CopyOnWriteArrayList.addIfAbsent 源码(JDK 8 简化):
public boolean addIfAbsent(E e) {
Object[] snapshot = getArray();
// 无锁快速路径检查:如果已存在且数组未变,则直接返回false
if (indexOf(e, snapshot, 0, snapshot.length) >= 0)
return false;
// 加锁进行安全插入
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] current = getArray();
int len = current.length;
// 再次检查,防止其他线程在其间插入
if (current != snapshot) {
int idx = indexOf(e, current, 0, len);
if (idx >= 0) // 存在则直接返回
return false;
// 不存在,复制数组并拼接
Object[] newElements = Arrays.copyOf(current, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
}
// 数组未变化,直接复制并追加
Object[] newElements = Arrays.copyOf(current, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
完整 COW + 去重流程图:
flowchart TD
A["add(E e)"] --> B["调用 al.addIfAbsent(e)"]
B --> C["获取当前数组 snapshot = getArray"]
C --> D{"indexOf(snapshot, e) >= 0?"}
D -->|"是"| E["返回 false 元素已存在"]
D -->|"否"| F["获取锁 lock.lock"]
F --> G["获取当前数组 current = getArray"]
G --> H{"current != snapshot?"}
H -->|"是"| I["在 current 中再次 indexOf(e)"]
I --> J{"找到?"}
J -->|"是"| K["返回 false 释放锁"]
J -->|"否"| L["复制数组长度+1 newElements = copyOf(current, len+1)"]
L --> M["追加新元素 newElements[len] = e"]
M --> N["setArray(newElements) 替换 volatile 引用"]
N --> O["返回 true"]
H -->|"否"| P["直接复制数组长度+1 newElements = copyOf(current, len+1)"]
P --> M
F --> Q["释放锁 unlock"]
K --> Q
O --> Q
图表说明:
- 第一层:无锁快速失败——在获取锁之前先使用
snapshot检查元素是否存在。如果已存在,直接返回false,避免了不必要的锁竞争,这对于高并发读多写少场景十分有益。 - 第二层:加锁保证原子性——如果 snapshot 中不存在,则获取
ReentrantLock进入互斥区。进入后再次获取当前数组,因为其它线程可能在获取锁之前修改了数组。 - 第三层:双重检查与复制——若数组引用已改变(
current != snapshot),则必须在current上再查一次indexOf,防止插入重复元素;若无变化或确定无重复,则调用Arrays.copyOf创建一个长度为len+1的新数组,将元素放在末尾,并通过setArray将新数组赋值给 volatile 域。 - 关键结论:唯一性由两次 indexOf 线性扫描保证(无锁一次、加锁一次),写时复制体现为全数组拷贝。这解释了为什么
add是 O(n),且写入成本随集合增大线性增长。
为什么不使用 HashMap 的思路:HashMap 写时复制要复制整个桶数组以及链表/树节点,每个节点涉及 hashCode、链表指针调整,实现复杂度远超一维数组复制,且对于小集合,内存分配和 GC 开销反而大于简单数组。
模块 6:remove 与 contains 操作(源码剖析)
remove(Object o) 源码路径:CopyOnWriteArraySet.remove(o) → al.remove(o)。
简化源码:
public boolean remove(Object o) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] current = getArray();
int len = current.length;
if (len == 0)
return false;
// 查找元素位置
int index = indexOf(o, current, 0, len);
if (index < 0)
return false;
Object[] newElements = new Object[len - 1];
// 复制除index以外的元素
System.arraycopy(current, 0, newElements, 0, index);
System.arraycopy(current, index + 1, newElements, index, len - index - 1);
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
remove 流程图:
flowchart TD
A["remove(Object o)"] --> B["获取锁 lock.lock"]
B --> C["获取当前数组 current"]
C --> D{"len == 0?"}
D -->|"是"| E["返回 false 释放锁"]
D -->|"否"| F["indexOf(current, o) 查找索引"]
F --> G{"index >= 0?"}
G -->|"否"| H["返回 false 释放锁"]
G -->|"是"| I["创建长度 len-1 的新数组"]
I --> J["arraycopy 复制 index 前后部分"]
J --> K["setArray 替换引用"]
K --> L["返回 true 释放锁"]
图表说明:
- 第一层:
remove必须获取锁,因为修改了数组结构。 - 第二层:查找
indexOf是线性遍历,O(n);若未找到直接返回 false。 - 第三层:删除操作通过分段数组复制实现,创建新数组长度为
len-1,将删除位置前后的元素分别复制到新数组,然后替换 volatile 引用。内存上老数组仍然可能被其他迭代器持有,但集合已不可见,保证并发安全。
contains(Object o) 源码:
public boolean contains(Object o) {
return al.contains(o);
}
// CopyOnWriteArrayList.contains
public boolean contains(Object o) {
Object[] elements = getArray();
return indexOf(o, elements, 0, elements.length) >= 0;
}
contains 完全无锁,直接获取当前的数组引用进行线性扫描,时间复杂度 O(n)。因为无锁,可能遍历到的是稍旧的数组,但这在 COW 的弱一致性模型下是可接受的。这也意味着如果在遍历过程中其他线程添加了元素,contains 可能返回 false,但迭代器后续遍历可能看见,整体一致性弱。
模块 7:批量操作
addAll(Collection<? extends E> c)委托给al.addAllAbsent(c),在单次锁内遍历 c,对每个元素执行indexOf+ 复制追加,保证全部或无一插入的原子性。removeAll(Collection<?> c)和retainAll同样在锁内进行全数组遍历与新数组构建,删除或保留元素。- 批量操作的快照一致性:在整个操作期间,读线程可能看到操作前、操作后或部分搬移的状态?由于
setArray只在最后一步执行,所以读操作要么看到完整的旧数组,要么看到完整的新数组,不会出现中间态。这就是利用 volatile 写替换引用的优势。
Part 4: 迭代器与一致性篇
模块 8:迭代器的快照机制与弱一致性(源码剖析)
iterator() 方法的实现:
public Iterator<E> iterator() {
return al.iterator();
}
CopyOnWriteArrayList.iterator() 返回一个 COWIterator,该迭代器在构造时获取当前数组的快照并持有:
private static class COWIterator<E> implements ListIterator<E> {
private final Object[] snapshot;
private int cursor;
COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
public boolean hasNext() {
return cursor < snapshot.length;
}
public E next() {
if (! hasNext()) throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
// 不支持 remove、set、add 等修改操作,抛 UnsupportedOperationException
}
关键特性:
- 快照不变:
snapshot在迭代器创建时固化,后续对集合的写入不会影响snapshot。 - 弱一致性:迭代器反映的是创建迭代器那一刻的集合状态,后续新增或删除的元素对迭代器不可见,也不抛出
ConcurrentModificationException。 - 不支持 remove():迭代器上的
remove方法直接抛出UnsupportedOperationException,要在遍历中删除元素只能通过集合本身的remove。
多线程下迭代器与写操作交互时序图:
sequenceDiagram
participant T1 as 写线程
participant Set as CopyOnWriteArraySet
participant Array as volatile Object[] array
participant T2 as 读线程(迭代器)
T2->>Set: iterator()
Set->>Array: 获取当前数组引用 (旧数组 oldArray)
Set-->>T2: COWIterator(snapshot = oldArray)
Note over T2: 迭代器持有 oldArray 快照
T1->>Set: add("X")
Set->>Array: 获取锁,创建新数组 newArray = copyOf(oldArray, len+1) 并设置元素
Set->>Array: setArray(newArray) volatile 写
Note over Array: array 引用指向 newArray,但 oldArray 仍被 T2 持有
T2->>T2: 遍历 snapshot (oldArray),看不见 "X"
T1-->>Set: add 完成,释放锁
T2->>T2: 继续遍历,全程无异常
图表说明:
- 第一层交互:读线程在
T1开始前获取迭代器,得到旧数组引用oldArray;此后写线程执行add,复制出一个newArray并原子替换array引用,然而迭代器仍持有oldArray,因为快照独立于集合。 - 第二层一致性表现:迭代器遍历的是创建时的数据,不会抛出并发修改异常,即弱一致性。这对于许多高并发读场景是完全可接受的(比如广播事件时,监听者列表的瞬间快照)。
- 第三层注意事项:如果需要基于最新集合状态进行判断,不应依赖迭代器结果,应直接调用
contains等方法。弱一致性不是 bug,是设计取舍。
模块 9:序列化机制
CopyOnWriteArraySet 实现 Serializable,其 writeObject 方法直接委托给 al(CopyOnWriteArrayList 已实现自己的序列化逻辑,将数组元素逐个写入)。反序列化时重新构建 CopyOnWriteArrayList 并恢复元素。因此序列化与底层数组快照一致,不涉及锁竞争。
Part 5: 对比与陷阱篇
模块 10:CopyOnWriteArraySet vs HashSet vs Collections.synchronizedSet——并发去重的三条路
Set 线程安全方案选型决策树:
flowchart TD
S["需要一个并发安全Set"] --> T{"是否要求元素有序?"}
T -->|"有序"| R1["使用 ConcurrentSkipListSet"]
T -->|"无序"| U{"数据量通常超过1000或写操作较多?"}
U -->|"是"| V{"主要操作是 contains/add/remove?"}
V -->|"是"| W["使用 ConcurrentHashMap.newKeySet"]
U -->|"否"| X{"读操作比例极高且写极低?"}
X -->|"是"| Y["使用 CopyOnWriteArraySet"]
X -->|"否"| Z{"能否接受全方法加锁?"}
Z -->|"能"| S2["使用 Collections.synchronizedSet(new HashSet)"]
Z -->|"否"| W
图表说明:
- 第一层有序性:
ConcurrentSkipListSet提供基于跳表的有序并发集,适用于要求有序的场景。 - 第二层数据量与写比例:当数据量较大或写操作不可忽略时,CopyOnWriteArraySet 的 O(n) 写代价成为性能瓶颈,推荐使用
ConcurrentHashMap.newKeySet()(内部基于分段锁或 CAS,add 接近 O(1))。 - 第三层读多写少判定:只有在元素数量小(通常 <1000)且写操作极少(如每分钟几次)的环境中,COW 的读无锁优势才能抵消写复制的开销。
- 第四层同步包装:
Collections.synchronizedSet通过为每个方法添加synchronized块实现安全,读写均需要获取锁,并发度最低,仅适用于极低并发或必须强一致性的场景。
详细对比如下:
| 特性 | CopyOnWriteArraySet | HashSet (非安全) + external sync | Collections.synchronizedSet | ConcurrentHashMap.newKeySet() (Java 8+) |
|---|---|---|---|---|
| 线程安全 | 是,写时复制,读无锁 | 否,需额外同步 | 是,所有方法加 synchronized | 是,CAS + synchronized,分段并发 |
| 读性能 | O(1) 索引,极高;contains O(n) | 无锁但需外部同步,限制并发 | 读也加锁,并发低 | O(1) 平均,并发读高 |
| 写性能 | O(n) 全数组复制 | O(1) 平均,需外部锁 | O(1) 加锁 | O(1) 平均,高并发写 |
| 内存开销 | 写时复制产生临时数组,GC 压力可预估 | 仅存储元素本身 | 同左 | 哈希表+节点,内存稍高 |
| 迭代器行为 | 快照,弱一致性,无异常 | fail-fast,要求外部同步 | fail-fast,在 synchronized 块内安全 | 弱一致性,无异常 |
| 适用数据量 | 小(建议<1000) | 任意大小,需同步 | 任意大小,单锁,大集合遍历阻塞严重 | 大集合,高并发 |
结论:CopyOnWriteArraySet 是读无锁与小集合的专精利器;HashSet 本身不安全;同步包装器是悲观锁实现;ConcurrentHashMap.newKeySet 是通用并发 Set 的首选。
模块 11:CopyOnWriteArraySet vs ConcurrentSkipListSet——并发有序与并发无序
| 特性 | CopyOnWriteArraySet | ConcurrentSkipListSet |
|---|---|---|
| 底层结构 | COW数组 | 跳表 (Skip List) |
| 是否有序 | 无序 | 自然顺序或自定义比较器有序 |
| 读操作复杂度 | contains O(n),索引读 O(1) | contains O(log n),导航操作如lower/floor等 |
| 写操作复杂度 | O(n) 数组复制 | O(log n) 平均,CAS无锁插入 |
| 并发机制 | 写时复制 + 锁 | 无锁 CAS 局部重试 |
| 迭代器一致性 | 弱一致性快照,无异常 | 弱一致性,不抛异常 |
| 内存占用 | 写时复制导致临时复制开销 | 节点对象较多,跳表索引额外内存 |
| 典型场景 | 静态小集合,读多写极少 | 需要有序遍历、范围查询的并发集 |
何时选择 CopyOnWriteArraySet? 当你不需要有序,且集合很小、写极其稀少时,COW 的数组连续内存和极简代码路径往往比跳表的 CAS 循环更轻量高效。何时选择 ConcurrentSkipListSet? 需要有序操作(如 first, last, subSet),或者集合可能会增长到数百以上且写操作较多。
模块 12:常见陷阱与最佳实践
陷阱 1:集合较大且写操作频繁导致严重性能问题
❌ 错误代码:
CopyOnWriteArraySet<Integer> set = new CopyOnWriteArraySet<>();
// 频繁写入大集合
for (int i = 0; i < 10_000; i++) {
set.add(i); // 每次 O(n),复制整个数组
}
✅ 正确做法:改用 ConcurrentHashMap.newKeySet() 或在构造时一次性添加(使用 addAll 批量操作)。
Set<Integer> set = ConcurrentHashMap.newKeySet();
// 或者用 CopyOnWriteArraySet 但批量初始化
CopyOnWriteArraySet<Integer> cowSet = new CopyOnWriteArraySet<>(existingCollection);
陷阱 2:误用 contains 做频繁的存在性检查,O(n) 导致慢
若高频调用 contains 且集合较大,应避免 CopyOnWriteArraySet,而采用 O(1) 查找的结构。
✅ 替代:ConcurrentHashMap.newKeySet() 或 HashSet 加适当同步。
陷阱 3:迭代器的弱一致性导致业务逻辑错误
如果迭代器遍历期间依赖新增元素,看到旧数据可能导致重复处理或漏处理。必须明确业务是否容忍快照滞后。若不能,可采用 synchronizedSet 并在同步块内迭代。
陷阱 4:迭代器不支持 remove 方法
❌ 错误:
Iterator<String> it = set.iterator();
while (it.hasNext()) {
if (someCondition(it.next())) {
it.remove(); // 抛出 UnsupportedOperationException
}
}
✅ 正确做法:直接调用集合的 remove:
for (String s : set) {
if (someCondition(s)) set.remove(s); // OK
}
但注意这样会在遍历期间修改集合(删除),但迭代器快照不变,不会抛异常,但要小心删除操作对快照的影响——迭代器看到的元素仍可能已被删除,但 remove 调用是安全的。
Part 6: 总结与面试篇
模块 13:注意事项与性能总结
时间复杂度总览:
| 操作 | 时间复杂度 | 备注 |
|---|---|---|
add | O(n) | 全数组复制 + indexOf 线性扫描 |
remove | O(n) | 全数组复制 + indexOf 线性扫描 |
contains | O(n) | 线性遍历,无锁 |
size | O(1) | 直接读取数组长度,无锁 |
isEmpty | O(1) | 同样基于数组长度 |
| 迭代器创建 | O(1) | 引用传递 |
| 迭代器遍历 | O(n) | 遍历快照数组 |
适用数据量建议:通常控制在 1000 元素以下;在小于 100 时,写操作复制开销基本忽略。
设计哲学:简单、正确、不可变生命周期下的视图。COW 将并行读推到极致,代价是写膨胀。
GC 友好性:由于写操作丢弃旧数组,频繁写会制造大量“短命”数组对象,增加 Young GC 频率,但无长期驻留。
模块 14:面试高频专题
(以下为独立面试问答,前文正文绝不出现。)
面试 1:CopyOnWriteArraySet 的底层实现原理?为什么不用 HashMap?
标准回答:
CopyOnWriteArraySet 内部通过组合一个 CopyOnWriteArrayList 实现,所有元素存储在 CopyOnWriteArrayList 的 volatile Object[] 中。写操作(add/remove)会加 ReentrantLock,复制整个数组并替换引用,保证线程安全;读操作完全无锁,直接从 volatile 读数组。唯一性通过 addIfAbsent 的线性查找 indexOf 保证。不用 HashMap 因为写时复制要求整体容器复制,数组连续内存复制比哈希表(分桶链表/树)简单高效,且对于小集合,O(n) 扫描足够快,同时避免了哈希计算和冲突管理的复杂性与内存开销。
追问模拟:“如果集合存有大量元素,性能会怎样?”
加分回答:随着集合增大,每次 add/remove 的数组复制开销线性增长,同时 contains 的 O(n) 也会变得不可接受。此时应切换至 ConcurrentHashMap.newKeySet()。COWArraySet 的设计假设元素数通常很小,这是一种“读优化、写惩罚”的设计,不能用于通用大集合。
面试 2:CopyOnWriteArraySet 如何保证元素不重复?与 HashSet 的去重方式有什么不同?
标准回答:通过调用 CopyOnWriteArrayList.addIfAbsent,该方法在插入前对当前数组进行线性遍历 indexOf 来确定元素是否已存在。若已存在则直接返回 false,不会重复插入。HashSet 则基于 hashCode 定位桶,再与桶内元素进行 equals 比较,平均 O(1)。CopyOnWriteArraySet 的去重是 O(n) 顺序扫描,不依赖哈希分散,因此不能利用哈希加速。
追问模拟:“如果元素实现了错误的 equals,会怎样?”
加分回答:与任何依赖 equals 的集合一样,错误实现将导致逻辑去重失败,可能出现“重复”元素。但 COWArraySet 的 indexOf 使用 o.equals(elements[i]),逻辑与 HashSet 一致,只是遍历方式不同。
面试 3:写时复制的过程是怎样的?为什么写操作要加锁?
标准回答:写操作(add/remove)首先获取 ReentrantLock,然后获取当前数组引用,检查是否需要修改(如 addIfAbsent 的两次检查)。若需要修改,则用 Arrays.copyOf 复制一个长度调整后的新数组,在新数组上完成元素的添加或删除,最后通过 setArray 将 volatile 数组引用指向新数组。加锁是为了防止多个写线程同时复制修改,导致数据丢失或数组状态错乱。同时,volatile 写保证新数组引用对所有读线程立即可见。
追问模拟:“读操作不需要锁,如何保证看到最新数组?”
加分回答:array 字段是 volatile 修饰的,JMM 保证 volatile 写会刷新到主存并使其他线程的缓存无效,因此读线程读取 getArray() 时总能得到写线程最新 set 的引用,从而看到最新数组。
面试 4:CopyOnWriteArraySet 的 contains 方法时间复杂度是多少?为什么是 O(n)?
标准回答:O(n)。因为 contains 调用 indexOf,对当前数组进行线性遍历,比对每个元素是否相等,没有使用哈希索引。尽管读无锁,但查找性能随元素数量线性下降。
面试 5:CopyOnWriteArraySet 和 Collections.synchronizedSet 的区别?各适用什么场景?
标准回答:CopyOnWriteArraySet 写操作加锁并复制数组,读操作无锁,迭代器快照弱一致;适合读远远多于写且数据量小的场景。Collections.synchronizedSet 在每个方法上使用 synchronized 互斥,读写均竞争同一把锁,迭代必须在外部同步块;适用于读写比例相当或要求强一致性且数据量不大的场景,但并发度较低。前者牺牲写性能和一致性换取极致读并发;后者牺牲并发度换取简单强一致。
面试 6:CopyOnWriteArraySet 和 ConcurrentSkipListSet 的区别?如何选择?
标准回答:ConcurrentSkipListSet 基于跳表,有序,读写均为 O(log n) 且大部分操作无锁;支持有序导航(lower、floor 等)。CopyOnWriteArraySet 无序,读索引快但 contains O(n),写 O(n) 复制。选择:若需要有序或集合会变大,写操作多于低频,选 ConcurrentSkipListSet;若集合极小、写极少、且不需要有序,CopyOnWriteArraySet 的连续内存和更简单的逻辑可能表现更佳。
面试 7:CopyOnWriteArraySet 的迭代器为什么是弱一致性的?有什么好处和坏处?
标准回答:迭代器在创建时捕获当前数组引用作为 snapshot,后续写操作产生新数组,不影响该快照,因此不抛 ConcurrentModificationException,称为弱一致性。好处:允许高并发读遍历,无锁,不会阻塞写操作;适合快照视图场景(如发布事件时)。坏处:遍历过程中未能反映最新数据,可能处理已删除元素或漏掉新增元素;且迭代器不支持 remove。
面试 8:为什么 CopyOnWriteArraySet 适合读多写少的场景?如果写多会发生什么?
标准回答:因为读操作无需锁,直接访问 volatile 数组,并发读几乎无竞争;而写操作要复制整个数组,代价大。如果写多,频繁的全数组复制会消耗大量 CPU 和内存,GC 压力剧增,同时锁竞争虽然单锁不会死锁,但大量写线程会排队等待,吞吐量显著下降,甚至服务不可用。
面试 9:CopyOnWriteArraySet 允许 null 元素吗?允许多个 null 吗?
标准回答:允许一个 null 元素。内部 indexOf 对 null 进行了特殊处理(e == null ? element == null : e.equals(element)),当第一个 null 插入后,addIfAbsent 会检测到 null 已存在并返回 false,所以不能存在多个 null。
面试 10:如果要存储大量元素并需要并发安全,应该选择 CopyOnWriteArraySet 吗?为什么?
标准回答:不应该。大量元素导致写操作 O(n) 全量复制代价极高,contains 也会变慢;推荐使用 ConcurrentHashMap.newKeySet(),其 O(1) 平均读写和分段并发能力能轻松应对大集合高并发场景。COWArraySet 只是为小集合极致读优化而生的专用轮子。