概述
CopyOnWriteArrayList 通过在写操作上应用数组全量复制与 ReentrantLock 互斥,将修改隔离于副本之上,再借助 volatile 写将新数组引用发布;读路径仅需一次 volatile 读即可访问逻辑上不可变的数组,完全免除锁或 CAS 的开销。这一“写复制、读快照”的模型使得高并发读取能线性扩展,但代价是每次写入都必须付出 O(n) 的复制开销与对应的内存压力,且迭代器始终基于构造时的数组快照,呈现弱一致性语义。理解其 volatile 数组引用所建立的 happen-before 关系、写时复制的内存作用以及与其他并发 List 在一致性与性能上的根本差异,是应对特定并发场景选型的关键。
- 写时复制(Copy-On-Write)思想:写操作复制整个底层数组,在新副本上修改,完成后原子替换引用,读取完全无锁,彻底消除读-写冲突。
- volatile + ReentrantLock 的并发原语:通过
volatile保证数组引用的可见性,ReentrantLock保护写操作的互斥性,二者协同构建安全且高效的并发模型。 - 迭代器快照机制与弱一致性:迭代器持有创建时的数组快照,不受后续写操作影响,永不抛出
ConcurrentModificationException,却也因此只能读取到“过去”的数据。 - 读多写少的极致优化:读操作零锁争用,高并发读场景下吞吐量远超
synchronizedList和Vector,是极致优化读性能的并发 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 条):
- 线程安全,读操作完全无锁:
get()、contains()等读方法不涉及任何同步手段,可在高并发下无争用访问。 - 写操作通过 ReentrantLock + 数组拷贝 + 原子替换实现:所有写方法获取同一把锁,确保同一时刻只有一个线程执行拷贝-修改-替换,避免写覆盖。
- 迭代器基于快照,弱一致性:
iterator()返回时捕获当前数组快照,之后列表的任何修改对迭代器不可见,不抛出ConcurrentModificationException。 - 不抛 CME(ConcurrentModificationException):根本原因在于迭代器遍历的是稳定不变的快照数组,不存在结构变化。
- 不允许迭代器 remove:调用
Iterator.remove()会直接抛出UnsupportedOperationException,因为快照一旦创建便不可变。 - 允许 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"]
决策树说明:
- 起点:首先判断是否必须使用线程安全的 List。若仅单线程或外部同步已保证,
ArrayList性能更佳。 - 读写比例:
CopyOnWriteArrayList的核心优势在于读无锁,因此读多写少(例如读写比 > 100:1)是重要前提。如果写操作占比高,应果断放弃。 - 数据规模:内部数组拷贝的复杂度为 O(n),当 n 大于数千时,单次写入成本显著上升,加之内存翻倍,大 List 会成为负担。
- 弱一致性容忍度:迭代器只能看到创建时的数据,写后立刻读也可能获取旧值。如果业务上要求实时准确,则需要使用
synchronizedList或其他同步结构,并注意在读写时手动持有锁。 - 综合判断:只有所有条件都满足,
CopyOnWriteArrayList才是最优解。任何一项不满足,都应考虑替代方案。
模块 2:写时复制(Copy-On-Write)设计思想
写时复制(COW)是一种广泛应用的计算机科学优化策略,核心思想是:将资源复制延迟到真正需要修改的时候,共享同一份数据的所有读者无需受影响,仅当某个写者要修改时,才复制出自己的私有副本。
- 操作系统:fork() 系统调用创建子进程时,内核并未立即复制父进程的整个地址空间,而是让父子进程共享只读物理页。直到任一进程尝试写入某页,才触发 page fault,由内核为该页创建可写副本。这极大加快了进程创建速度并节省了内存。
- 虚拟化/容器:写时复制在镜像分层存储中同样关键,多个容器可以共享相同的基础镜像层,仅在写入时产生轻量的差分层。
- Java 集合:
CopyOnWriteArrayList和CopyOnWriteArraySet将这一理念应用到并发集合。所有读线程共享同一个数组(不可变视图),写线程互斥地创建新数组并原子地发布,使读完全不阻塞。
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"
继承体系说明:
- 直接实现 List:
CopyOnWriteArrayList并没有像ArrayList那样继承AbstractList。AbstractList提供了一些基于迭代器的默认实现(如indexOf、lastIndexOf、add(E)、set等),但这些实现要么在并发下不安全,要么依赖可写迭代器(iterator()返回的Itr支持remove)。由于CopyOnWriteArrayList的迭代器是快照且不支持remove,继承AbstractList将误导开发者,且其默认实现无法利用 COW 的锁机制。因此,它纯粹实现List接口,并自行实现所有方法。 - 标记接口:实现了
RandomAccess(表明支持快速随机访问,for 循环遍历优于迭代器)、Cloneable(支持浅克隆)和Serializable(支持序列化)。 - 对比 ArrayList:
ArrayList侧重单线程高效可变数组,继承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:
- T1 读当前数组长度=10,拷贝出 newArray1 长度 11,添加元素 A。
- T2 同样读长度=10(此时 T1 尚未写入新数组),也拷贝出 newArray2 长度 11,添加元素 B。
- T1 用 setArray 替换引用为 newArray1,此时列表有 [原元素..., A]。
- 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部分。 - 优化点:如果原列表为空且
cs是Object[]类型,直接使用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;若不存在或不确定,则加锁后在最新的数组上再次检查。加锁过程可能原数组已发生变化,因此要对比
snapshot和current,并查找新增部分,避免重复添加。确认不存在后再进行 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,并自定义了 writeObject 和 readObject 方法以适应并发与 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 的对比是设计哲学上的绝佳案例。
| 对比维度 | CopyOnWriteArrayList | Vector |
|---|---|---|
| 读操作 (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 的互斥读:
Vector的get方法是synchronized,意味着任何时刻只有一个读线程能进入,其他线程必须阻塞,直到占有锁的线程退出。这会导致严重的线程上下文切换开销,在读密集场景下成为瓶颈。 - 写操作对比:
Vector的写操作同样是互斥的,但无需拷贝整个数组,只是通过System.arraycopy移动元素,所以写性能通常优于CopyOnWriteArrayList(除非列表极小)。但写并发度都受同一把锁限制。 - 时代烙印:
Vector诞生时 Java 还没有并发工具包,其设计简单粗暴,如今已被《Effective Java》明确建议弃用。CopyOnWriteArrayList体现了 Doug Lea 等人对于特定并发场景的精准优化。
模块 13:CopyOnWriteArrayList vs Collections.synchronizedList——快照迭代 vs 手动加锁
Collections.synchronizedList 返回一个包装器,将所有方法包裹在 synchronized 块中(基于装饰器模式)。与 CopyOnWriteArrayList 相比,有以下关键差异:
| 对比维度 | CopyOnWriteArrayList | Collections.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:注意事项与最佳实践
-
严格控制写频率:COW 仅适合写极低频场景。若写比例超过 1%,就需要重新评估。
- ❌ 错误:用
CopyOnWriteArrayList做高频计数器记录。 - ✅ 正确:存放全局配置,启动时写入,运行时仅更新数次。
- ❌ 错误:用
-
数据量不宜过大:元素数保持在数千以内。若达上万,
add耗时数十微秒甚至更长,且内存膨胀。- ❌ 错误:存放几万个用户的 Session 对象。
- ✅ 正确:存放当前系统加载的模块监听器数(通常 < 100)。
-
迭代器不支持 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); -
弱一致性陷阱:写完立刻读可能获取不到新值,避免依赖“写入后立即可见”的逻辑。
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(); list.add(1); // 此处 get(0) 保证能读到 1,因为 add 内部 setArray 已经 happen-before 当前线程后续读。但如果是另一个线程先 add,当前线程 get 可能看到旧值。正确做法:业务需要强一致结果时,采用
synchronizedList并自行加锁包围读写;或使用volatile等信号量通知。 -
使用
addIfAbsent实现幂等写入:对于黑名单这类不允许重复的场景,addIfAbsent比先查后写更安全原子。CopyOnWriteArrayList<String> blackList = new CopyOnWriteArrayList<>(); if (!blackList.contains(ip)) { // 线程不安全的 check-then-act blackList.add(ip); } // 应改为: blackList.addIfAbsent(ip);
模块 16:性能总结与选型建议
时间复杂度:
| 操作 | CopyOnWriteArrayList | synchronizedList (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),但必须外部同步 |
| addAll | O(m+n) 复制 | O(m+n) 复制/移动 |
选型结论:
- ✅ 事件监听器列表、配置缓存、白/黑名单 →
CopyOnWriteArrayList,极致读性能。 - ✅ 读多写少、数据量小、容忍弱一致 →
CopyOnWriteArrayList。 - ✅ 读写均衡或写多、需要强一致性 →
Collections.synchronizedList(new ArrayList<>())或Vector(不推荐),甚至直接使用synchronized块。 - ✅ 高并发队列场景 → 考虑
ConcurrentLinkedQueue或LinkedBlockingQueue等。 - ❌ 大数据量、频繁写 → 绝对不要使用
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 语义将写线程的整个构造过程可见地发布给读线程。