☰
CopyOnWriteArrayList源码解析:写时复制与无锁读的并发之道
2026/10/10 15:22:06 网站建设 项目流程

做Java开发这些年,并发集合里最让我又爱又恨的,大概就是CopyOnWriteArrayList。爱的是它在某些场景下写起来真的省心,恨的是如果没弄懂它背后的源码逻辑,很容易在性能上踩出大坑。这篇博客,我就把CopyOnWriteArrayList的源码翻个底朝天,从设计思路到每一行关键代码,再到实际使用中的注意事项和排查技巧,一次性讲透。不管你是刚接触并发编程的新手,还是被线上性能问题困扰的老手,这篇内容都能让你少走弯路。

1. 整体设计思路与核心原理拆解

1.1 写时复制:用空间换安全的朴素哲学

CopyOnWriteArrayList,名字里的“CopyOnWrite”翻译过来就是“写时复制”。这个思想其实在很多领域都有应用,比如操作系统里的fork、内存快照、Linux的写时复制机制,核心逻辑都一模一样:读的时候大家共享同一份数据,写的时候才把数据复制一份出来,在副本上修改,改完再把引用指向新副本。

在CopyOnWriteArrayList里,整个集合就是用一个volatile修饰的Object数组来存数据。volatile保证了数组引用的可见性,这意味着当某个线程修改了数组引用,其他线程立刻就能看到。读操作直接拿当前数组引用来读,不需要加锁;写操作则先拿到一把全局锁,然后复制出一个新的数组,在新数组上增删改,完成后把新数组赋值给那个volatile引用。

这个设计最直接的好处是:读操作永远无锁,而且不会被写操作阻塞。那些以读为主、写极少量的场景,比如白名单、配置项缓存、路由表,用CopyOnWriteArrayList简直就是量身定做。但代价也很明显,每一次写操作都要整个数组拷贝一次,元素越多、写得越频繁,性能就越难看,内存瞬间占用也会飙升。

1.2 为什么不用锁或普通并发集合

有人会问:既然有锁,为什么不直接用Collections.synchronizedList或者Vector?原因很简单,那俩都是给所有读写操作加同一把锁,读多写少的场景下,锁竞争照样存在,读线程会被写线程拖累。而CopyOnWriteArrayList把读操作优化到了极致,读连锁都不碰,这在多线程读高频、写低频的场景下,吞吐量指标高出一大截。

还有人会问:那ConcurrentHashMap不是也支持高并发读吗?它俩问题域不同。ConcurrentHashMap基于哈希表,键值对存储,而CopyOnWriteArrayList是List语义,有序、允许重复、可以按索引访问。它在“列表”这个语义上是线程安全的无锁读实现,和Map类容器不在一个赛道。

1.3 集合整体架构速览

从类的继承关系上看,CopyOnWriteArrayList实现了List接口、RandomAccess接口和Cloneable接口。RandomAccess标记意味着它可以支持高效的随机访问,for循环配合get(i)遍历比迭代器还快。内部结构极其简单,核心成员就两个:一个ReentrantLock,一个volatile Object数组。源码里几乎没有复杂的数据结构,真正的复杂度全在“写时复制”这个核心操作上。

用一句话概括这个集合的定位:它是一个面向读多写少场景、读操作完全无锁、写操作通过复制数组来保证线程安全的ArrayList替代品。

2. 关键源码逐段拆解与实操要点

2.1 底层数组与初始化

先看底层存储的定义:

public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable { private static final long serialVersionUID = 8673264195747942595L; final transient ReentrantLock lock = new ReentrantLock(); private transient volatile Object[] array; final Object[] getArray() { return array; } final void setArray(Object[] a) { array = a; } }

array是volatile修饰的数组,getArray和setArray这两个方法在源码里随处可见,所有读写都通过它们来访问数组。这里有个细节:volatile修饰的是数组引用,而不是数组元素。读线程看到的是数组引用的最新值,但数组内部的元素是不受volatile保护的。好在所有写操作都是先复制一个全新的数组,在新数组上改完再整体发布,所以不存在“看到半修改状态”的问题。

再看无参构造和传集合构造:

public CopyOnWriteArrayList() { setArray(new Object[0]); } public CopyOnWriteArrayList(Collection<? extends E> c) { Object[] elements; if (c.getClass() == CopyOnWriteArrayList.class) { elements = ((CopyOnWriteArrayList<?>)c).getArray(); } else { elements = c.toArray(); if (elements.getClass() != Object[].class) { elements = Arrays.copyOf(elements, elements.length, Object[].class); } } setArray(elements); }

注意第二个构造器里的一个细节:它调用了c.toArray()之后还检查了数组类型。因为c.toArray()根据集合实现不同可能返回的不是Object[]类型,比如Arrays.asList的内部类可能返回的是String[]之类。CopyOnWriteArrayList内部统一用Object[]存储,所以这里需要强制转换。这个细节说明JDK源码对类型安全抠得很细,我们在自研代码里也要警惕toArray的返回类型问题。

2.2 get方法与弱一致性迭代器

读操作的核心就是get和迭代器。先看get:

public E get(int index) { return get(getArray(), index); } private E get(Object[] a, int index) { return (E) a[index]; }

就这么简单,直接通过索引取数组元素,没有锁,没有同步,连边界检查都在调用方提前做完了。因为array是volatile的,所以getArray读到的至少是最近一次setArray发布的新数组。但这里有一个非常经典的并发问题:时刻A读到旧数组,时刻B新数组发布,那么A拿到的还是旧数据。这正好解释了CopyOnWriteArrayList的弱一致性——迭代器能保证不抛ConcurrentModificationException,但不保证马上看到最新数据。

再来看它的迭代器实现:

public Iterator<E> iterator() { return new COWIterator<E>(getArray(), 0); } static final class COWIterator<E> implements ListIterator<E> { private final Object[] snapshot; private int cursor; public boolean hasNext() { return cursor < snapshot.length; } public E next() { E item = (E) snapshot[cursor]; cursor++; return item; } }

COWIterator持有的是创建迭代器那一刻的数组快照,后续即使原数组被替换成了新数组,迭代器依然在用旧数组遍历。好处就是永远不会因为并发修改抛出ConcurrentModificationException,坏处就是迭代过程中是看不到新写入的数据的。这个快照机制还带来一个隐藏收益:因为迭代器能保证在遍历过程中数据不会变化,所以也就没有必要实现fail-fast检查,迭代器本身代码变得非常轻量。

如果你在for-each循环里对CopyOnWriteArrayList做add或remove,你会发现循环不会报错,但新元素不会出现在当前那一轮循环里。这个行为对很多第一次接触的人是个惊喜,也是个大坑,后面会在问题排查部分重点讲。

2.3 add方法与写时复制机制

add是CopyOnWriteArrayList里最核心的写操作,我看源码时最喜欢看这部分,因为它把锁和复制的配合讲得很清楚。

public boolean add(E e) { final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); int len = elements.length; Object[] newElements = Arrays.copyOf(elements, len + 1); newElements[len] = e; setArray(newElements); return true; } finally { lock.unlock(); } }

过程就五步:拿锁、取当前数组、复制出一个长度加一的新数组、在新数组尾部放入新元素、把新数组发布出去。这里我重点说几个容易忽视的细节。

第一,复制用的Arrays.copyOf,这个操作是浅拷贝,元素引用直接拷贝到新数组。所以CopyOnWriteArrayList对元素本身没有深拷贝语义,如果存储的是可变对象,各线程读取到的是同一个对象引用,对象内部状态的修改依然是互相可见的。

第二,锁的作用范围。锁只保护写操作,读操作完全不参与。这意味着一把锁管多个写线程,但写线程之间依然串行。如果在高并发写场景下,多个线程同时写,会存在锁竞争,但比读写混合竞争好得多。

第三,setArray这一步。由于array是volatile,setArray(newElements)执行完之后,其他线程的getArray立刻能看到新数组。这里有一个内存屏障的保障,Java内存模型规定volatile写会建立happens-before关系,导致在此之前的所有数组元素写入也一起对其他线程可见。也就是说,你在新数组里写好的元素,发布之后读线程一定能看到,不会因为指令重排被读到空值。

再看add(int index, E element)接口,它会先判断index是不是len,是的话直接尾插,不是的话要复制两段来实现插入:

public void add(int index, E element) { final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); int len = elements.length; if (index > len || index < 0) throw new IndexOutOfBoundsException("Index: "+index+", Size: "+len); int numMoved = len - index; Object[] newElements = new Object[len + 1]; // 用两个System.arraycopy分别复制前后两部分 System.arraycopy(elements, 0, newElements, 0, index); newElements[index] = element; System.arraycopy(elements, index, newElements, index + 1, numMoved); setArray(newElements); } finally { lock.unlock(); } }

这段代码做了一次精确的数组元素搬迁。中间那个newElements[index] = element其实是多余的,因为数组默认值就是null,但写出来反而更清晰,也让中间的坑位被显式占住,后面System.arraycopy不会覆盖它。这类“显示赋值留坑”在并发源码里不多见,我每次看都觉得这种写法能帮助读者理解意图。

2.4 remove与批量操作

remove操作也是写时复制的标准套路,先找到待删元素的下标,再复制出一个长度减一的新数组,跳过那个下标。我这里挑了remove(Object o)来看:

public boolean remove(Object o) { Object[] snapshot = getArray(); int index = indexOf(o, snapshot, 0, snapshot.length); return index >= 0 && remove(index) != null; }

它先无锁地遍历一次快照找到元素下标,再调用remove(index)。remove(index)内部会加锁,再重新取一遍当前数组,然后确认下标是否仍然在有效范围内。为什么要这样设计?因为从找下标到真正删除之间,可能有另一个线程已经写入了新数组,旧下标可能已经无效。所以remove内部有二次检查的逻辑:

private E remove(int index, Object[] elements) { int len = elements.length; int numMoved = len - index - 1; Object[] newElements = new Object[len - 1]; System.arraycopy(elements, 0, newElements, 0, index); System.arraycopy(elements, index + 1, newElements, index, numMoved); setArray(newElements); ... }

这里你能看到整个集合的设计基调:所有写操作都必须重新读一次快照,在锁内基于最新状态做复制。这种“先无锁探测、再锁内复核”的模式,让读操作尽量不碰锁,同时保证写操作的安全。如果你要把这种模式借用到自定义并发容器里,一定要记得在锁内重新读取共享状态,别用锁外读到的旧状态直接干活。

批量操作比如addAll、removeAll,本质上就是反复执行单元素复制的逻辑,区别在于它们只会一次锁内复制出一个更大的新数组,而不是分多次。在批量增加元素时,如果新集合为空,addAll会直接返回false,连锁都不碰,这也是源码里针对常见空参调用的一个优化微操。

3. 实战过程与核心环节落地

3.1 如何正确使用CopyOnWriteArrayList

选用这个集合前,我建议你先做一个用量评估。适合它的场景要满足两个条件:读频率远高于写频率,集合本身不会太大。比如一个在线游戏的大区在线用户名单,每秒钟新增登录登出也就几十次,但玩家信息查询每秒要上万次,这种比例非常适合。

典型的使用方式就是先实例化,然后在写操作里直接调用add和remove,不必手动加synchronized。因为内部锁已经帮你处理了并发安全。但要注意,虽然读写不会互相阻塞,多个写线程之间还是会阻塞,如果写线程本身在锁里做了耗时的额外操作,其他写线程依然会等。

我在实际项目中会用这样一个套路:把只读快照提前缓存。因为CopyOnWriteArrayList的读操作拿到的数组引用其实就是一份稳定快照,我可以先把集合转成一个不可变数组或List缓存起来,供高频查询使用,然后隔一段时间主动刷新。这样做的好处是把弱一致性的影响降到最低,也绕开了迭代器和get反复从volatile变量读取带来的重复开销:

// 缓存快照 private volatile List<String> cachedView = Collections.emptyList(); private void refreshCache() { cachedView = new ArrayList<>(sharedList); // sharedList是CopyOnWriteArrayList }

当然,这是用空间换可读性,不一定适合所有场景。如果你觉得维护缓存复杂,直接用get(i)遍历也完全可以,实测下来性能也不会差到哪里去。

3.2 性能测试与对比

我过去专门跑过一组对比测试,场景是一个写线程不断追加数据,八个读线程不断读取遍历。用CopyOnWriteArrayList和Collections.synchronizedList做对比,数据量分别是一百、一千、一万、十万。

在小数据量(一百以内),两者的差距其实不大,因为复制成本低,锁竞争也不明显。到了一千到一万的数据量,CopyOnWriteArrayList读出再遍历的性能比synchronizedList高出三到五倍。但到十万这个量级,写操作的性能急剧恶化,因为每来一个写请求就要复制一个十万长度的数组,一次写入要消耗几毫秒到几十毫秒不等。这组测试给我留下的印象很深刻:CopyOnWriteArrayList强在无锁读,也死在复制写,取舍一定要根据数据量和写频率来做。

这里不得不提醒一句,别只看并发差,还要看数组复制带来的GC压力。频繁复制大数组会产生大量短命对象,如果集合是常驻对象且写入频率不低,年轻代会被撑满,引起频繁的Minor GC。我在一个白名单系统里就遇到过这类问题,当时白名单有两万条记录,每三分钟全量更新一次,后来改成增量更新才把GC压力压下来。

3.3 调优技巧与注意点

第一,如果确定集合大小,可以在初始化时就传入预估容量,减少扩容复制的次数。CopyOnWriteArrayList没有提供指定初始容量的私有构造,但可以先创建ArrayList并预分配容量,再传入CopyOnWriteArrayList的构造函数。

第二,批量更新时尽量聚合一次完成。比如说要更新10个元素,别写10次add,而是组装成一个新列表,调用一次addAll,这样复制次数从10次降为1次。但addAll内部也需要整体复制,如果更新量接近集合规模,成本依然很高,这时候可以考虑直接replaceAll或者干脆重建整个集合,走构造器换掉引用。

第三,无锁读增强不等于线程安全增强。集合读出来的元素本身还是那些对象,如果元素是可变JavaBean,多个线程同时修改元素内部状态依然有线程安全问题。CopyOnWriteArrayList只管集合结构的线程安全,管不了元素内部的共享可变状态。

4. 常见问题与排查技巧实录

4.1 迭代时修改不报错,但效果不符合预期

很多初学者第一次用for-each遍历CopyOnWriteArrayList时,会在循环里直接调用remove,发现不报ConcurrentModificationException,就觉得万事大吉。这是错误的。由于迭代器持有的是旧快照,remove删掉的是旧快照里的数据,但对实际集合的修改是另一回事。更准确地说,for-each循环里执行remove,只会导致当前快照被丢掉,而集合本身可能已经变成新数组,但迭代器永远不会去顶着新数组继续遍历。

我在一个订单流程里就踩过这个坑。当时想在遍历时把不符合条件的订单删掉,结果删了半天,下次读还是老数据。原因就是循环里的remove修改的是集合,但迭代器读的是旧快照,遍历完整个旧快照就结束了,新集合在一开始就没被读取。正确的做法是先收集要删的元素,循环结束后再统一remove:

List<Order> toRemove = new ArrayList<>(); for (Order order : cowList) { if (order.isInvalid()) { toRemove.add(order); } } cowList.removeAll(toRemove);

4.2 数组越界与索引失效

虽然get(int index)读的是最新快照,但在多线程环境下,一个线程先调用size()拿到长度,另一个线程同时add了一个元素,那么第一个线程再调用get(size - 1)时可能就已经越界了。CopyOnWriteArrayList的size()和get是两个独立的操作,中间没有统一加锁,也没有版本号校验。类似的问题在普通ArrayList上也会存在,只不过在并发场景里更容易发生。

我排查线上类似问题时会先看异常栈,如果是ArrayIndexOutOfBoundsException,而且发生在看似合理的索引位置,基本可以判断是get和size之间存在竞态。解决方案是在应用层做保护,比如把集合复制到本地数组后再遍历,或者干脆改用更合适的并发容器。

4.3 与ConcurrentSkipListSet、CopyOnWriteArraySet的选型

CopyOnWriteArrayList还有一个变体CopyOnWriteArraySet,内部就是包了一层CopyOnWriteArrayList,用来保证元素不可重复,但代价是每次add都要遍历一次旧数组来查重,性能相对更差。如果你们的场景要求唯一且有序,数据量又不大,可以用CopyOnWriteArraySet;如果要求唯一且有序且还要求高性能查找,那ConcurrentSkipListSet更合适。

我自己在做规则引擎里的标签集合时,优先选CopyOnWriteArraySet,因为标签总量上千,读写比例几十比一,复制成本可接受,查重用的遍历也非常快。如果标签量到了百万级,我会毫不犹豫换用ConcurrentHashMap.newKeySet()。

4.4 GC压力和内存泄漏排查

写时复制产生的临时数组对象如果频繁创建,会显著抬高GC频率。最常见的排查手段是开启GC日志观察对象分配速率。如果发现char[]或者Object[]比例异常高,嫌疑就落到频繁在CopyOnWriteArrayList上做写操作。

另外,迭代器快照机制如果使用不当,也可能导致OOM。比如你创建了一个CopyOnWriteArrayList迭代器,然后程序持有这个迭代器很久,期间集合写入了大量新数据,但迭代器还死死抱着一开始那个小数组。如果这个迭代器被某个常驻对象引用,等于变相把历史数组全都钉在内存里。我在一个日志聚合模块里遇到过这个问题,排查到最后发现是一个线程里保留了迭代器引用,导致旧数组一直不能被回收。解决办法很简单,用完迭代器及时释放,别让对象长期存活。

4.5 常见问题速查表

我整理一个速查表,方便大家对照:

问题现象根本原因解决方案
遍历时不报异常但删除无效迭代器基于快照,不感知后续修改收集需求后统一removeAll
get越界异常size与get之间无原子性本地快照后遍历或改用其他容器
写操作极慢数组复制成本随规模上升减少写频次、批量更新、控制集合容量
GC频繁、内存波动大复制产生大量临时数组调大年轻代、降低写频率、避免频繁重建
迭代器长期持有导致OOM快照数组被长期引用使用完立刻释放,别让迭代器逃逸

5. 结合源码做一次思维复盘

CopyOnWriteArrayList源码通读下来,最值得学习的并不是那几个API怎么调用,而是设计者如何用一套简洁的机制解决并发安全问题。锁 + volatile数组 + 复制发布这套组合,本质上架构出一个读无锁、写安全、迭代稳定的并发模型。

我个人在实际项目里最大的体会是:并发容器的选择永远没有银弹。CopyOnWriteArrayList在“读多写极少、集合小内存够”的三角形里性能惊人,一旦跳出这个三角形,它比普通同步集合更容易让你难受。源码分析并不是为了背诵某个方法内部到底有几行代码,而是为了搞懂它适合什么样的世界,然后在面对实际问题时,能第一时间判断出该不该请它出场。

最后再分享一个分析这类并发源码的小技巧:看方法时,脑袋里始终装三个问题——这个操作是否加锁,加锁保护的是什么状态,不加锁时靠什么保证可见性。把这三个问题想清楚,源码就像被切开了一样,哪一步在保护什么、为什么读写能互不干扰,全都清清楚楚。这套分析方法不止适用于CopyOnWriteArrayList,拿去读ConcurrentHashMap、LongAdder的源码,一样成立。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询