如果你在并发环境里用过ArrayList大概率被ConcurrentModificationException教育过一边遍历一边改数据轻则抛异常重则读到脏数据。很多人第一反应是加锁加synchronized关键字或者直接套一层Collections.synchronizedList结果锁粒度太粗读多写少的场景直接被拖成串行性能很难看。CopyOnWriteArrayList这个类就是为了解决这类尴尬而生的它把读写分离的思想用“写时复制”的方式搬进了JUC集合让并发读稳定访问一个旧数组快照写操作则在新数组上完成后一次性发布。这篇文章适合正在准备并发框架源码面试、或者在项目里纠结并发集合选型的人我会从原理到源码再到真实场景把这个类彻底拆开讲清楚。1. 并发集合里的尴尬为什么需要一个CopyOnWriteArrayList1.1 ArrayList在并发读写时的“翻车现场”先把最基础的问题摆出来。ArrayList本身不是线程安全的这个大家都知道但“不是线程安全”到底意味着什么很多人以为只是读的时候可能读到旧值其实不止于此。它内部维护了一个Object[] elementData还有一个modCount字段专门记录结构性修改次数。当迭代器创建时会记住当时的modCount之后每次hasNext或next都会检查这个值是否变了变了就立刻抛出ConcurrentModificationException。这就是fail-fast机制。看下面的代码两个线程共用一个ArrayList一个线程不断add另一个线程用增强for遍历运行几轮之后大概率会在第二个线程里看到异常ListInteger list new ArrayList(); Thread writer new Thread(() - { for (int i 0; i 100000; i) { list.add(i); } }); Thread reader new Thread(() - { for (Integer v : list) { // do nothing } }); writer.start(); reader.start();这还只是结构修改导致的遍历问题。更隐蔽的是ArrayList的扩容和赋值操作并不是原子的。比如两个线程同时add可能导致elementData数组的同一位置被覆盖或者size被错误更新甚至数组越界。真实的并发环境里这种问题一旦出现排查起来非常费劲。所以并发场景下直接裸用ArrayList基本是在埋雷。那用Vector呢Vector把几乎所有方法都用synchronized修饰了安全性确实有保障但性能也难堪。因为每个读操作都要抢同一把锁。读操作明明不修改数据却也要和其他读操作互相阻塞这在高并发读的场景下属于典型的锁竞争浪费。1.2 加锁方案的代价与写时复制的救场换个思路如果不想让读操作去抢锁能不能做到读操作完全无锁能。核心就一句话把读操作锁定的目标变成一个永远不会变的老数组写操作在副本上改完了再让读操作看到新数组。这就是CopyOnWriteArrayList做的事情。它的全名Call-On-Write已经揭示了核心思想任何add、remove、set操作都不会直接修改当前数组而是先复制出一个新数组在新数组上执行修改再把内部指向数组的引用切换到新数组。读操作始终只碰那个引用当前指向的数组而这个数组在切换前是稳定的所以读不需要加锁。这个设计完美绕开了fail-fast问题。迭代器在一个数组快照上工作即使另一个线程修改了List迭代器持有的仍然是创建时的旧数组自然也不会抛ConcurrentModificationException。这是和ArrayList最大的区别之一。我在项目中第一次接触它时并没有马上get到这套设计的精妙后来手动画了一遍数组引用变化图才明白写时复制本质上是把“多线程并发访问同一个可变对象”的问题转换成了“多线程并发访问多个不可变快照”的问题。这个思路在很多底层组件里都能看到比如CopyOnWriteArrayList的“近亲”CopyOnWriteArraySet以及一些日志框架、配置中心里的快照缓存。2. 读写分离思想CopyOnWriteArrayList的核心设计2.1 写时复制到底是怎么实现的JDK里CopyOnWriteArrayList的成员变量非常少核心就两个一个可重入锁一个用volatile修饰的数组引用。注意这个volatile非常关键它保证了当写线程把新数组引用赋值给array字段时其他线程立刻可见。字段定义大致是这样的final transient ReentrantLock lock new ReentrantLock(); private transient volatile Object[] array;当调用add方法时并不是简单地在原数组后面加一个元素而是获取lock读取当前array引用保存为旧的数组创建一个长度比旧数组长1的新数组把旧数组的全部元素拷贝到新数组在新数组末尾放入新元素把array引用指向新数组释放lock。整个过程里旧数组除了被读线程引用之外不会被任何写操作再修改。读线程访问的数组要么是“变更前”的旧数组要么是“变更后”的新数组但绝不会看到一个写到一半的中间状态。因为数组元素拷贝和引用发布之间是有先后次序的只要引用赋值一旦发生新数组里的内容就已经完全准备好了。这里有一点需要特别注意锁只保护写操作读操作不需要锁。这也正是读写分离的体现。在ArrayList时代读写都操作同一个内部数组在CopyOnWriteArrayList时代读和写操作被迫分开面向不同代际的数组读永远面对一个不可变的快照写永远在另一个副本上推进。2.2 最终一致性读到的不一定是最新的说到这里很多人会问一个很尖锐的问题读线程拿到旧数组那它读到的数据是不是旧数据确实是。CopyOnWriteArrayList提供的并不是严格的强一致性而是最终一致性。举个例子假设当前数组是[1, 2, 3]线程A正在遍历这个快照此时线程B调用add(4)它复制出[1, 2, 3, 4]并让array指向新数组。线程A手里拿到的还是老数组[1, 2, 3]所以它这次遍历永远看不到4。只有下一次遍历线程A才会去读array字段然后看到最新数组。这个特性在大部分业务场景下是可以接受的。比如一个黑名单列表某个请求黑名单更新后已经发出去的、正在判断的逻辑如果看到旧列表最多就是晚几毫秒生效下一次判断就会正确。但如果业务场景要求“我必须在写入后立刻读到这个新数据”比如库存扣减、订单状态变更那就不能用CopyOnWriteArrayList。千万别拿它当强一致缓存用。volatile在这里起到的作用是保证“新数组发布”对其他线程可见。这种发布方式满足safe publication读线程不会看到部分构造的对象或半初始化的数组。不过volatile解决不了语义上的最终一致问题它是“可见性保证”而不是“一致性保证”。这是两个层面的事情面试里很多人会混淆。2.3 从类结构看设计意图可以从源码顶层布局看出设计意图。CopyOnWriteArrayList实现了List接口所以它本质上还是一个有序、可重复的List而不是Set。它还有一个内部类COWIterator专门用来做快照遍历。内部字段虽然是volatile数组但所有写方法都会通过lock串行化。这样做的另一个好处是即使多个线程同时写最终的结果也是确定的不会像ArrayList那样出现两个线程同时add然后把数据写丢的问题。为什么不用读写锁而不是写时复制这个确实值得想。ReentrantReadWriteLock也能实现读读并发、读写互斥而且不需要复制数组那为什么还要忍受写时复制的内存开销关键在迭代器。读写锁只能保证单个方法内的线程安全但一个迭代器遍历List的过程是多条get调用组合起来的如果另一个线程在遍历中途remove掉某个元素遍历逻辑还是会踩到并发修改。CopyOnWriteArrayList的快照迭代器从根上消除了这个问题。它选择的不是用锁去强制互斥而是用复制让两个线程各玩各的互不干扰。3. 源码级拆解add、get、remove、迭代器都在做什么3.1 add方法加锁、复制、赋值、发布直接看JDK 8里的add方法实现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(); } }代码非常简单但每一步都有讲究。先getArray拿到当前数组引用然后Arrays.copyOf会新建一个长度加1的数组并拷贝所有元素最后setArray其实就是把新的数组赋值给array字段。为什么这里需要加锁因为如果两个线程同时执行copyOf两个线程都会基于同一个旧数组做复制然后各自setArray后一个setArray会把前一个的修改覆盖掉那个被覆盖的线程里新加的元素就丢了。所以写操作必须串行。还有一点如果是在ArrayList的add末尾插入底层数组长度不够时才会扩容扩容后通常会有多余容量。而CopyOnWriteArrayList每次add都会精确扩容到len1。这意味着每次写操作都完整复制一次绝无复用多余容量的说法这就是它写代价高的根源。3.2 get方法无锁读的底气在哪get方法就更简单了public E get(int index) { return get(getArray(), index); } private E get(Object[] a, int index) { return (E) a[index]; }没有synchronized没有lock就是一次普通的数组下标访问。为什么这样安全因为array是volatile的读线程每次调用getArray都会拿到当前最新的数组引用。而数组本身在发布之后就不会再被修改所以读线程可以放心地按索引访问。要特别强调在写线程复制数组的过程中读线程完全不受影响。它不会因为数组扩容就读到下标越界因为读到的数组引用要么是旧的长度n的数组要么是新的长度n1的数组而索引范围都在合理区间内。这就是快照读的底气。不过有个小细节如果get的index是负数a[index]会抛ArrayIndexOutOfBoundsException但这是正常的毕竟List本来就不允许负数索引。3.3 remove和set同样的复制策略remove方法也并不是在原数组上直接删除元素而是把除了被删除位置之外的元素拷贝到新数组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; 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(); } }这段代码展示了为什么写时复制需要系统级拷贝。System.arraycopy是native方法利用系统内存拷贝能力会比Java层循环快但仍然逃不过O(n)的复杂度。如果删除的位置接近数组头部numMoved会很大后半段整个要往前搬如果删除位置靠近末尾搬移的数据量小一点但依然至少复制了一次整个数组。set方法也是同样逻辑复制一个新数组把指定位置替换成新值然后setArray。注意set方法会返回旧值并且会在取旧值时就进行越界检查。看到这里你应该意识到凡是有写操作代价都是随数组长度线性增长的。所以CopyOnWriteArrayList只适合那种数组规模不大、写频率极低的场景比如最多几百个元素的白名单。3.4 迭代器COWIterator和弱一致性CopyOnWriteArrayList的迭代器实现非常有意思它内部只持有一个数组快照和游标private class COWIteratorE implements ListIteratorE { private final Object[] snapshot; private int cursor; ... }创建迭代器时会拿到当时的array引用作为snapshot之后所有遍历都在snapshot上进行。snapshot一旦被迭代器持有就不再受后续写操作影响。所以迭代器不会抛ConcurrentModificationException也不需要额外加锁。它遍历到的集合内容只代表“迭代器创建那一刻”的数据快照。这就是弱一致性迭代器。弱一致性有两个方向的影响。好的一面是它可以安全地在一个线程遍历时由另一个线程增删元素不会破坏遍历结构。坏的一面是它读不到遍历期间发生的新修改如果业务逻辑基于“遍历时能实时看到最新数据”那就会踩坑。如果你想在遍历的同时删除匹配元素普通ArrayList会抛ConcurrentModificationException但CopyOnWriteArrayList的iterator本身是快照调用iterator.remove()会直接抛UnsupportedOperationException因为它不能修改snapshot。这也是很多新手容易踩的点。4. 使用场景与性能边界4.1 适合什么读多写少、监听器列表、配置缓存CopyOnWriteArrayList最典型的应用场景就是事件监听器列表。比如某个框架里维护了一份Listener数组业务会在启动时注册少量监听器运行期间极少数情况下才会新增或移除监听器但每次事件发生时需要遍历这些监听器并逐个通知遍历频率极高。这个时候用CopyOnWriteArrayList非常合适因为写少读多写时复制的成本完全可以接受而遍历时无锁、不抛异常的特性又能让事件分发流程保持简洁。类似的还有白名单配置、路由表、灰度规则集。机器配置变更频率很低但请求量可能每秒几万次读操作才需要极致的速度。这类场景里CopyOnWriteArrayList比Collections.synchronizedList有明显优势。我习惯把它当成一个“发布后基本不变”的只读容器来用。初始化时一次性add很多元素之后并发读只在特殊情况下更新。这种情况下它几乎是完美的。4.2 不适合什么频繁写入、大列表、强一致要求如果写入频繁每次add/remove都要复制全量数组那么CopyOnWriteArrayList性能会非常糟糕。举个例子一个list里有10000个元素每秒需要执行100次add那就意味着每秒要复制约100万次元素引用同时产生100个新数组GC压力很大。这种场景下使用ConcurrentLinkedQueue、ConcurrentSkipListSet或者加锁的ArrayList反而更合适。大列表同样要谨慎。如果列表里面有百万级数据一次add就要复制百万个引用即使只是引用内存带宽也会被瞬间打爆。数组越大写延迟就越明显直接让你服务RT飙升。还要强调一点它不提供快照隔离之外的一致性。如果你的业务要求“写完立刻读到自己写的数据”比如用这个List做令牌桶、做会话记录那最终一致性会让你在并发测试时看到不符合预期的现象。这类需求请用ConcurrentHashMap或显式加锁方案。4.3 和其他并发集合的对比很多人喜欢把CopyOnWriteArrayList和Vector、Collections.synchronizedList、ConcurrentLinkedQueue放在一起比较我整理成一张表方便判断集合类锁策略迭代器一致性适合场景CopyOnWriteArrayList写加锁读无锁弱一致快照读多写少list语义Vector方法级synchronized快照或fail-fast取决于迭代器需要简单线程安全但性能容忍Collections.synchronizedList方法块级synchronizedfail-fast遍历需手动加锁低并发下的互斥访问ConcurrentLinkedQueue无锁CAS弱一致队列语义高并发入队出队ConcurrentHashMap分段/CAS锁弱一致读多写多Map语义选择的关键不在于“谁更快”而在于“数据结构语义是否匹配”。如果你要的是一个有序、可按下标访问的List而且读多写少CopyOnWriteArrayList是正解如果你要的是一个高性能队列那就算CopyOnWriteArrayList迭代再安全也没用应该选ConcurrentLinkedQueue或BlockingQueue。5. 实操中的经验与避坑指南5.1 弱一致性迭代器带来的业务坑先说一个真实发生过的场景。某个配置中心客户端用CopyOnWriteArrayList缓存服务节点地址每次服务发现更新时会add/remove而业务线程不停遍历这个列表选择节点。上线后大家发现偶尔会有请求在一个故障节点上重试原因就是遍历刚开始时拿到的快照里还有旧节点随后服务发现已经把这个节点移除了但当前遍历仍然会用到它。严格来说这不是bug而是弱一致性的预期内表现。但业务不总是能接受这种“预计内的旧数据”。解决思路很简单如果每次遍历都必须使用最新列表就不要在遍历时复用同一个循环而应该在每次取数据前重新获取当前迭代器或者改成每次读取getArray后手动遍历而不是长期持有迭代器对象Object[] snapshot list.toArray(); // 重读快照 for (Object item : snapshot) { // 每次处理都基于最新快照 }这里要注意list.toArray()本身也是复制一份数组甚至比迭代器更浪费但换来了更明确的语义每次操作前取一次最新快照而不是把迭代器对象存起来用很久。5.2 addIfAbsent和contains的“隐藏竞态”CopyOnWriteArrayList有一个addIfAbsent方法它被很多人当作去重神器。它的实现是加锁的并且在锁里面先contains再add所以单次调用是线程安全的。源码大致逻辑是public boolean addIfAbsent(E e) { final ReentrantLock lock this.lock; lock.lock(); try { Object[] elements getArray(); int len elements.length; if (indexOf(e, elements, 0, len) 0) return false; Object[] newElements Arrays.copyOf(elements, len 1); newElements[len] e; setArray(newElements); return true; } finally { lock.unlock(); } }但要注意如果你把流程拆成先调用contains判断再调用add这两个操作之间锁会释放别的线程可能已经加入同一个元素最终你的add还是会重复添加。不要试图自己优化它。单次调用addIfAbsent是安全的多次组合就会打破原子性。另外还有一个细节addIfAbsent虽然能避免重复元素但它是O(n)的contains加O(n)的复制耗时是普通add的两倍写频率高的时候开销更高。如果你的业务并不需要去重语义直接add就够了别用addIfAbsent硬扛。5.3 内存与GC压力写时复制的隐形代价写时复制最直观的问题就是大量对象创建。每一次add都会new一个Object[]即使这个List里只有一个元素。如果列表很大、写很频繁YGC会明显上涨严重时影响整个应用。我遇到过一个案例一个规则引擎把几千条规则放在CopyOnWriteArrayList里每次规则变更时会先remove旧规则再add新规则。本来以为规则变更频率很低但运营后台经常全量刷新一次刷新就是几千次remove和add瞬间创建几千个副本数组GC耗时飙升部分请求出现超时。后来改成了用一个单独的数组引用加volatile发布或者干脆用不可变的List对象做整体替换。思路还是一样的复制一次整表而不是每一行变更都复制。这个教训告诉我CopyOnWriteArrayList适合小步慢走不适合大批量更新。5.4 批量操作真的优化过吗有些朋友听说addAll是批量复制就以为成本低很多。看JDK源码addAll其实也是复制一次但它是把旧数组和新集合一次性合并。比如addAll(Collection)会先确认新集合大小然后创建len newArr.length的数组再做两次System.arraycopy。所以addAll只复制一次比循环调用add省得多。但removeAll、retainAll这些操作并没有类似的高效优化它们是遍历原数组逐个判断是否包含在新集合中再构建一个新集合。每步都需要O(n*m)的判断并且最后还会再次复制到新数组。如果集合很大这个开销会很惊人。所以涉及批量删除时建议考虑用其他数据结构重建例如用ConcurrentHashMap的keySet代替或者在明确不要求List语义时换ConcurrentLinkedQueue。6. 一个压测实验来验证场景适配性6.1 实验设计只讲理论容易飘我这边设计一个简单的并发压测用来对比CopyOnWriteArrayList和Collections.synchronizedList在读多写少场景下的表现。实验目标在固定线程数下模拟80%读、20%写观察两边的吞吐量。这里不需要特别精确的数字重点是趋势。测试中用一个共享List里面先放200个元素作为初始数据。读线程不断执行get写线程不断add/remove。通过CountDownLatch同时放行统计固定时间内完成的读次数和写次数。6.2 测试代码参考代码只做演示你可以根据自己的机器核数调整线程数ExecutorService pool Executors.newFixedThreadPool(8); ListInteger list new CopyOnWriteArrayList(); // 初始化 for (int i 0; i 200; i) list.add(i); CountDownLatch ready new CountDownLatch(1); CyclicBarrier barrier new CyclicBarrier(8); AtomicLong readCount new AtomicLong(); AtomicLong writeCount new AtomicLong(); Runnable readTask () - { try { barrier.await(); ready.await(); while (System.nanoTime() startTime 3000) { list.get(list.size() - 1); readCount.incrementAndGet(); } } catch (Exception e) { } };写线程类似但每次会随机add或remove并计入writeCount。这里没有给出完整可运行的代码因为在不同JDK、不同机器上结果差异很大我只想强调实验思路。6.3 结果分析与使用判断从我自己跑的结果看当读写比在50:1以上时CopyOnWriteArrayList的读吞吐量可以比synchronizedList高出不少因为读没有锁竞争但一旦写比例升到5:1、2:1写时复制的复制开销开始反噬吞吐量迅速下滑甚至比不上同步包装的ArrayList。所以结论不是“谁一定好”而是先确认你的读写比。读占比越高CopyOnWriteArrayList越有价值写占比一旦超过10%就要谨慎评估。这个阈值不需要记死但方向是稳定的写频率越低收益越大。我现在的选择习惯是数据量小于1000、读多写少、需要List语义、且能接受弱一致优先CopyOnWriteArrayList如果写多直接用ConcurrentLinkedQueue或者加锁ArrayList不要为了“看起来线程安全”而硬上。实战中用多了会发现CopyOnWriteArrayList并不是一个能包打天下的类它只是把读写分离和快照思想应用到一个非常细分的场景里。什么时候该用它、什么时候该换比记住它源码里每一行更关键。每次项目里有人问我并发集合选型我都会先说一句话先看读写比例再看数据量最后才选集合工具。顺序反了后面就会踩坑。