集合-Set-CopyOnWriteArraySet

3 阅读25分钟

概述

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 会被去重逻辑过滤)。

核心特性列表(结合源码行为):

  1. 读无锁:读操作不获取任何锁,完全依赖 volatile 语义保证数组引用的可见性,读操作永不阻塞。
  2. 写时复制保证并发安全:修改时复制整个内部数组,替换引用,写线程之间互斥,写读之间无锁竞争。
  3. 迭代器快照,弱一致性:迭代器遍历的是创建时的数组快照,后续写操作不可见,也不抛出 ConcurrentModificationException
  4. 元素唯一性由线性查找保证:不存在哈希分桶,每次 add 调用 indexOf 扫描数组,时间复杂度 O(n)。
  5. 允许 null 元素:内部实现支持 null(indexOf 兼容 null 比较)。
  6. 无顺序保证:迭代顺序依赖于数组存储顺序,该顺序随写操作而改变。
  7. 写操作开销大,读操作高效: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 提供了 equalshashCode 等通用实现,其中 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 提供了两个构造器:

  1. 无参构造:创建一个空的集合。

    public CopyOnWriteArraySet() {
        al = new CopyOnWriteArrayList<E>();
    }
    

    说明:初始化一个空的 CopyOnWriteArrayList,底层数组为空(长度为 0)。

  2. 基于现有集合的构造

    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 方法直接委托给 alCopyOnWriteArrayList 已实现自己的序列化逻辑,将数组元素逐个写入)。反序列化时重新构建 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 块实现安全,读写均需要获取锁,并发度最低,仅适用于极低并发或必须强一致性的场景。

详细对比如下

特性CopyOnWriteArraySetHashSet (非安全) + external syncCollections.synchronizedSetConcurrentHashMap.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——并发有序与并发无序

特性CopyOnWriteArraySetConcurrentSkipListSet
底层结构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:注意事项与性能总结

时间复杂度总览

操作时间复杂度备注
addO(n)全数组复制 + indexOf 线性扫描
removeO(n)全数组复制 + indexOf 线性扫描
containsO(n)线性遍历,无锁
sizeO(1)直接读取数组长度,无锁
isEmptyO(1)同样基于数组长度
迭代器创建O(1)引用传递
迭代器遍历O(n)遍历快照数组

适用数据量建议:通常控制在 1000 元素以下;在小于 100 时,写操作复制开销基本忽略。
设计哲学简单、正确、不可变生命周期下的视图。COW 将并行读推到极致,代价是写膨胀。
GC 友好性:由于写操作丢弃旧数组,频繁写会制造大量“短命”数组对象,增加 Young GC 频率,但无长期驻留。


模块 14:面试高频专题

(以下为独立面试问答,前文正文绝不出现。)

面试 1:CopyOnWriteArraySet 的底层实现原理?为什么不用 HashMap?

标准回答
CopyOnWriteArraySet 内部通过组合一个 CopyOnWriteArrayList 实现,所有元素存储在 CopyOnWriteArrayListvolatile 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 只是为小集合极致读优化而生的专用轮子。