CopyOnWriteArrayList源码解析:读多写少场景下的无锁读与写时复制机制

发布时间:2026/10/13 15:59:14
CopyOnWriteArrayList源码解析:读多写少场景下的无锁读与写时复制机制 1. 一次模拟面试引发的复盘读多写少到底该选谁如果说Java并发编程里哪个集合类最适合当面试题的“主角”CopyOnWriteArrayList一定排在前面。我最近在帮团队做模拟面试时问候选人“多线程场景下读多写少的数据列表你会优先选哪个容器”结果一组五个人里三个人条件反射式地说了ConcurrentHashMap一个人犹豫半天说Vector只有一个稍微提到了CopyOnWriteArrayList但问他原理又说不利索。这个比例其实很有代表性——很多人知道这个类存在但不清楚它为什么叫“写时复制”更说不清它适合什么、不适合什么。CopyOnWriteArrayList是java.util.concurrent包下专门为“读多写少”场景设计的线程安全 List。它读操作完全不加锁写操作在底层数组的副本上完成然后通过一次引用替换把修改“发布”给所有读线程。这个设计听起来简单但背后的权衡、代价、边界条件值得掰开揉碎讲清楚。这篇文章不是面试八股文我会从源码实现、性能账本、真实踩坑三个层面把“为什么选它”这件事讲透。2. 写时复制机制拆解从源码看 add / set / remove 的真实流程2.1 add 方法的三步走加锁、复制、切换引用先看CopyOnWriteArrayList最核心的add(E e)方法源码逻辑其实不长但每一步都值得解读public boolean add(E e) { final ReentrantLock lock this.lock; lock.lock(); try { Object[] elements getArray(); // 1. 拿当前数组引用 int len elements.length; Object[] newElements Arrays.copyOf(elements, len 1); // 2. 整体复制到新数组长度1 newElements[len] e; // 3. 在新数组尾部插入元素 setArray(newElements); // 4. 把内部数组引用切换成新数组 return true; } finally { lock.unlock(); } }我在讲这个流程时喜欢用一个比喻你把一份重要文件放在公共桌上大家随时都能翻看不需要登记。现在你要在文件里加一页内容你会怎么做不会直接在原文件上改而是先复印一份完整的在复印件上补充新页然后把桌上的原文件换成复印件。整个过程“读旧文件的人”完全不受影响等文件被换掉的那一刻新来的读者看到的就是新版本。这个比喻对应到源码里就是getArray()只是拿引用没有拷贝Arrays.copyOf才是真正的“复印”这一步会把旧数组里的所有元素搬到新数组newElements[len] e是“补充新内容”setArray(newElements)是“换文件”用 volatile 语义把新数组的引用发布出去。这里有个关键点复制和修改都是在加锁状态下完成的所以同一时刻只有一个写线程在做拷贝和插入其他写线程必须等待。但读线程不碰这把锁它们甚至不知道锁的存在。2.2 set 和 remove 的本质同样的“复制-替换”套路set(int index, E element)和remove(Object o)的底层思路完全一致都是先在锁内复制出一个新数组然后在新数组上修改最后切换引用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); } return oldValue; } finally { lock.unlock(); } }注意set方法里有个优化如果新老值相同oldValue ! element不成立就不复制数组直接返回。这个细节看似不起眼但能避免一部分无谓的内存分配。同理add的时候如果集合本身就很大一次写操作会复制整个数组这也就是CopyOnWriteArrayList“写代价高”的根源。2.3 读操作为什么敢完全不加锁这是面试最常追问的地方。CopyOnWriteArrayList的get(int index)长这样public E get(int index) { return get(getArray(), index); } private E get(Object[] a, int index) { return (E) a[index]; }不加锁的底气来自于两点。第一get操作的只是getArray()拿到的数组引用而setArray是用 volatile 方式写的引用所以读线程要么看到旧数组要么看到新数组绝对不会看到“改了一半的数组”——因为数组一旦构建好就再也不会被修改了写操作只在新副本上进行。第二由于底层数组是“不可变”的读操作天然没有脏读风险不需要锁来保护共享状态。这是理解这个类最重要的心智模型它在用“空间换时间”把并发问题从“锁竞争”转化成“无锁读取快照替换”。读者永远看到的是一个完整、自洽、不会被并发修改破坏的数组快照。3. 读多写少场景下的性能账本锁粒度与复制成本3.1 一笔一笔算清楚一次读 vs 一次写的真实开销很多人选型只是因为“听说它适合读多写少”但实际做技术方案时还是要算清楚账。我们假设集合里有 N 个元素操作时间复杂度是否加锁额外内存开销get(index)O(1)数组按索引访问否0contains(Object)O(N)遍历旧数组否0iterator()O(1)只需拿引用否0add(E)O(N)先复制再插入是ReentrantLock至少 N1 个元素引用的新数组remove(Object)O(N)遍历一遍找位置然后复制是新数组去掉一个元素set(index, E)O(N)复制整个数组是新数组写操作的“O(N)”不是比喻是实打实的全量拷贝。假设列表有 10 万条记录每写一次就要复制 10 万个引用差不多 800KB 内存64 位 JVM 下一个引用 8 字节实际数组头还会有点开销。这在读少写多的场景下会迅速变成灾难。但反过来说读操作是真正的零成本——没有锁状态检查没有 CAS没有 volatile 读虽然底层其实还是 volatile 读但相比锁竞争来说是极轻量的。在竞争激烈的多线程场景下锁等待造成的时间浪费往往远超操作本身CopyOnWriteArrayList相当于把读路径上的锁开销清零了。3.2 和其他“选手”的正面对比synchronizedList、Vector、读写锁我整理了一张对比表方便大家直接用于方案选型容器/方案读操作写操作一致性适用场景Vector / Collections.synchronizedList每个读方法都加 synchronized每个写方法都加 synchronized强一致读写都非常少或对一致性要求很高且规模小ReentrantReadWriteLock ArrayList读锁可共享但仍有锁开销写锁独占且可能被读者阻塞强一致读多写少但能接受一定的锁开销需要强一致CopyOnWriteArrayList无锁零等待全量复制新数组串行进行弱一致最终一致读极多、写很少且能接受短暂不一致ConcurrentHashMap桶级无锁或锁定桶级粒度弱一致迭代器读写都比较密集但注意它是有序性不保证的 Map这里我想专门说说读写锁的对比。很多人第一反应是有ReentrantReadWriteLock了为什么还要CopyOnWriteArrayList关键差异在于读写锁的读锁虽然是共享的但每次读仍要执行lock()和unlock()这里面有 volatile 写、CAS 操作乃至线程阻塞唤醒的开销。当读操作达到每秒几十万次甚至更高时光是锁相关指令就会成为瓶颈。而CopyOnWriteArrayList的读路径上几乎没有任何同步原语是把读性能压到了物理极限。当然代价就是写操作要复制全量数组并且存在“读完旧快照”的窗口期。所以它只适用于读的频次远超写的频次的场景通常写操作比例在 5% 以内才划算。3.3 什么阈值算“读多写少”一个来自实际项目的估算方法我在一个后台配置中心项目里用过它。那个场景是配置项列表启动时从远程拉取一次之后几乎只有新增和下线而每个请求进来都要读一遍配置。上线初期列表量约 500 条读 QPS 峰值到 1 万写频率大概一小时几十次。这种情况下复制 500 个引用的成本几乎可以忽略读无锁带来的收益却是实打实的——用synchronizedList时 CPU 有明显锁竞争损耗切到CopyOnWriteArrayList后 RT 直接降了一截。怎么判断你的场景适不适合我自己会这么估算估算每秒写操作次数 W每秒读操作次数 R看列表长度 N估算一次写的复制成本如果 R 远大于 W比如 100:1 以上并且 N 不算太大几千到几万CopyOnWriteArrayList很合适如果 W 到了每秒几十次以上或者 N 到了几十万写操作的全量复制就会频繁触发 GC这时候它就不是好选择了。4. 快照迭代器与弱一致性的边界4.1 迭代器为什么永远不抛 ConcurrentModificationException面试时如果能主动说出“快照式迭代器”这个概念会明显加分。要实现它关键在iterator()方法public IteratorE iterator() { return new COWIteratorE(getArray(), 0); }它直接把当前数组引用交给了迭代器之后迭代器遍历的就是这个数组快照跟后续集合状态完全无关。当其他线程执行add或remove时改的是另外一份新数组迭代器手里那份根本不会变自然也就不存在modCount不匹配的问题。这一点和ArrayList形成鲜明对比。普通ArrayList的迭代器内部维护了一个expectedModCount一旦检测到实际modCount变化立刻抛ConcurrentModificationException。而CopyOnWriteArrayList根本没有modCount这种机制它的迭代器一旦生成就永远不会失效。4.2 弱一致性你能读到旧数据但不会读到坏数据所谓弱一致性指的是读操作不一定能立即看到最新的写入。A 线程执行了list.add(x)B 线程如果在此之前已经通过getArray()拿到了旧数组引用那么 B 接下来再get()几个元素看到的仍然是没有 x 的旧版本。如果业务允许这种“短暂延迟”那没问题。我在某个灰度发布系统里曾用CopyOnWriteArrayList维护一组可用的服务节点列表。节点上下线时通过一个定时任务更新请求进来从列表里挑一个节点。有一次我注意到某节点刚刚下线但紧接着还是有少量请求被路由到了它。排查之后发现问题不在CopyOnWriteArrayList本身——因为读请求可能已经持有旧快照新请求最多延迟一个快照切换的窗口期。真正要做的是在业务层加上请求级别的失败重连逻辑比如路由失败后再查一次列表。这就是“弱一致性”的现实影响它保证你不会读到半截数据但不保证每次读到的都是最新数据。凡是业务上绝对不能容忍旧数据的场景比如金融交易中的余额读取都要避开这个类。4.3 一个容易踩的坑迭代时“好像数据少了”还有个小坑值得分享。某个同学在项目里这样写// 错误示范清空根本不是用clear()清掉的 CopyOnWriteArrayListString list new CopyOnWriteArrayList(); list.addAll(Arrays.asList(a, b, c)); for (String s : list) { if (b.equals(s)) { list.remove(b); // 编译器不报错运行也不报错 } }这段代码最终输出是a, b, c加一次循环但删除操作并不能对当前迭代器产生任何影响。在这个例子里它不会抛异常但这不代表业务逻辑正确——很多人的直觉是“我在遍历时删除了元素后面就不该再看到它”实际上遍历的仍是旧快照删除的是快照切换后的新数组。如果业务需要基于最新数据的遍历就得换思路比如遍历前先确认自己拿到的是最新引用或者干脆用支持结构性修改的容器。5. 生产环境使用 CopyOnWriteArrayList 的代价清单5.1 内存翻倍问题的真实影响写入时复制意味着每次写操作会同时存在新旧两份数组内存瞬时占用是双份的。如果数组本身存储的是大对象引用算下来可能多出上百 MB。频繁写操作时老数组要等 GC 才能回收年轻代如果扛不住就会发生 Minor GC 甚至 Full GC。我见过一个真实事故某服务用CopyOnWriteArrayList存了 20 万条用户数据摘要每小时更新一次。平时没事但某个夜晚数据源异常触发了一波 10 分钟内连续几十次的全量更新结果 JVM 老年代直接被复制出来的数组快照填满GC 时间飙到秒级接口大面积超时。后来改成了双缓冲加版本号手动管理的方案问题才解决。所以生产环境必须评估写频率 × 列表大小这个乘积是否在 JVM 内存承受范围内。5.2 addAll 与 removeAll 的连环复制还有一个容易忽略的点addAll(Collection)和removeAll这类批量操作在CopyOnWriteArrayList里的实现同样是一次全量复制。假设原数组 10 万个元素你要addAll1 万个Java 内部会一次性分配一个 11 万大小的新数组然后System.arraycopy批量搬移。相比多次调用add来说批量操作已经算优化过但仍然是全量复制内存峰值依旧暴力。如果遇到 “大列表 批量写” 的组合一定提前做容量评估。我自己常用的经验值参考列表超过 5 万元素、写操作每分钟超过 5 次就需要认真考虑替代方案。5.3 选型矩阵什么情况换什么容器条件组合推荐方案读极多 写极少 集合不大1万CopyOnWriteArrayList读较多 写可控 对强一致有要求ReentrantReadWriteLock ArrayList读写都多 数据量大的键值结构ConcurrentHashMap读写都少 代码追求简单synchronizedList / Vector这张表不是绝对的但可以帮助你把选项框定在一个更合理的小范围内再结合业务的 RT 和 GC 容忍度做最终决策。6. 面试官视角的追问链从原理到实践6.1 高频追问清单把这个类作为面试题时我一般会沿着这些方向追问CopyOnWriteArrayList底层是怎么实现线程安全的为什么它的读操作不需要加锁什么是快照式迭代器跟ArrayList的 fail-fast 迭代器有何不同写操作的代价有多大数组复制是怎么发生的它和Collections.synchronizedList有什么区别它和支持强一致的读写锁方案比各自优劣既然有ConcurrentHashMap为什么还要用它如果写操作很频繁为什么不能选它它是怎么处理 null 元素的“弱一致性”体现在哪里你怎么理解6.2 回答这些问题的正确“姿势”前三个问题属于送分题核心是抓住“不可变数组 volatile 引用”这种组合。第四、五题要求你理解写时复制的内存语义不能只说“它拷贝了一份数组”而要说清锁保证的是“同一时刻只能有一个写线程在复制”volatile 保证的是“新数组引用对其他线程可见”。第六、七题是区分度题能答出“读写锁仍然有读锁获取/释放的开销”以及“ConcurrentHashMap 是有序性不保证的 Map不是 List”的人说明真的做过选型思考。第八到第十题是加分项。能主动说“写多场景会复制爆炸、GC 压力大”能提到“iterator 是弱一致快照不适合依赖实时数据的遍历”能指出“它允许 null不像某些并发容器”基本就能证明你不仅背过源码还在生产环境里碰过壁。6.3 我自己总结的答题节奏我在面别人或者自己复盘时比较推荐“三段式”答题结构先说适用场景读多写少再说原理写时复制、无锁读、快照迭代器最后主动补一句边界代价写操作全量复制、弱一致性、不适合写多场景。这三段都讲完面试官基本没有再追问的必要了。如果时间充裕还可以顺手对比一下CopyOnWriteArraySet它内部就是用一个CopyOnWriteArrayList实现的但通过add时的indexOf检查天然去重代价是每次插入都要做一次 O(N) 的线性扫描。这时候你就可以自然地把话题拓展到“内部组合也是并发设计的一部分”。最后分享一个写代码外的经验面试时不要只把CopyOnWriteArrayList当作一个“答案”来背而是真的去理解它背后那句老话——并发问题没有银弹每个方案都在用某种代价换取另一种收益。读多写少这个前提是CopyOnWriteArrayList成立的根基前提一变方案就要跟着变。能在面试里把这句话说出来比背十遍源码更有说服力。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询