# CopyOnWriteArrayList 深度分析
作者:Ethan.Yang
博客:https://blog.ethanyang.cn (opens new window)
基于JDK11进行分析, 大部分公司已经开始使用JDK11
前面介绍的2种List都会有并发风险, CopyOnWriteArrayList 是 Java 并发包 (java.util.concurrent) 中的一个线程安全的 List 实现,适合读多写少的场景。
# 一、继承体系
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
2
| 父类/接口 | 作用 | 责任说明 |
|---|---|---|
RandomAccess | 接口能力 | 支持快速随机访问 |
List | 行为规范 | 定义有序可重复集合的基本操作,如 add、get、remove 等 |
Cloneable | 接口能力 | 支持对象浅拷贝,通过 clone() 方法实现 |
Serializable | 接口能力 | 支持对象序列化,可跨 JVM 传输或持久化存储 |
# 二、底层数据结构解析
# 核心字段
private transient volatile Object[] array;
array:是CopyOnWriteArrayList唯一的数据存储结构,类型是Object[]。volatile保证了对不同线程的 可见性,即线程修改了 array,其他线程能立刻看到更新后的数组。
# 核心机制
写时复制(Copy-On-Write), 什么是写时复制?
每次修改数据(比如 add、remove、set)时,都不直接修改原数组,而是:
- 复制出一个新数组(原数组的副本)
- 在副本上修改
- 用
setArray()替换旧的数组引用
好处
- 读操作无锁,直接读取数组,效率极高
- 写操作互斥(加锁),但互斥范围小
- 不会发生并发修改异常(
ConcurrentModificationException)
# 三、核心方法解析(JDK11)
# 增
add(E e)
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
// 写时复制
es = Arrays.copyOf(es, len + 1);
es[len] = e;
// 替换底层数组引用
setArray(es);
return true;
}
}
2
3
4
5
6
7
8
9
10
11
12
过程:获取当前数组副本,复制扩容1,追加元素,替换引用。
线程安全:这里的
lock是一个内部对象(final Object lock = new Object();),用于保证写操作的互斥。性能:写操作开销较大(复制数组),适合写少场景。
在JDK8很多jdk代码中使用的仍旧是juc下的ReentrantLock, JDK11已经改为synchronized, 因为JDK对对象锁不断的优化包括锁膨胀 偏向锁等, 而且ReentrantLock本身支持锁的可重入, 相对开销可能更大。
# 删
remove(Object o)
public boolean remove(Object o) {
// 读快照
Object[] snapshot = getArray();
// 查找元素所在索引
int index = indexOfRange(o, snapshot, 0, snapshot.length);
// 存在则删除
return index >= 0 && remove(o, snapshot, index);
}
private boolean remove(Object o, Object[] snapshot, int index) {
// 加锁, 只锁写过程
synchronized (lock) {
Object[] current = getArray();
int len = current.length;
// 检测快照是否过期, 如果在读取后有其它线程写入数据, 需要重新校验避免误删
if (snapshot != current) findIndex: {
int prefix = Math.min(index, len);
for (int i = 0; i < prefix; i++) {
if (current[i] != snapshot[i]
&& Objects.equals(o, current[i])) {
index = i;
break findIndex;
}
}
if (index >= len)
return false;
if (current[index] == o)
break findIndex;
index = indexOfRange(o, current, index, len);
if (index < 0)
return false;
}
// 创建新数组移除指定元素
Object[] newElements = new Object[len - 1];
System.arraycopy(current, 0, newElements, 0, index);
System.arraycopy(current, index + 1,
newElements, index,
len - index - 1);
setArray(newElements);
return true;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
读取快照(array) 利用
getArray()获取当前内部数组,称为快照(snapshot)。这是一个线程安全的数组副本。查找元素所在索引 在快照中查找目标元素的位置
index,如果找不到就返回false。真正的删除 交由私有方法
remove(...)处理,传入快照和索引。snapshot != current
这个是写时复制核心设计的补充机制,用于 防止 ABA 问题 或 错删其他线程已修改的数据。
例如:
- 你在快照中发现 index = 3 是
"itemX" - 但此时另外一个线程在 index = 3 插入了
"itemY",你如果用旧快照直接删就会误删"itemY"
所以:写操作必须在加锁后再次校验当前状态的合法性
- 你在快照中发现 index = 3 是
# 改
set(int index, E element)
public E set(int index, E element) {
synchronized (lock) {
Object[] es = getArray();
E oldValue = elementAt(es, index);
if (oldValue != element) {
es = es.clone();
es[index] = element;
setArray(es);
}
return oldValue;
}
}
static <E> E elementAt(Object[] a, int index) {
return (E) a[index];
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 根据索引去查找对应元素
- 进行比较, 如果和插入元素相同则不进行修改操作, 如果不同同样采用写时复制的方法进行updte
# 查
get(int index)
public E get(int index) {
return get(getArray(), index);
}
private static <E> E get(Object[] a, int index) {
return (E) a[index];
}
2
3
4
5
6
7
8
- 读不加锁
# 四、常见问题
# 为什么适合“读多写少”的场景?
- 每次写操作(
add、remove、set)都要复制整个数组,时间复杂度为 O(n),开销较大。 - 但读操作无锁,直接读取数组,性能极高,且不会抛出
ConcurrentModificationException。
因此如果写操作频繁,会频繁触发复制,不仅性能差,还会导致频繁GC,造成系统抖动。
# 如何避免内存泄漏问题?
写时复制会创建新数组,而旧数组会交由GC回收。频繁写操作(尤其在大集合场景)会导致大量临时数组创建,若GC跟不上可能引起内存泄漏或OOM。
解决建议:
- 控制集合大小
- 控制写操作频率
- 写少读多才考虑用
CopyOnWriteArrayList
# 是否适用于高并发写的场景?
不适合。
因为每次写都要复制整个数组,即使加锁了也无法抵挡大量写操作导致的性能瓶颈,此时更适合使用 Collections.synchronizedList 或 ConcurrentLinkedQueue 等。
# 为什么读操作不用加锁却不会读到中间状态?
因为:
- 写操作是一次性替换整个数组引用(原子操作),并不会对原数组就地修改。
array字段是volatile,保证写入对其它线程可见。- 所以不会出现“读到一半的写入结果”这种情况。
# 为什么写操作需要“快照一致性检查”?
为了避免因读-写时序差异造成的数据不一致。
例如:
- 线程A在快照中找到 index = 2 准备删除
- 但线程B已经修改了 index = 2 的内容
- 如果不再次校验,A可能会误删不该删的元素
所以 remove() 会在加锁后再次判断快照与当前数组是否一致,防止ABA问题。