CopyOnWriteArrayList 实现原理

什么是CopyOnWriteArrayList?

CopyOnWriteArrayList 是 Java 并发包 (java.util.concurrent) 中一个非常独特且重要的线程安全集合。与 Collections.synchronizedList 不同,CopyOnWriteArrayList 不依赖外部同步,而是通过内部机制实现并发控制,它专为"读多写少 "的极端场景设计,通过一种巧妙的"写时复制"策略,在保证线程安全的同时,实现了极高的读性能。

核心思想:写时复制

CopyOnWriteArrayList 的核心思想是 读写分离。它将读操作和写操作分离开来,采用不同的策略以保证并发安全:

  • 读操作 :直接访问底层数组,完全无锁,因此性能极高。
  • 写操作 (增、删、改):不是直接修改原数组,而是先复制 一份当前数组的副本,然后在副本上进行修改,修改完成后,再用新数组原子性地替换掉旧数组。

这种"以空间换时间"的设计哲学,使得它在读操作远多于写操作的场景下(如白名单、配置管理、监听器列表),性能表现远超传统的同步集合(如 Collections.synchronizedList)。

底层实现原理与数据结构

CopyOnWriteArrayList 的线程安全主要由两个核心组件保证:一个 volatile 修饰的数组和一个用于写操作的锁。

  • transient volatile Object[] array:存储元素的数组,用 volatile 修饰以保证可见性。
  • final transient ReentrantLock lock:用于写操作的同步锁(JDK 1.5引入,之后版本优化为内部锁对象)。

该数据存储结构本质为动态数组,与 ArrayList 类似,但其线程安全的实现机制却截然不同。

java 复制代码
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    // 1. 用于保证写操作原子性的锁
    final transient ReentrantLock lock = new ReentrantLock();
    
    // 2. 存储元素的数组,volatile 保证修改后的新数组对所有线程立即可见
    private transient volatile Object[] array;

    // 获取当前数组的辅助方法
    final Object[] getArray() {
        return array;
    }
    
    // 设置新数组的辅助方法
    final void setArray(Object[] a) {
        array = a;
    }
    // ... 其他方法
}

写操作 (以 add(E e) 为例)

写操作通过加锁和数组复制来保证线程安全。

  1. 加锁 :获取 ReentrantLock 锁,确保同一时间只有一个线程能执行写操作。
  2. 复制 :获取当前的旧数组,并使用 Arrays.copyOf 方法创建一个长度加一的新数组。
  3. 修改:将新元素添加到新数组的末尾。
  4. 替换 :调用 setArray(newElements) 方法,将 array 引用指向这个新数组。由于 arrayvolatile 的,这个赋值操作对所有线程立即可见。
  5. 解锁:释放锁,允许其他写操作线程进入。
java 复制代码
public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock(); // 1. 加锁
    try {
        Object[] elements = getArray(); // 2. 获取旧数组
        int len = elements.length;
        // 3. 复制新数组
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e; // 4. 在新数组上修改
        setArray(newElements); // 5. 原子性替换旧数组
        return true;
    } finally {
        lock.unlock(); // 6. 解锁
    }
}

读操作 (以 get(int index) 为例)

读操作非常简单高效,因为它不需要任何同步。

  1. 访问 :直接通过 getArray() 获取当前的数组引用。
  2. 返回:返回指定索引位置的元素。
java 复制代码
public E get(int index) {
    return (E) getArray()[index]; // 无锁,直接访问
}

迭代器:快照迭代器 (Snapshot Iterator)

CopyOnWriteArrayList 的迭代器是其设计的另一大亮点。

  • 创建快照 :当调用 iterator() 方法时,迭代器会持有创建时刻的数组引用,这个数组就是它的"快照"。
  • 弱一致性 :在迭代过程中,即使有其他线程修改了原列表,迭代器也只会遍历它持有的那份旧数组快照,不会受到影响,也绝不会抛出 ConcurrentModificationException
  • 不支持修改 :迭代器自身的 remove()add() 等方法不被支持,调用会抛出 UnsupportedOperationException

优点与缺点

特性 优点 缺点
读性能 极高。读操作无锁,并发读取性能极佳。 -
写性能 - 很低。每次写操作都需要复制整个数组,时间复杂度为 O(n),频繁写入时开销巨大。
内存开销 - 很高。写操作期间,内存中会同时存在新旧两个数组,可能导致内存占用翻倍,大数据量下有 OOM 风险。
数据一致性 迭代器安全,遍历时不会因并发修改而崩溃。 弱一致性。读操作可能获取到旧数据,无法保证数据的实时性。
适用场景 读多写少的场景,如监听器列表、配置项、黑白名单等。 写多读少或读写频繁的场景完全不适用。

典型应用场景

  • 监听器列表 (Listener List):在事件驱动模型中,监听器的注册(写)和移除(写)频率很低,但事件触发时遍历所有监听器(读)的频率非常高。
  • 系统配置/参数管理:系统配置通常在启动时加载,运行期间很少变更,但会被大量业务线程频繁读取。
  • 黑白名单:用于风控或权限控制的名单,更新频率低,但校验(读取)频率极高。

源码中的优化点

写操作的优化:避免不必要的数组复制

在执行 set(int index, E element) 这类修改操作时,源码并非无脑地复制数组,而是会先进行一次判断。

  1. 核心逻辑 :只有当新值与旧值不相等 时,才会触发 Arrays.copyOf 来创建新数组副本。
  2. 优化效果:如果新值与旧值相同,则跳过昂贵的数组复制过程,直接复用原数组。这在一定程度上减少了写操作的内存开销和时间成本。
java 复制代码
public E set(int index, E element) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        E oldValue = get(elements, index);  // 1. 获取旧值
        // 2. 核心优化:仅当新旧值不同时才复制数组
        if (oldValue != element) {
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len);
            newElements[index] = element;
            setArray(newElements);
        } else {
            // 值未改变,但仍需触发 volatile 写以保证可见性
            setArray(elements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}

可见性的优化:保证 volatile 语义

这是一个非常隐蔽但至关重要的优化点。在上面的 set 方法中,即使新旧值相同、没有复制数组,代码依然会执行 setArray(elements)

  1. 核心逻辑setArray() 方法的核心是为 array 字段赋值。由于 arrayvolatile 修饰的,这个赋值操作本身就是一个 volatile 写。
  2. 优化效果:这确保了即使数据本身没有变化,这次操作的"发生"对其他线程也是立即可见的。它防止了因指令重排序等原因导致的可见性问题,保证了线程间状态感知的正确性。

删除操作的优化:高效的数组分段复制

在执行 remove(int index) 操作时,源码根据被删除元素的位置,选择了最优的数组复制策略,而不是简单粗暴地复制所有元素。

  1. 删除末尾元素 :如果删除的是数组最后一个元素,直接使用 Arrays.copyOf(elements, len - 1),复制前 len-1 个元素即可。
  2. 删除中间元素 :如果删除的是中间某个元素,则会分两步进行复制,以跳过被删除的元素:
    • 使用 System.arraycopy 复制被删除元素之前的所有元素。
    • 使用 System.arraycopy 复制被删除元素之后的所有元素。

这种分段复制的策略避免了不必要的元素移动,提高了删除操作的效率。

java 复制代码
public E remove(int index) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        E oldValue = get(elements, index);
        int numMoved = len - index - 1;
        Object[] newElements;
        if (numMoved == 0) {
            // 1. 删除末尾元素,直接复制前半部分
            newElements = Arrays.copyOf(elements, len - 1);
        } else {
            // 2. 删除中间元素,分段复制,跳过被删元素
            newElements = new Object[len - 1];
            System.arraycopy(elements, 0, newElements, 0, index);
            System.arraycopy(elements, index + 1, newElements, index, numMoved);
        }
        setArray(newElements);
        return oldValue;
    } finally {
        lock.unlock();
    }
}

常见面试题

CopyOnWriteArrayList 是如何保证线程安全的?

  • :通过"写时复制"机制。写操作时,先获取独占锁,然后复制一份当前数组的副本,在副本上进行修改,最后用新数组原子性地替换旧数组。volatile 关键字保证了新数组对所有线程的可见性。读操作则直接访问数组,无需加锁。

为什么 CopyOnWriteArrayList 的迭代器不会抛出 ConcurrentModificationException

  • :因为它的迭代器是"快照迭代器"。在创建迭代器时,它会持有当时数组的一个引用(快照)。后续的写操作会创建新的数组,但不会影响迭代器持有的旧数组快照。因此,迭代器在遍历过程中感知不到集合结构的变化,自然也就不会抛出异常。

CopyOnWriteArrayList 的适用场景和缺点是什么?

    • 适用场景 :非常适合读多写少的并发场景,例如监听器列表、配置缓存、黑白名单等。
    • 缺点
      1. 内存占用高:每次写操作都会复制数组,导致内存开销大,有 OOM 风险。
      2. 数据非实时:读操作可能读到旧数据,存在最终一致性问题。
      3. 写性能差:频繁写入时,数组复制的开销巨大。

CopyOnWriteArrayListCollections.synchronizedList 有什么区别?

    • 锁策略CopyOnWriteArrayList 读写分离,读无锁,写加锁;synchronizedList 对所有操作(读和写)都加锁。
    • 性能 :在读多写少场景下,CopyOnWriteArrayList 的读性能远超 synchronizedList;在读写均衡或写多读少场景下,synchronizedList 更合适。
    • 迭代器CopyOnWriteArrayList 的迭代器是弱一致性的快照迭代器,不会抛异常;synchronizedList 的迭代器是快速失败的(fail-fast),并发修改时会抛出 ConcurrentModificationException

和Vector、Collections.synchronizedList的区别是什么?

  • :这三者虽然都是线程安全的 List 实现,但设计理念和适用场景截然不同。主要区别可以从以下三个维度来阐述:
    *

    1. 与 Vector 的对比:核心区别在于"全同步"与"读写分离"

    • Vector(全同步机制): 它是 JDK 1.0 时代的产物,属于"古老"的实现。它的线程安全是通过在几乎所有方法 (包括 getadd 等)上都加上 synchronized 关键字来实现的。这意味着 Vector 是全同步的,无论是读操作还是写操作,都必须竞争同一把锁。
    • CopyOnWriteArrayList(读写分离): 它采用了"读写分离"的思想。读操作完全无锁,直接访问底层数组;写操作才需要加锁并进行数组复制。
    • 结论: 在高并发场景下,Vector 的"全同步"会导致严重的性能瓶颈,因为读操作也会阻塞其他线程。而 CopyOnWriteArrayList 允许并发读取,性能远高于 Vector。因此,Vector 在现代开发中已被视为过时,不推荐使用。

    2. 与 Collections.synchronizedList 的对比:核心区别在于"锁粒度"与"性能"

    • Collections.synchronizedList(粗粒度锁): 它通过装饰器模式包装了一个普通的 ArrayList,并使用一个共有的 mutex 对象锁。它的锁粒度非常粗,所有的读、写操作都必须串行执行(读写互斥,读读也互斥)。在高并发读取的场景下,这种粗粒度的锁会引发激烈的锁竞争,导致 CPU 资源浪费在上下文切换上。
    • CopyOnWriteArrayList(极致优化读性能): 它通过"写时复制"规避了锁竞争。读操作不需要获取任何锁,多个线程可以同时无阻塞地读取列表。
    • 结论:读多写少的场景下(如白名单、配置管理),CopyOnWriteArrayList 的读性能远超 synchronizedList;而在读写均衡或写操作频繁的场景下,synchronizedList 因为不需要频繁复制数组,反而更合适。

    3. 迭代器行为的区别

    • CopyOnWriteArrayList: 采用弱一致性 的快照迭代器。迭代器持有创建时刻的数组快照,遍历时不会抛出 ConcurrentModificationException,但也无法感知到迭代开始后的修改。
    • Vector 和 synchronizedList: 它们的迭代器都是快速失败(fail-fast) 的。如果在遍历过程中有其他线程修改了集合,迭代器会立即抛出 ConcurrentModificationException
对比维度 CopyOnWriteArrayList Collections.synchronizedList Vector
核心机制 读写分离 (写时复制) 全锁机制 (装饰器模式) 全同步 (方法级同步)
读性能 极高 (无锁,并发读) 一般 (需竞争锁) 差 (需竞争锁)
写性能 差 (需复制数组,开销大) 一般 (需竞争锁) 差 (需竞争锁)
锁粒度 写操作独占锁,读操作无锁 粗粒度 (全操作互斥) 粗粒度 (全操作互斥)
迭代器 快照迭代器 (不抛异常) 快速失败 (抛异常) 快速失败 (抛异常)
适用场景 读多写少 (如监听器列表) 读写均衡 / 写多 已过时 (不推荐)
相关推荐
Java成神之路-2 小时前
通俗易懂理解 Spring MVC 拦截器:概念、流程与简单实现(Spring系列16)
java·spring·mvc
zhanghongbin012 小时前
AI 采集器:Claude Code、OpenAI、LiteLLM 监控
java·前端·人工智能
良木生香2 小时前
【C++初阶】C++入门相关知识(2):输入输出 & 缺省参数 & 函数重载
开发语言·c++
计算机毕设vx_bysj68692 小时前
【免费领源码】77196基于java的手机银行app管理系统的设计与实现 计算机毕业设计项目推荐上万套实战教程JAVA,node.js,C++、python、大屏数据可视化
java·mysql·智能手机·课程设计
忘梓.2 小时前
墨色规则与血色节点:C++红黑树设计与实现探秘
java·开发语言·c++
hhh3u3u3u2 小时前
Visual C++ 6.0中文版安装包下载教程及win11安装教程
java·c语言·开发语言·c++·python·c#·vc-1
星河耀银海2 小时前
C++ 模板进阶:特化、萃取与可变参数模板
java·开发语言·c++
cccccc语言我来了2 小时前
【C++---unordered_set/map底层封装】个不拘一格的集合。它不似有序集合那般循规蹈矩,而是以一种洒脱不羁的方式,将元素们随意地散落其中。每一个元素都是独一无二的。
开发语言·c++·哈希算法
Zfox_2 小时前
C++ IO流全解析:标准库中的数据处理与文件读写艺术
开发语言·c++