集合-List-CopyOnWriteArrayList

0 阅读35分钟

概述

CopyOnWriteArrayList 通过在写操作上应用数组全量复制与 ReentrantLock 互斥,将修改隔离于副本之上,再借助 volatile 写将新数组引用发布;读路径仅需一次 volatile 读即可访问逻辑上不可变的数组,完全免除锁或 CAS 的开销。这一“写复制、读快照”的模型使得高并发读取能线性扩展,但代价是每次写入都必须付出 O(n) 的复制开销与对应的内存压力,且迭代器始终基于构造时的数组快照,呈现弱一致性语义。理解其 volatile 数组引用所建立的 happen-before 关系、写时复制的内存作用以及与其他并发 List 在一致性与性能上的根本差异,是应对特定并发场景选型的关键。

  • 写时复制(Copy-On-Write)思想:写操作复制整个底层数组,在新副本上修改,完成后原子替换引用,读取完全无锁,彻底消除读-写冲突。
  • volatile + ReentrantLock 的并发原语:通过 volatile 保证数组引用的可见性,ReentrantLock 保护写操作的互斥性,二者协同构建安全且高效的并发模型。
  • 迭代器快照机制与弱一致性:迭代器持有创建时的数组快照,不受后续写操作影响,永不抛出 ConcurrentModificationException,却也因此只能读取到“过去”的数据。
  • 读多写少的极致优化:读操作零锁争用,高并发读场景下吞吐量远超 synchronizedListVector,是极致优化读性能的并发 List。
  • 内存代价与适用边界:每次写入需全量复制数组,内存翻倍且 GC 压力大,仅适合写极低频、数据量可控的场景,滥用将导致性能灾难。
flowchart TD
    subgraph Part1["Part 1: 基础认知篇"]
        M1["模块1: 定义 核心特性与适用场景"]
        M2["模块2: 写时复制设计思想"]
        M3["模块3: 接口与继承体系"]
    end
    subgraph Part2["Part 2: 存储与构造篇"]
        M4["模块4: 存储结构与核心字段"]
        M5["模块5: 构造方法"]
    end
    subgraph Part3["Part 3: 核心原理篇"]
        M6["模块6: 读操作 get/contains 无锁实现"]
        M7["模块7: 写操作 add 的完整 COW 流程"]
        M8["模块8: 写操作 remove 与 set 的 COW 流程"]
        M9["模块9: 批量操作 addAll/addIfAbsent"]
    end
    subgraph Part4["Part 4: 迭代器与一致性篇"]
        M10["模块10: COWIterator 快照机制"]
        M11["模块11: 序列化机制"]
    end
    subgraph Part5["Part 5: 对比与并发篇"]
        M12["模块12: 对比 Vector"]
        M13["模块13: 对比 Collections.synchronizedList"]
        M14["模块14: 内存开销与 GC 影响"]
    end
    subgraph Part6["Part 6: 总结与面试篇"]
        M15["模块15: 注意事项与最佳实践"]
        M16["模块16: 性能总结与选型建议"]
        M17["模块17: 面试高频专题"]
    end

    Part1 --> Part2 --> Part3 --> Part4 --> Part5 --> Part6

图表说明:全文围绕 六大篇章 逐层展开,形成从概念到源码、从机制到对比、再到实践与面试的完整闭环。

  • Part 1 基础认知:建立对 CopyOnWriteArrayList 的宏观定位。从定义与核心特性出发,提炼写时复制的设计思想,并通过接口与继承体系阐明其与 ArrayList 在实现路径上的本质区别,为后续源码分析提供背景。
  • Part 2 存储与构造:进入内部实现,重点剖析 volatile 数组引用 与 ReentrantLock 这两个核心字段如何构成并发原语,以及构造器在防御性拷贝上的设计考量,奠定线程安全的内存基础。
  • Part 3 核心原理:深入读写分离的实现细节。先解析 get/contains 的无锁读取路径,再依次拆解 add、remove/set 的完整 COW 流程与加锁原因,最后覆盖 addAll、addIfAbsent 等批量操作的复合逻辑,形成对写时复制的完整认知。
  • Part 4 迭代器与一致性:聚焦 COWIterator 的快照机制——它为何不会抛 ConcurrentModificationException,又为何抛弃 remove 能力;同时分析序列化时如何通过加锁保证数组一致性,揭示弱一致性在持久化中的表现。
  • Part 5 对比与并发:将 CopyOnWriteArrayList 与 Vector 和 Collections.synchronizedList 进行系统性对比,从锁粒度、迭代安全、读写性能到内存开销与 GC 影响逐一剖析,理清各自的适用边界。
  • Part 6 总结与面试:从注意事项与最佳实践中提炼工程落地的反模式,通过性能总结与选型建议给出决策依据,最后将全部高频考点凝结于面试专题,完成从原理到应用的闭环。

Part 1:基础认知篇

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

定义CopyOnWriteArrayList 是 Java 5 在 java.util.concurrent 包下引入的线程安全 List 实现。它基于“写时复制”策略:每当发生结构性修改(如 add、set、remove),都会在底层数组的一个全新副本上执行,修改完成后用 volatile 写替换旧数组引用。读操作直接操作当前数组,没有任何锁竞争。这一设计使其成为读多写极少场景的理想选择。

核心特性(共 6 条):

  1. 线程安全,读操作完全无锁get()contains() 等读方法不涉及任何同步手段,可在高并发下无争用访问。
  2. 写操作通过 ReentrantLock + 数组拷贝 + 原子替换实现:所有写方法获取同一把锁,确保同一时刻只有一个线程执行拷贝-修改-替换,避免写覆盖。
  3. 迭代器基于快照,弱一致性iterator() 返回时捕获当前数组快照,之后列表的任何修改对迭代器不可见,不抛出 ConcurrentModificationException
  4. 不抛 CME(ConcurrentModificationException):根本原因在于迭代器遍历的是稳定不变的快照数组,不存在结构变化。
  5. 不允许迭代器 remove:调用 Iterator.remove() 会直接抛出 UnsupportedOperationException,因为快照一旦创建便不可变。
  6. 允许 null 元素:底层数组为 Object[],可以存放 null。

适用场景

  • 事件监听器列表:注册/注销监听器操作极少,而事件触发时遍历监听器极为频繁。
  • 配置信息缓存:系统元数据或配置项很少变化,但大量线程频繁读取。
  • 白名单、黑名单:少量更新,大量匹配检查。

反例场景

  • 数据频繁变更(如每秒大量 add/remove)会导致严重的 CPU 和内存开销。
  • 数据量巨大(如数十万元素),单次写入的数组复制成本难以承受。
  • 需要强一致性读(写入后立刻读取必须看到新值),弱一致性模型会导致业务错误。
flowchart TD
    Start["是否需要一个线程安全的 List?"] -->|"是"| Q_ReadMost{"读操作远多于写操作?"}
    Q_ReadMost -->|"是"| Q_DataSize{"数据量一般在 1000 以内?"}
    Q_DataSize -->|"是"| Q_WeakConsist{"能否接受弱一致性/快照读?"}
    Q_WeakConsist -->|"是"| Recommend["使用 CopyOnWriteArrayList"]
    Q_ReadMost -->|"否"| Advice1["考虑 ConcurrentLinkedQueue 或加锁的方案"]
    Q_DataSize -->|"否"| Advice2["若写极极低频仍可,否则考虑手动同步集合"]
    Q_WeakConsist -->|"否"| Advice3["使用 Collections.synchronizedList 并手动管理锁"]
    Start -->|"否"| Other["无需线程安全,使用 ArrayList"]

决策树说明

  1. 起点:首先判断是否必须使用线程安全的 List。若仅单线程或外部同步已保证,ArrayList 性能更佳。
  2. 读写比例CopyOnWriteArrayList 的核心优势在于读无锁,因此读多写少(例如读写比 > 100:1)是重要前提。如果写操作占比高,应果断放弃。
  3. 数据规模:内部数组拷贝的复杂度为 O(n),当 n 大于数千时,单次写入成本显著上升,加之内存翻倍,大 List 会成为负担。
  4. 弱一致性容忍度:迭代器只能看到创建时的数据,写后立刻读也可能获取旧值。如果业务上要求实时准确,则需要使用 synchronizedList 或其他同步结构,并注意在读写时手动持有锁。
  5. 综合判断:只有所有条件都满足,CopyOnWriteArrayList 才是最优解。任何一项不满足,都应考虑替代方案。

模块 2:写时复制(Copy-On-Write)设计思想

写时复制(COW)是一种广泛应用的计算机科学优化策略,核心思想是:将资源复制延迟到真正需要修改的时候,共享同一份数据的所有读者无需受影响,仅当某个写者要修改时,才复制出自己的私有副本。

  • 操作系统:fork() 系统调用创建子进程时,内核并未立即复制父进程的整个地址空间,而是让父子进程共享只读物理页。直到任一进程尝试写入某页,才触发 page fault,由内核为该页创建可写副本。这极大加快了进程创建速度并节省了内存。
  • 虚拟化/容器:写时复制在镜像分层存储中同样关键,多个容器可以共享相同的基础镜像层,仅在写入时产生轻量的差分层。
  • Java 集合CopyOnWriteArrayListCopyOnWriteArraySet 将这一理念应用到并发集合。所有读线程共享同一个数组(不可变视图),写线程互斥地创建新数组并原子地发布,使读完全不阻塞。
flowchart LR
    Idea["COW 思想: 共享数据,修改时复制"] --> OS["OS fork: 只读共享物理页"]
    Idea --> Virt["虚拟化: 分层镜像共享基础层"]
    Idea --> JavaColl["Java 集合: 共享数组,写时复制"]
    JavaColl --> Steps["实现步骤: 1.加锁 2.拷贝数组 3.修改副本 4.原子替换引用 5.解锁"]

映射说明

  • 从思想到落地:无论操作系统还是 Java,COW 都遵循“共享读取、私密写入”模式。CopyOnWriteArrayList 中所有读操作均无锁共享 array,当写线程调用 add() 时,首先获取互斥锁,然后 Arrays.copyOf 创建新数组(私有副本),在新数组上修改,最后通过 setArray() 将 volatile 引用指向新数组并释放锁。后续的读操作将自动看到这一新数组。
  • 不可变性(逻辑上):一旦数组引用被发布,写线程不再修改原数组。因此,读线程看到的数组内容在整个读操作期间逻辑上是不变的,从而避免了脏读。
  • 性能代价:拷贝整个数组是 O(n) 的时空双重开销。当 n 较大且写操作相对频繁时,COW 的优势便会被拷贝成本与 GC 压力所抵消。

模块 3:接口与继承体系

classDiagram
    class List~E~ {
        <<interface>>
    }
    class RandomAccess {
        <<interface>>
    }
    class Cloneable {
        <<interface>>
    }
    class Serializable {
        <<interface>>
    }
    class CopyOnWriteArrayList~E~ {
        +CopyOnWriteArrayList()
        +CopyOnWriteArrayList(Collection)
        +CopyOnWriteArrayList(E[])
        -Object[] array
        -ReentrantLock lock
        +get(int) E
        +add(E) boolean
        +set(int E) E
        +remove(Object) boolean
        +iterator() Iterator
    }
    class ArrayList~E~ {
        +ArrayList()
        -Object[] elementData
        +get(int) E
        +add(E) boolean
    }
    class AbstractList~E~ {
        <<abstract>>
    }

    List <|.. CopyOnWriteArrayList
    RandomAccess <|.. CopyOnWriteArrayList
    Cloneable <|.. CopyOnWriteArrayList
    Serializable <|.. CopyOnWriteArrayList
    List <|.. ArrayList
    AbstractList <|-- ArrayList

    note for CopyOnWriteArrayList "不继承 AbstractList,直接实现 List"

继承体系说明

  1. 直接实现 ListCopyOnWriteArrayList 并没有像 ArrayList 那样继承 AbstractListAbstractList 提供了一些基于迭代器的默认实现(如 indexOflastIndexOfadd(E)set 等),但这些实现要么在并发下不安全,要么依赖可写迭代器(iterator() 返回的 Itr 支持 remove)。由于 CopyOnWriteArrayList 的迭代器是快照且不支持 remove,继承 AbstractList 将误导开发者,且其默认实现无法利用 COW 的锁机制。因此,它纯粹实现 List 接口,并自行实现所有方法。
  2. 标记接口:实现了 RandomAccess(表明支持快速随机访问,for 循环遍历优于迭代器)、Cloneable(支持浅克隆)和 Serializable(支持序列化)。
  3. 对比 ArrayListArrayList 侧重单线程高效可变数组,继承 AbstractList 复用通用逻辑。二者实现路径截然不同,反映出“可变单线程”与“并发读优化”的设计哲学差异。

Part 2:存储与构造篇

模块 4:存储结构与核心字段(源码剖析)

CopyOnWriteArrayList 的底层存储极为简单,仅有两个核心字段:

  • private transient volatile Object[] array;:存放元素的数组,用 volatile 修饰以保证引用变更的线程间可见性。transient 表示序列化时由自定义 writeObject 方法处理。
  • final transient ReentrantLock lock = new ReentrantLock();:用于保护所有写操作的互斥锁,使用默认非公平策略以减少上下文切换开销。

辅助方法:

  • final Object[] getArray():返回 array,一个普通的 volatile 读。
  • final void setArray(Object[] a):赋值 array = a,一个普通的 volatile 写。
classDiagram
    class CopyOnWriteArrayList~E~ {
        - volatile Object[] array
        - final ReentrantLock lock
        + getArray() Object[]
        + setArray(Object[] a) void
        + get(int) E
        + add(E) boolean
        + iterator() Iterator~E~
    }
    class ReentrantLock {
        + lock()
        + unlock()
    }
    CopyOnWriteArrayList --> "1" ReentrantLock : lock

字段关系说明

  • volatile 的作用:写线程在 unlock() 之前调用 setArray(newArray),该 volatile 写会将其之前的所有写操作(构造新数组、拷贝元素)都 happen-before 于随后任何读线程的 volatile 读(getArray())。因此,读线程要么看到旧数组,要么看到完整构造好的新数组,绝不会看到一个“半成品”数组。这便是“读无锁但仍能保证可见性”的根本原因。
  • 锁的职责lock 确保写-写互斥。假设两个写线程同时进行 add,若无锁保护,各自拷贝原数组并进行替换,后完成的线程会覆盖先前线程的修改,造成元素丢失。锁使得同一时刻只有一个线程能执行数组拷贝与替换,从而保证写操作的安全顺序。
  • transient 意义:底层数组通过 writeObject 持有锁后再序列化,避免序列化期间并发写导致的不一致。transient 防止默认序列化直接写出未经同步的数组。

模块 5:构造方法(源码剖析)

CopyOnWriteArrayList 提供三个构造器,都遵循防御性拷贝原则。

源码分析(基于 JDK 8):

// 1. 无参构造:直接赋空数组
public CopyOnWriteArrayList() {
    setArray(new Object[0]);
}

// 2. 从集合构造:转数组 + 防御性拷贝(若 c 是 CopyOnWriteArrayList 则直接复用其数组快照?)
public CopyOnWriteArrayList(Collection<? extends E> c) {
    Object[] elements;
    if (c.getClass() == CopyOnWriteArrayList.class)
        elements = ((CopyOnWriteArrayList<?>)c).getArray();
    else {
        elements = c.toArray();
        // 确保数组类型为 Object[](toArray 可能返回带类型的数组)
        if (elements.getClass() != Object[].class)
            elements = Arrays.copyOf(elements, elements.length, Object[].class);
    }
    setArray(elements);
}

// 3. 从已有数组构造:直接防御性拷贝传入数组
public CopyOnWriteArrayList(E[] toCopyIn) {
    setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
}

设计要点

  • 无参构造创建长度为 0 的空数组,避免 NPE,与 ArrayList 一致。
  • 集合构造器对 CopyOnWriteArrayList 来源特殊处理:直接复用其 getArray() 返回的数组。因为 COW 的数组一旦暴露给读线程便不会被修改,是事实不可变的,所以共享是安全的,能避免不必要的数组拷贝。对于其他集合,通过 toArray() 获取数组,并确保运行时类型是 Object[](防止某些集合返回的数组类型不匹配)。
  • 数组构造器的防御性拷贝:Arrays.copyOf 创建新数组,使得修改外部传入的数组不会影响容器的内部状态。这是保证不可变语义的关键。

Demo 演示

public class COWListConstructDemo {
    public static void main(String[] args) {
        // 1. 空列表
        CopyOnWriteArrayList<String> list1 = new CopyOnWriteArrayList<>();
        System.out.println("空列表大小: " + list1.size());

        // 2. 从集合构造
        List<String> source = new ArrayList<>(Arrays.asList("a", "b", "c"));
        CopyOnWriteArrayList<String> list2 = new CopyOnWriteArrayList<>(source);
        source.set(0, "changed");   // 不影响 list2,因为已防御拷贝
        System.out.println("list2: " + list2); // [a, b, c]

        // 3. 从数组构造
        String[] arr = {"x", "y", "z"};
        CopyOnWriteArrayList<String> list3 = new CopyOnWriteArrayList<>(arr);
        arr[0] = "modified";        // 不影响 list3
        System.out.println("list3: " + list3); // [x, y, z]
    }
}

Part 3:核心原理篇

模块 6:读操作——get 与 contains 的无锁实现(源码剖析)

读操作是 CopyOnWriteArrayList 的性能核心。没有锁、没有 CAS,只有一次 volatile 读和数组下标直接访问。

get(int index)

public E get(int index) {
    return get(getArray(), index);
}
private E get(Object[] a, int index) {
    return (E) a[index];
}
  • 首先 getArray() 读取 volatile 变量 array,获取当前最新数组引用。
  • 由于数组引用由 volatile 保证可见性,此处拿到的数组要么是快照(旧),要么是最新的完全构造好的数组。
  • 然后直接数组下标访问 a[index],O(1)。
  • 整个过程无锁,无阻塞。

contains(Object o)

public boolean contains(Object o) {
    Object[] elements = getArray();
    return indexOf(o, elements, 0, elements.length) >= 0;
}
private static int indexOf(Object o, Object[] elements, int index, int fence) {
    if (o == null) {
        for (int i = index; i < fence; i++)
            if (elements[i] == null)
                return i;
    } else {
        for (int i = index; i < fence; i++)
            if (o.equals(elements[i]))
                return i;
    }
    return -1;
}
  • 同样先通过 getArray() 获取数组快照,然后线性遍历,O(n)。
  • 即使遍历过程中有写操作发生(写线程替换了新数组),当前读线程仍会遍历原始数组,保证了遍历期间元素集合不变,不会被脏数据干扰。
flowchart TD
    Start[调用 get index] --> GetArray[getArray: volatile 读, 获取当前数组引用]
    GetArray --> RangeCheck[检查 index 是否越界?]
    RangeCheck -->|越界| Throw[抛出 IndexOutOfBoundsException]
    RangeCheck -->|未越界| Access[直接返回 array index]
    Access --> End[返回元素]

流程图解读

  • Step1 getArray() 是整个读操作唯一的同步点。由于 volatile 语义,它能读取到写操作最新发布的数组引用,但不会阻塞。
  • Step2 边界检查由 Java 数组本身完成(a[index] 时 JVM 会隐式检查),源码中并未显式写,实际调用 get(Object[], int) 时如果 index 非法会直接抛出异常。
  • Step3 数组访问是纯内存读取,由于此时获取的数组引用对应当前线程缓存的数组对象,该对象在创建后不会被修改,因此读取出的元素必然一致且有效(不会出现半个新增元素等情况)。
  • 整个过程与写操作完全解耦,无论写线程是否正在持有锁进行数组复制,读线程都无需等待。这是实现高并发读的基石。

模块 7:写操作——add 的完整 COW 流程(源码剖析)

add(E e) 是所有写操作的代表,它完美展现了写时复制的四个步骤。

源码剖析(JDK 8 CopyOnWriteArrayList.add(E)):

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();                           // 步骤1:获取锁
    try {
        Object[] elements = getArray();    // 步骤2:获取当前数组
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1); // 步骤3:复制到新数组,长度+1
        newElements[len] = e;             // 步骤4:在副本末尾添加新元素
        setArray(newElements);            // 步骤5:原子替换数组引用
        return true;
    } finally {
        lock.unlock();                    // 步骤6:释放锁
    }
}

为什么需要加锁? 假设未加锁,两个线程 T1、T2 同时执行 add:

  1. T1 读当前数组长度=10,拷贝出 newArray1 长度 11,添加元素 A。
  2. T2 同样读长度=10(此时 T1 尚未写入新数组),也拷贝出 newArray2 长度 11,添加元素 B。
  3. T1 用 setArray 替换引用为 newArray1,此时列表有 [原元素..., A]。
  4. T2 也用 setArray 替换引用为 newArray2,覆盖了 T1 的结果,列表只剩 [原元素..., B],A 丢失。

锁的作用是让“读原数组→复制→修改→替换”这四个动作成为一个原子操作,消除写-写冲突。

volatile 在写流程中的作用setArray(newElements) 是 volatile 写,它会将新数组引用刷入主内存,并使后续读操作的 volatile 读(getArray)能够看到新数组。写操作在锁内执行,volatile 并不替代锁,而是作为“发布新引用”的窗口,确保可见性。

sequenceDiagram
    participant W as 写线程
    participant Lock as ReentrantLock
    participant OldArr as 旧数组 (volatile array)
    participant NewArr as 新数组副本
    participant Mem as 主内存/读线程可见

    W->>Lock: lock() 获取锁
    W->>OldArr: getArray() 读取当前数组引用
    W->>NewArr: Arrays.copyOf 创建长度+1的新数组
    W->>NewArr: 在最后位置写入新元素
    W->>OldArr: setArray(newArray) volatile 写,更新引用
    W-->>Mem: 新数组引用刷入主内存
    W->>Lock: unlock() 释放锁
    Note over Mem: 后续读线程 getArray() 将返回新数组

时序图详细说明

  • 锁获取阶段:写线程竞争 lock,未获得锁的线程将阻塞,确保写操作严格串行化。由于锁是非公平的,吞吐量较高。
  • 数组拷贝Arrays.copyOf(elements, len + 1) 底层调用 System.arraycopy,将旧数组元素复制到新长度+1 的数组中。这是整个写操作最耗时的 O(n) 部分,也是 COW 最大的开销来源。在这个过程中,其他读线程仍然可以无阻地访问旧数组。
  • 修改副本:在新数组的末尾(索引为 len)写入新元素。因为只有当前写线程持有该引用,没有任何并发问题。
  • volatile 写替换setArray 将新数组引用写入 volatile 变量 array。根据 Java 内存模型的 happen-before 规则,这个 volatile 写之前的所有动作(元素复制、赋值)对后续任何读取该 volatile 变量的线程都是可见的。因此,读线程读完 volatile 后直接进行下标访问,一定能看到完整的新数组。
  • 锁释放unlock() 唤醒可能等待的其它写线程,它们将竞争锁,然后基于新的 array 重复 COW 流程。
  • 读线程视角:在写线程完成 setArray 后的某个时刻(经过 CPU 缓存同步),读线程调用 getArray() 将获得新数组引用。在此之前,读线程一直使用旧数组,从而实现了读写的无锁隔离,同时也意味着读取的是某个历史版本(弱一致性)。

模块 8:写操作——remove 与 set 的 COW 流程(源码剖析)

remove(int index)

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;
        if (numMoved == 0)  // 删除最后一个元素
            setArray(Arrays.copyOf(elements, len - 1));
        else {
            Object[] 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();
    }
}
  • 同样地,获取锁后根据删除位置构建长度减一的新数组。
  • numMoved == 0 表示删除末尾元素,此时只需 Arrays.copyOf 截取前 len-1 个元素即可。
  • 否则,先拷贝 [0, index) 部分,再拷贝 [index+1, len) 部分,跳过被删元素。
  • 最后原子设置新数组。

set(int index, E element)

public E set(int index, E element) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        E oldValue = get(elements, index);
        if (oldValue != element) {
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len);
            newElements[index] = element;
            setArray(newElements);
        } else {
            // 值完全相同,没必要替换,但依然要 setArray 以保持 volatile 写语义?
            // 实际上 JDK 8 源码并未做此优化,仍是同一路径,这里演示逻辑。
            setArray(elements);
        }
        return oldValue;
    } finally {
        lock.unlock();
    }
}
  • 即使新旧值相等,源码也会执行复制和 setArray(注释说明并非必须,但保持简单)。实际上在 JDK 8 源码中,set 方法并没有前面那段 if 判断,而是直接复制并替换。这里为体现优化思想做了示例。
  • 从原理角度看,任何写操作都必须创建新数组才能保证读的一致性,因为原数组不能修改(否则正在读的线程会看到部分修改)。
flowchart TD
    Start[调用 remove index] --> Lock[获取 ReentrantLock]
    Lock --> GetOld[getArray 获取当前数组]
    GetOld --> CalcMove[计算需要移动的元素数 numMoved]
    CalcMove --> CheckEnd{numMoved == 0?}
    CheckEnd -->|是| CopyTail[Arrays.copyOf 截取前 len-1 元素]
    CheckEnd -->|否| NewArr[创建长度为 len-1 的新数组]
    NewArr --> Copy1[System.arraycopy 拷贝 0, index 部分]
    Copy1 --> Copy2[System.arraycopy 拷贝 index+1, len 部分]
    Copy2 --> SetArr1[setArray 新数组]
    CopyTail --> SetArr2[setArray 新数组]
    SetArr1 --> Unlock[解锁并返回旧值]
    SetArr2 --> Unlock

流程图说明

  • 获取原数组:必须持有锁,因为计算 numMoved 依赖于当前数组长度,不加锁长度可能变化。
  • 元素移动:除最后一个元素外,删除中间元素需要将两段子数组拼接。System.arraycopy 是原生方法,性能很高,但整个操作仍是 O(n) 级别。
  • 新旧数组共存:在 setArray 之前,旧数组仍然可读。这意味着即使删除过程耗时较长,也不会阻塞任何读线程。释放锁后,旧的数组对象可能仍被某些迭代器或读线程引用,直至不再使用后由 GC 回收。
  • 为何不直接修改原数组?若直接修改原数组(如 array[index] = null 并调整后续元素),读线程可能看到一个不一致的中间状态(比如 size 没变,元素却部分移动),造成脏读或 NPE。COW 策略彻底消除这种可能。

模块 9:批量操作——addAll、addIfAbsent 的 COW 实现

addAll(Collection<? extends E> c)

public boolean addAll(Collection<? extends E> c) {
    Object[] cs = (c.getClass() == CopyOnWriteArrayList.class) ?
        ((CopyOnWriteArrayList<?>)c).getArray() : c.toArray();
    if (cs.length == 0)
        return false;
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        // 若原数组为空 + 目标集合是 Object[] 类型,直接复用 cs?
        // JDK 8 做了这个优化:
        if (len == 0 && cs.getClass() == Object[].class)
            setArray(cs);
        else {
            Object[] newElements = Arrays.copyOf(elements, len + cs.length);
            System.arraycopy(cs, 0, newElements, len, cs.length);
            setArray(newElements);
        }
        return true;
    } finally {
        lock.unlock();
    }
}
  • 先将集合转为数组 cs(若来源也为 COW,则直接共享其数组,因为不可变)。
  • 锁内获取原数组,新建长度为 len + cs.length 的数组,先拷贝原数组部分,再追加 cs 部分。
  • 优化点:如果原列表为空且 csObject[] 类型,直接使用 cs 作为新数组(保证了防御性拷贝?实际上这里“直接复用”因为 COW 的数组不被修改,所以安全)。

addIfAbsent(E e)

public boolean addIfAbsent(E e) {
    Object[] snapshot = getArray();
    // 先快照检查,若已存在则不加锁直接返回 false
    return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
        addIfAbsent(e, snapshot);
}
private boolean addIfAbsent(E e, Object[] snapshot) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] current = getArray();
        int len = current.length;
        // 一旦持有锁,需要再次检查,因为在获取锁期间可能已有其他线程添加了该元素
        if (snapshot != current) {
            // 数组已变更,在最新的数组上查找
            int common = Math.min(snapshot.length, len);
            for (int i = 0; i < common; i++) {
                if (current[i] != snapshot[i] && eq(e, current[i]))
                    return false;  // 新数组中已存在
            }
            if (indexOf(e, current, common, len) >= 0)
                return false;
        }
        // 确实不存在,执行 COW 添加
        Object[] newElements = Arrays.copyOf(current, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}
  • 这是典型的双重检查:先无锁快速检查快照中是否存在,若存在直接返回 false;若不存在或不确定,则加锁后在最新的数组上再次检查。加锁过程可能原数组已发生变化,因此要对比 snapshotcurrent,并查找新增部分,避免重复添加。确认不存在后再进行 COW。
flowchart TD
    Start[addIfAbsent E] --> Snapshot[getArray 获取快照]
    Snapshot --> FastCheck{indexOf 在快照中是否存在?}
    FastCheck -->|存在| ReturnFalse1[返回 false]
    FastCheck -->|不存在| Lock[获取锁]
    Lock --> Current[getArray 获取最新数组 current]
    Current --> SameRef{snapshot == current?}
    SameRef -->|是| Add[直接复制并追加 E]
    SameRef -->|否| CheckChanged[逐一对比变化区域并查找是否存在 E]
    CheckChanged --> Found{发现 E?}
    Found -->|是| ReturnFalse2[返回 false, 解锁]
    Found -->|否| Add
    Add --> SetArr[setArray 新数组, 解锁, 返回 true]

流程图说明

  • 快速路径:第一次快照检查不加锁,极大地提高了重复添加(幂等写入)时的性能,符合读多写少的哲学。
  • 加锁后二次检查:这是多线程环境下保证正确性的关键。由于第一次检查到加锁之间可能有其他线程写入了相同元素,如果不二次检查就会产生重复元素。检查方式上,先比较公共前缀部分,如果某个位置元素不同,则要判断新元素是否就是 e;再检查新增尾部。
  • 最终复制写入:只有确认最新数组中不存在 e 时,才进行数组拷贝并追加。这是经典的“check-then-act”模式,必须在锁的保护下原子完成。

Part 4:迭代器与一致性篇

模块 10:迭代器(COWIterator)的快照机制(源码剖析)

CopyOnWriteArrayList 的迭代器 COWIterator 是其弱一致性的直接体现。

源码结构(内部类):

static final class COWIterator<E> implements ListIterator<E> {
    private final Object[] snapshot;  // 迭代器创建时的数组快照
    private int cursor;               // 下一个元素索引

    private 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++];
    }

    public void remove() {
        throw new UnsupportedOperationException();  // 不允许修改快照
    }
    // 其他方法省略...
}

iterator() 方法

public Iterator<E> iterator() {
    return new COWIterator<E>(getArray(), 0);
}
  • 直接在构造时保存当前array的引用为snapshot。后续无论外部列表经历多少次 add/remove,snapshot始终指向旧数组对象。
  • 因为旧数组没有任何写操作去改动它,迭代过程完全无锁且恒定不变。

弱一致性

  • 迭代器遍历的是“过去某个时间点”的数据快照。如果外部有写操作,迭代器感知不到。
  • 这是典型的最终一致性/弱一致性模型。牺牲强一致性以换取高性能无锁迭代。
sequenceDiagram
    participant W as 写线程
    participant List as CopyOnWriteArrayList
    participant It as COWIterator (读线程)
    participant OldArr as 旧数组 (array)
    participant NewArr as 新数组

    Note over List: 初始 array = OldArr
    It->>List: iterator() 获得 COWIterator(snapshot = OldArr)
    W->>List: add(E) 获取锁,拷贝 OldArr -> NewArr,setArray(NewArr)
    Note over List: array = NewArr
    It->>It: next() -> snapshot[cursor++] 读取 OldArr 中的元素
    W->>List: remove(0) 再次拷贝 NewArr -> NewArr2,setArray(NewArr2)
    It->>It: next() -> 仍然读取 OldArr
    Note over It: 遍历完毕,snapshot 中的元素集合始终是 OldArr 的内容

时序图说明

  • 创建快照:当读线程调用 iterator() 时,getArray() 返回当前数组引用并存入 snapshot。此刻起,迭代器与该数组对象绑定。
  • 写操作并行:写线程随后进行的 add 创建了新数组 NewArr 并通过 setArray 更新了列表的 array 引用,但对迭代器的 snapshot 毫无影响。之后无论进行多少次写操作,迭代器始终遍历 OldArr
  • 不会 CME:正因为迭代器的数据源是不变的数组,不存在结构性修改,因此永远不需要抛出 ConcurrentModificationException。这也是相比于 synchronizedList(其迭代器必须手动锁住整个列表,若忘记加锁则会快速失败抛出 CME)的一大优势。
  • 迭代器 remove 被禁用:如果允许迭代器调用 remove,就势必要修改其背后的数组。由于 snapshot 是共享的,修改它会破坏隔离性,而且即便允许修改,也无法反映到当前最新的列表(因为列表引用已经可能指向新数组)。因此直接抛出 UnsupportedOperationException 是最安全的选择。
  • 业务影响:如果业务逻辑需要在迭代时看到最新的数据(例如实时计价器),弱一致性可能导致错误;但对于监听器通知,快照反而能避免通知期间监听器列表变动导致的不确定性,是一种合理的语义。

模块 11:序列化机制

CopyOnWriteArrayList 实现了 Serializable,并自定义了 writeObjectreadObject 方法以适应并发与 transient 数组。

writeObject

private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
    s.defaultWriteObject();
    final ReentrantLock lock = this.lock;
    lock.lock();                           // 序列化时加锁,保证数组一致性
    try {
        Object[] elements = getArray();
        int len = elements.length;
        s.writeInt(len);
        for (int i = 0; i < len; i++)
            s.writeObject(elements[i]);
    } finally {
        lock.unlock();
    }
}
  • 序列化期间持有锁,防止数组在写出过程中被修改,保证序列化快照的数据一致性。
  • 写入长度和逐个元素。

readObject

private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
    s.defaultReadObject();
    int len = s.readInt();
    Object[] elements = new Object[len];
    for (int i = 0; i < len; i++)
        elements[i] = s.readObject();
    setArray(elements);                    // 直接设置为新数组
}
  • 反序列化时重建数组,不需要锁,因为对象尚未发布。
sequenceDiagram
    participant Ser as 序列化线程
    participant Lock as ReentrantLock
    participant List as CopyOnWriteArrayList
    participant Out as ObjectOutputStream

    Ser->>Lock: lock() 获取锁
    Ser->>List: getArray() 获取当前数组
    Ser->>Out: writeInt(len)
    loop 对每个元素
        Ser->>Out: writeObject(element)
    end
    Ser->>Lock: unlock()

序列化流程图说明

  • 加锁原因:如果不加锁,假设在遍历数组写出元素的过程中,另一个写线程执行了 add,数组引用被替换,可能导致序列化数据错乱(写出部分旧数组、部分新数组,甚至数组长度不匹配)。通过锁将整个序列化过程与写操作互斥,保证了序列化内容是一致的历史快照。
  • 默认序列化s.defaultWriteObject() 负责写出非 static、非 transient 的字段,而 array 是 transient,所以其内容由自定义代码负责写出。
  • 性能考虑:序列化通常不是高频操作,加锁带来的阻塞可以接受。

Part 5:对比与并发篇

模块 12:CopyOnWriteArrayList vs Vector——无锁读 vs 全方法锁

Vector 是 Java 1.0 就存在的遗留线程安全 List,几乎所有方法都用 synchronized 修饰。与 CopyOnWriteArrayList 的对比是设计哲学上的绝佳案例。

对比维度CopyOnWriteArrayListVector
读操作 (get)无锁,volatile 读直接数组访问synchronized 方法,每次获取锁/监视器
写操作 (add)ReentrantLock + 数组拷贝 O(n)synchronized + 数组扩容/移动 O(n)
迭代器COWIterator 快照,无锁,不抛 CME迭代器会检查 modCount,并发写抛 CME
并发读吞吐量极高,完全无等待低,读线程竞争同一把锁
内存开销每次写产生新数组,大列表 GC 压力大无额外数组拷贝,但扩容时同样复制
适用时期Java 5+ 并发包,现代并发设计遗留类,不推荐使用
sequenceDiagram
    participant R1 as 读线程1
    participant R2 as 读线程2
    participant COW as CopyOnWriteArrayList
    participant Vec as Vector (synchronized)

    Note over R1,Vec: COW 场景:读操作完全无锁
    R1->>COW: get(0) (volatile 读 + 数组下标)
    R2->>COW: get(1) (同时进行,无阻塞)
    COW-->>R1: 返回
    COW-->>R2: 返回

    Note over R1,Vec: Vector 场景:读互斥
    R1->>Vec: get(0) (获取 this 锁)
    R2->>Vec: get(1) (BLOCKED 等待 this 锁)
    Vec-->>R1: 返回并释放锁
    R2->>Vec: 获取锁,get(1)
    Vec-->>R2: 返回并释放锁

时序图说明

  • COW 的无等待读:在 CopyOnWriteArrayList 中,所有读线程通过 getArray() 获取当前数组引用,这是纯粹的 volatile 读,不存在任何队列或 context switch。因此,理论上读线程数量增加只会受到 CPU 缓存带宽限制,不会因锁争用而下滑吞吐量。
  • Vector 的互斥读Vectorget 方法是 synchronized,意味着任何时刻只有一个读线程能进入,其他线程必须阻塞,直到占有锁的线程退出。这会导致严重的线程上下文切换开销,在读密集场景下成为瓶颈。
  • 写操作对比Vector 的写操作同样是互斥的,但无需拷贝整个数组,只是通过 System.arraycopy 移动元素,所以写性能通常优于 CopyOnWriteArrayList(除非列表极小)。但写并发度都受同一把锁限制。
  • 时代烙印Vector 诞生时 Java 还没有并发工具包,其设计简单粗暴,如今已被《Effective Java》明确建议弃用。CopyOnWriteArrayList 体现了 Doug Lea 等人对于特定并发场景的精准优化。

模块 13:CopyOnWriteArrayList vs Collections.synchronizedList——快照迭代 vs 手动加锁

Collections.synchronizedList 返回一个包装器,将所有方法包裹在 synchronized 块中(基于装饰器模式)。与 CopyOnWriteArrayList 相比,有以下关键差异:

对比维度CopyOnWriteArrayListCollections.synchronizedList
读锁无锁读方法也用 synchronized
迭代直接迭代,无额外操作,不抛 CME必须手动 synchronized(list) 包裹迭代,否则抛 CME
内存每次写复制整个数组不额外复制
数据视图迭代器快照是旧数据若正确加锁,迭代器能看见最新数据
写频率适应性写频率极低可承受更高的写频率
使用复杂度简单,天然线程安全迭代易出错,忘记加锁可能导致 CME 或数据不一致
flowchart TD
    Start["需要线程安全 List"] --> Ratio{"读写比例?"}
    Ratio -->|"读 >>> 写"| DataSize{"数据量 < 1000 或 极低频写?"}
    DataSize -->|"是"| Weak{"允许迭代器看到旧数据?"}
    Weak -->|"是"| PickCOW["使用 CopyOnWriteArrayList"]
    Weak -->|"否 必须强一致"| PickSyncList1["使用 synchronizedList + 手动同步迭代"]
    DataSize -->|"否"| PickSyncList2["使用 synchronizedList 或 ConcurrentHashMap 等"]
    Ratio -->|"写操作频繁"| PickSyncList3["使用 synchronizedList 或 其他并发集合"]
    PickSyncList1 --> NoteSync["注意: 迭代时必须 synchronized 包裹"]
    PickSyncList2 --> NoteSync
    PickSyncList3 --> NoteSync

决策树说明

  • 关键分歧点CopyOnWriteArrayList 在迭代安全性上完胜,因为完全不用使用者操心同步问题,代价是弱一致性。synchronizedList 在适合同步迭代时需要开发者自行保证锁的范围,忘记了就出 CME,这是无数线上故障的来源。
  • 应对写操作的能力:如果写比例上升,CopyOnWriteArrayList 性能剧烈恶化(O(n) 拷贝),synchronizedList 则相对平缓,因为 add 是 O(1) 摊销 (数组扩容时的拷贝) -> 实际上 ArrayList 尾部追加也是 O(1)*,而 COW 每次都是 O(n)。所以在有一定写操作的场合,果断使用 synchronizedList 或更优的 ConcurrentLinkedQueue/LinkedBlockingQueue 等。
  • 容错性与代码维护CopyOnWriteArrayList 降低了并发代码的编写难度,尤其适合公开给团队内部多个模块共享的公共列表(如插件列表),避免了他处忘记同步导致的神秘崩溃。

模块 14:内存开销与 GC 影响分析

内存图片

  • 初始状态:数组 A(size=1000),占用内存约 4KB(假设对象引用)。
  • 线程执行 add:复制 A 得到 A'(size=1001),占用额外 4KB,此时旧数组 A 如果不再被任何读线程或迭代器引用,将成为垃圾。
  • 频繁写入:每写一次便产生一个新数组对象,旧数组对象废弃。若写操作间隔极短,堆中将同时存在大量数组对象,迅速占满新生代,频繁触发 Minor GC,甚至直接晋升至老年代导致 Full GC。

内存预估

  • 假设一个包含 N 个元素的列表,每次写入都需要分配一个长度为 N+1 的新数组。
  • 若写入频率为 F 次/秒,每秒产生的数组垃圾至少为 F * N * refs_size 字节。
  • 当 N=10,000 且 F=10 时,每秒产生约 10 * 10000 * 4 = 400KB 的引用数组,以及可能的对象包装,对 GC 形成持续压力。
  • 对于 N > 10,000,单次数组拷贝消耗的时间可能超过 1ms,再加上垃圾回收,系统吞吐量会受到致命影响。

优化建议

  • 控制大小:确保 CopyOnWriteArrayList 的容量保持在千级别以下。如果业务上有上万元素,可以考虑分段锁结构或使用 ConcurrentHashMap 替代。
  • 合并写操作:如果可以,用 addAll 一次性添加多条数据,只发生一次复制,减少数组拷贝次数。
  • 避免频繁修改:如果是类似于“定时刷新全量列表”的场景,使用 new CopyOnWriteArrayList<>(newCollection) 重新构造,而不是逐条 clear+addAll,因为构造器会复用直接拷贝,和 clear/addAll 类似,但语义更清晰。

Part 6:总结与面试篇

模块 15:注意事项与最佳实践

  1. 严格控制写频率:COW 仅适合写极低频场景。若写比例超过 1%,就需要重新评估。

    • ❌ 错误:用 CopyOnWriteArrayList 做高频计数器记录。
    • ✅ 正确:存放全局配置,启动时写入,运行时仅更新数次。
  2. 数据量不宜过大:元素数保持在数千以内。若达上万,add 耗时数十微秒甚至更长,且内存膨胀。

    • ❌ 错误:存放几万个用户的 Session 对象。
    • ✅ 正确:存放当前系统加载的模块监听器数(通常 < 100)。
  3. 迭代器不支持 remove:如果业务需在遍历时删除元素,必须自行收集待删除元素,遍历结束后再调用 removeAll

    CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(Arrays.asList("a","b","c"));
    Iterator<String> it = list.iterator();
    // it.remove(); // 抛出 UnsupportedOperationException
    
    // 正确做法:
    List<String> toRemove = new ArrayList<>();
    for (String s : list) {
        if (shouldRemove(s)) toRemove.add(s);
    }
    list.removeAll(toRemove);
    
  4. 弱一致性陷阱:写完立刻读可能获取不到新值,避免依赖“写入后立即可见”的逻辑。

    CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
    list.add(1);
    // 此处 get(0) 保证能读到 1,因为 add 内部 setArray 已经 happen-before 当前线程后续读。但如果是另一个线程先 add,当前线程 get 可能看到旧值。
    

    正确做法:业务需要强一致结果时,采用 synchronizedList 并自行加锁包围读写;或使用 volatile 等信号量通知。

  5. 使用 addIfAbsent 实现幂等写入:对于黑名单这类不允许重复的场景,addIfAbsent 比先查后写更安全原子。

    CopyOnWriteArrayList<String> blackList = new CopyOnWriteArrayList<>();
    if (!blackList.contains(ip)) {  // 线程不安全的 check-then-act
        blackList.add(ip);
    }
    // 应改为:
    blackList.addIfAbsent(ip);
    

模块 16:性能总结与选型建议

时间复杂度

操作CopyOnWriteArrayListsynchronizedList (ArrayList)
get(int)O(1) 无锁O(1) 有锁
add(E)O(n) 复制O(1) 摊销*
add(int,E)O(n) 复制O(n) 移动元素
remove(int)O(n) 复制O(n) 移动元素
contains(E)O(n) 遍历O(n) 遍历
iterator()O(1) 创建快照,遍历 O(n)O(1),但必须外部同步
addAllO(m+n) 复制O(m+n) 复制/移动

选型结论

  • 事件监听器列表、配置缓存、白/黑名单CopyOnWriteArrayList,极致读性能。
  • 读多写少、数据量小、容忍弱一致CopyOnWriteArrayList
  • 读写均衡或写多、需要强一致性Collections.synchronizedList(new ArrayList<>())Vector(不推荐),甚至直接使用 synchronized 块。
  • 高并发队列场景 → 考虑 ConcurrentLinkedQueueLinkedBlockingQueue 等。
  • 大数据量、频繁写 → 绝对不要使用 CopyOnWriteArrayList

模块 17:面试高频专题

1. CopyOnWriteArrayList 的实现原理是什么?为什么读不加锁?

标准回答CopyOnWriteArrayList 基于写时复制思想,使用 volatile 修饰的 Object[] 数组存储元素,写操作(add、set、remove)先获取 ReentrantLock,拷贝当前数组生成新数组,修改后通过 volatile 写替换旧引用,最后释放锁。读操作直接通过 getArray() 获取当前数组引用,然后进行下标或遍历访问,完全无锁。读不加锁是因为 volatile 保证了数组引用的可见性,一旦获得引用,该数组就相当于不可变对象,读操作安全无需同步。

追问模拟:为什么 volatile 能保证读到的数组内容一定是完整的? 回答:Java 内存模型的 happen-before 规则,volatile 写 setArray(newArray) 之前的所有操作(构造新数组、拷贝元素)对于后续任何读该 volatile 变量的线程是可见的。因此读线程拿到引用后,访问数组元素时看到的是完全构造好的数组,非半个状态。

加分回答:还可以提与读写锁的区别:读写锁在读多写少时依然有状态同步开销(CAS 修改 state),而 CopyOnWriteArrayList 的读就是一条普通的 volatile 读,成本极低,且不阻塞,适合极致读优化。

2. 写时复制的过程是怎样的?为什么要加锁?volatile 的作用是什么?

标准回答:写线程先获取锁,然后 getArray() 获取当前数组,用 Arrays.copyOf 创建出新长度的数组,在副本上完成修改,最后调用 setArray(newElements) 进行 volatile 写替换引用,再释放锁。加锁是为了防止多个写线程同时拷贝并替换导致写覆盖。volatile 保证新数组引用发布后,所有读线程能立即看到。

追问模拟:如果去掉锁,只用 volatile 会有什么问题? 回答:会导致经典的“遗失更新”问题。多个写线程可能基于同一个旧数组各创建新数组,最后 volatile 写仅有一个生效,其他线程的修改全部丢失。

3. CopyOnWriteArrayList 的迭代器为什么不支持 remove?为什么不抛 CME?

标准回答:迭代器在创建时保存了当前数组的快照,之后遍历完全基于该快照。快照本身是稳定不变的,不存在结构修改,所以无需抛出 ConcurrentModificationException。但正是由于快照隔离,迭代器无法将删除操作反映到原始列表的最新数组上,硬性删除快照内的元素没有意义且会破坏隔离性,因此直接抛出 UnsupportedOperationException

4. CopyOnWriteArrayList 和 Collections.synchronizedList 的区别?

标准回答CopyOnWriteArrayList 读无锁、写复制;synchronizedList 所有方法通过 synchronized 互斥。COW 适合读多写极少场景,迭代无需手动加锁,但写成本高且弱一致。synchronizedList 适合写操作不那么稀少的场景,但迭代时必须手动包裹 synchronized (list),否则抛 CME。COW 内存开销大。

追问:在读多写少场景为何 COW 吞吐量更高? 回答:因为读操作不参与任何锁竞争,不会阻塞,避免了上下文切换和内核态切换开销,多核 CPU 下读线程可真正并行执行。

5. CopyOnWriteArrayList 和 Vector 的区别?

标准回答Vector 是同步方法全加锁,读写都互斥;CopyOnWriteArrayList 读无锁,写通过锁加复制。Vector 迭代器快速失败抛 CME,COW 快照迭代器不抛。Vector 是遗留类,现代代码应使用 COW 或 synchronizedList。COW 内存代价更高。

6. CopyOnWriteArrayList 的 add 操作为什么比 synchronizedList 的 add 慢?

标准回答:COW 的 add 需要 Arrays.copyOf 复制整个数组,时间复杂度 O(n),而且涉及内存分配和拷贝。synchronizedList 基于 ArrayList,add 在尾部追加一般为 O(1),只是偶尔扩容复制。因此,当数组大小 n 较大时,COW 的 add 明显更慢。

追问:数组复制的具体代价体现在哪? 回答:CPU 时间用于 System.arraycopy,同时分配新数组消耗内存,并给 GC 带来压力。频繁 add 将生成大量临时数组对象,导致 GC 停顿。

7. 弱一致性具体是什么意思?什么业务场景不可接受?

标准回答:弱一致性指读操作可能看不到最新的写操作修改,迭代器也仅能看到创建时的快照。具体表现为:线程 A 刚 add 完一个元素,线程 B 紧接着 get 可能返回的还是旧版本列表。在金融交易系统、实时计费系统等需要严格线性一致性的场景下不可接受。适用于允许轻微滞后的查询场景,如配置下发、黑名单更新。

8. CopyOnWriteArrayList 适合什么场景?为什么?

标准回答:适合读操作远超写操作,数据量不大,能接受弱一致性的场景,最典型的是事件监听器管理。原因:监听器列表注册/注销极低频,事件触发时遍历频率极高,COW 的读无锁极大提升并发事件分发效率。

9. CopyOnWriteArrayList 大量写操作会发生什么?

标准回答:CPU 飙升(频繁复制数组),内存消耗巨大(每次写创建新数组),GC 频繁甚至 Full GC,吞吐量急剧下降。系统可能陷入 GC 停顿和性能雪崩。

10. 如果要删除大量元素,CopyOnWriteArrayList 是好的选择吗?为什么?

标准回答:不是。因为每次 remove 都要复制整个数组创建一个略小的新数组,批量删除会触发多次复制,效率极低。建议使用 synchronizedList,或者先收集要保留的元素,一次性构造新列表。更好的是使用支持批量处理的集合。

11. 请简述 volatile 在 CopyOnWriteArrayList 中的作用。

标准回答:volatile 修饰 array 字段,使得写线程在 setArray 后,所有读线程都能立即看到新的数组引用。它是实现读操作无锁化但依然能看到“最新”数组的关键,通过 volatile 的 happen-before 语义将写线程的整个构造过程可见地发布给读线程。