# 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
1
2
父类/接口 作用 责任说明
RandomAccess 接口能力 支持快速随机访问
List 行为规范 定义有序可重复集合的基本操作,如 addgetremove
Cloneable 接口能力 支持对象浅拷贝,通过 clone() 方法实现
Serializable 接口能力 支持对象序列化,可跨 JVM 传输或持久化存储

# 二、底层数据结构解析

# 核心字段

private transient volatile Object[] array;
1
  • array:是 CopyOnWriteArrayList 唯一的数据存储结构,类型是 Object[]
  • volatile 保证了对不同线程的 可见性,即线程修改了 array,其他线程能立刻看到更新后的数组。

# 核心机制

写时复制(Copy-On-Write), 什么是写时复制?

每次修改数据(比如 add、remove、set)时,都不直接修改原数组,而是:

  1. 复制出一个新数组(原数组的副本)
  2. 在副本上修改
  3. 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;
    }
}
1
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;
    }
}
1
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"

    所以:写操作必须在加锁后再次校验当前状态的合法性

#

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];
}
1
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];
}

1
2
3
4
5
6
7
8
  • 读不加锁

# 四、常见问题

# 为什么适合“读多写少”的场景?

  • 每次写操作(addremoveset)都要复制整个数组,时间复杂度为 O(n),开销较大。
  • 但读操作无锁,直接读取数组,性能极高,且不会抛出 ConcurrentModificationException

因此如果写操作频繁,会频繁触发复制,不仅性能差,还会导致频繁GC,造成系统抖动。

# 如何避免内存泄漏问题?

写时复制会创建新数组,而旧数组会交由GC回收。频繁写操作(尤其在大集合场景)会导致大量临时数组创建,若GC跟不上可能引起内存泄漏或OOM

解决建议:

  • 控制集合大小
  • 控制写操作频率
  • 写少读多才考虑用 CopyOnWriteArrayList

# 是否适用于高并发写的场景?

不适合。

因为每次写都要复制整个数组,即使加锁了也无法抵挡大量写操作导致的性能瓶颈,此时更适合使用 Collections.synchronizedListConcurrentLinkedQueue 等。

# 为什么读操作不用加锁却不会读到中间状态?

因为:

  • 写操作是一次性替换整个数组引用(原子操作),并不会对原数组就地修改。
  • array 字段是 volatile,保证写入对其它线程可见
  • 所以不会出现“读到一半的写入结果”这种情况。

# 为什么写操作需要“快照一致性检查”?

为了避免因读-写时序差异造成的数据不一致。

例如:

  • 线程A在快照中找到 index = 2 准备删除
  • 但线程B已经修改了 index = 2 的内容
  • 如果不再次校验,A可能会误删不该删的元素

所以 remove() 会在加锁后再次判断快照与当前数组是否一致,防止ABA问题