资讯动态

CopyOnWriteArrayList 读写分离设计:读多写少并发场景的解法与选型

发布时间:2026/10/14 21:05:52 来源:尧图企业网站定制
先问你一个问题一个业务列表读每秒十万次写每十分钟一次你会用什么集合拿这个问题去问不同人答案通常都是从ArrayList synchronized开始到CopyOnWriteArrayList结束。我在一个风控网关项目里就是这么处理的规则名单需要后台运营不定期更新但每个请求进来都要遍历名单做一次命中检查迭代过程中绝不能因为别人在改名单就把正在运行的所有请求全部打断。这正好是CopyOnWriteArrayList最拿手的事情。它不通过加锁把读线程挡在外面而是让写者自己复制一份再发布把“读”和“写”彻底拆开这种思路在并发集合里算是最容易被低估的设计之一。这篇文章会把读写分离思想从设计动机、源码细节、应用场景到选型边界完整过一遍。不管你是被ConcurrentModificationException折磨过的同学还是准备在项目里替换并发容器、又担心踩坑的开发者这篇文章都值得读完。1. 普通集合在并发下为什么守不住“读多写少”这一关1.1 ArrayList 的第一道坎迭代与修改互斥在实际生产环境里最经典的一个事故现场是这样的一个全局配置列表用ArrayList存放某天定时任务往列表里追加配置与此同时另一个请求线程正在for-each遍历列表然后控制台突然抛出一段异常java.util.ConcurrentModificationException at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1045)这段报错的根源在于ArrayList的迭代器内部维护了一个modCount修改计数。每次结构性修改比如add、remove、clear都会把modCount加一。迭代器在每次next()时都会检查final void checkForComodification() { if (modCount ! expectedModCount) throw new ConcurrentModificationException(); }一旦发现集合在迭代过程中被改过迭代器就认为自己手里的数据已经不可信宁可抛异常也不返回错误结果。这个约束在单线程下毫无问题因为单线程边遍历边改结果本身就不确定。但在多线程读多写少的场景下这套机制太苛刻一个线程只想往尾部追加一个元素正在遍历的读线程凭什么全部被强制中断恰恰因为ArrayList的遍历行为是“强一致性”的它根本不接受并发修改。1.2 加锁后的第二道坎读线程被低频写入拖垮最简单的补救办法自然是加锁。给所有读写操作套上同一把互斥锁读和写互斥问题看起来就解决了。但在读多写少的业务里这种方案往往更疼。因为写虽然是低频的读却是高频的。用synchronized包一层等于用 1% 的写频率去拖累 99% 的读请求所有读线程都要先抢锁即使当前根本没有写线程在运行。一旦有一个写线程持有锁后面排队的大批读线程全部阻塞。锁竞争会放大线程上下文切换最终导致整体吞吐量掉一个数量级。读操作本身是幂等的、无副作用的它真正怕的不是“读到旧数据”而是“被锁挡在门外连数据都读不到”。加锁方案把这个次序完全搞反了。1.3 CopyOnWrite 的直觉与其锁住读者不如让写者重写一份CopyOnWriteArrayList的思路非常直白既然读操作不修改数据那就让读操作永远访问一份稳定的数据写操作则把数据整体复制一份出来在副本上完成修改最后用一个原子的引用替换把旧数组整个扔掉。打个比方读线程相当于拿着一份纸质城市地图在查找路线后台想更新一条街道信息它不会去抢你手里的地图而是重新印刷一份新地图等着下一批读者拿到新版。你手里这份旧地图从头到尾都没有被人动过自然不存在“看了一会儿地图突然发现地图被撕了一页”的问题。这种设计与加锁方案有着本质区别加锁是限制访问写时复制是隔离版本。2. 源码级别的读写分离实现拆解2.1 内部结构一个 volatile 数组和一把写锁打开 JDK 源码CopyOnWriteArrayList内部结构极其精简public class CopyOnWriteArrayListE implements ListE, 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; } public CopyOnWriteArrayList() { setArray(new Object[0]); } }关键点有两个volatile修饰的array以及一把ReentrantLock。ReentrantLock只服务于写操作。为什么写操作还需要锁因为写操作是一个典型的“读-改-写”复合流程先读到当前数组复制一份修改副本再把新数组发布出去。如果两个写线程同时执行它们各自复制出不同的新数组然后交替发布后发布的一方就会覆盖掉先发布一方的修改。所以写操作必须串行化。array用volatile修饰则是为了保证发布新数组后所有读线程能立刻看到最新引用。volatile写本身带有一层内存屏障读线程对array进行volatile读时不仅能看到最新引用还能看到新数组在发布前写入的所有元素。这就是可见性保障的秘密。2.2 写操作复制是常态代价是 O(N)来看最核心的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(); } }执行过程可以用四步概括持锁。读取当前数组引用拿到旧数组。用Arrays.copyOf创建一个长度加一的新数组并一次性拷贝全部旧元素。在新数组尾部放入新元素然后setArray发布新数组。这里要注意一个细节它不是像普通ArrayList那样在原有数组上原地写入而是直接把整个数组复制成一个新数组。这也是为什么CopyOnWriteArrayList没有“扩容留余量”的概念。每次add都是一次完整的len - len 1复制。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到新数组里最后发布新数组。整体时间复杂度同样是 O(N)。2.3 读操作不加锁靠 volatile 保可见性读操作是最简单的部分甚至不需要持锁public E get(int index) { return get(getArray(), index); } private E get(Object[] a, int index) { return (E) a[index]; }它唯一的动作就是读取volatile array引用然后按下标从数组里取值。没有锁、没有synchronized、没有 CAS。indexOf、contains这类操作也类似只是遍历某个时刻的快照数组。从这里能看出读写分离的真正含义读和写在物理上访问的可能是两个不同的数组对象。写线程正在构造新数组时读线程仍然在读旧数组二者之间没有任何锁竞争。直到setArray发布新数组的那一刻后续读操作才会切换到新数据上。2.4 迭代器和 Stream创建时刻的快照CopyOnWriteArrayList的迭代器实现非常特殊public IteratorE iterator() { return new COWIteratorE(getArray(), 0); }迭代器在创建时就把当前array引用捕获下来后续遍历只在这个“旧”数组上进行。所以哪怕你一边迭代另一边有人调用了add迭代器也不会抛ConcurrentModificationException。它看到的内容永远是迭代器创建那一刻的数据快照。这种一致性级别叫弱一致性读线程不会读到正在写入的中间状态但也可能在一段时间内看不到最新添加的数据。Java 8 引入的stream()同样基于这个快照机制流的执行过程中即使集合被修改也不会影响已经开始的流计算。如果你的业务代码把list.stream().filter(...).collect(...)放在高频路径上这个特性简直是救命稻草。3. 工程实践用 CopyOnWriteArrayList 实现“规则注册表”3.1 初始化与批量装载的正确姿势我把CopyOnWriteArrayList用在一个规则注册表上规则的存在形式是一份字符串列表大致几千到几万条不等。初始化时最忌讳的做法是循环addCopyOnWriteArrayListString rules new CopyOnWriteArrayList(); for (String rule : sourceRules) { rules.add(rule); // 错误示范会复制 N 次 }如果源数据有 10000 条上面这段代码会在CopyOnWriteArrayList内部连续做 10000 次数组复制从长度 1 一路复制到长度 10000总复制量接近 5000 万次元素拷贝白白浪费。正确做法是用批量构造器或者addAll// 一次性构造内部只复制一次源集合 CopyOnWriteArrayListString rules new CopyOnWriteArrayList(sourceRules);如果列表对象已经在别处被引用无法重新构造就用rules.addAll(sourceRules);addAll会先把外力集合转换成一个新数组再一次性复制并发布整个过程只生成一个新数组。这一点和循环add是质的区别。3.2 更新时的整体替换技巧规则名单在后台运营更新时我更倾向于不直接操作现有的CopyOnWriteArrayList实例而是在包装类里持有一个volatile引用更新时直接替换整个列表对象public class RuleRegistry { private volatile CopyOnWriteArrayListString rules new CopyOnWriteArrayList(); public boolean contains(String rule) { // 读只读取一次引用然后在新快照上遍历 return rules.contains(rule); } public void updateRules(ListString newRules) { // 写构造一个全新列表整体发布 rules new CopyOnWriteArrayList(newRules); // 或者更节省一点styles.addAll(newRules) } }这样的好处在于批量更新只需一次复制。如果直接在原列表上clear()再addAll()中间会产生两次复制clear生成一个空数组addAll再生成最终数组而且在clear到addAll之间的窗口期读线程会看到空列表。整体替换引用就不会出现这类中间状态。读线程要么看到旧版本要么看到新版本永远不会看到“被清了一半”的版本。3.3 弱一致性在业务上怎么权衡每次规则更新后读线程并不一定立刻看到最新规则最多只能保证“最终会看到”。在我的网关场景里规则命中检查可以接受最多几十毫秒的延迟。后台刚更新完一条规则下一秒的请求没及时命中问题也不大下一轮请求总会覆盖到。如果你的业务要求“写入后立即对所有读线程可见”比如分布式配置中心强一致推送那么CopyOnWriteArrayList的弱一致性就不够用。这种情况下应该考虑每个请求都加锁读取或者使用ConcurrentHashMap配合原子变量做版本号校验。强一致性和高并发读两者本质上不可兼得。4. 选型对比它与同步容器、读写锁容器的本质差别4.1 三种方案横向对比很多团队在并发列表选型时会犹豫到底用Collections.synchronizedList、ReentrantReadWriteLock ArrayList还是CopyOnWriteArrayList。我用下面这张对比表说明关键差异对比维度synchronizedListReentrantReadWriteLock ArrayListCopyOnWriteArrayList读操作加锁加读锁无锁写操作加锁加写锁与读互斥加锁 整数组复制迭代安全性需手动加锁否则抛 CME持有读锁时可安全迭代天然安全快照迭代迭代期间写入写线程阻塞或读线程抛异常写线程阻塞直到迭代结束写线程不阻塞互不影响读到的数据强一致强一致弱一致快照写放大成本无无O(N) 数组复制在“迭代 同时写”这个关键场景上前两者都会产生阻塞或异常只有CopyOnWriteArrayList能做到读写完全无感。4.2 写频繁场景切换到哪个容器反过来如果列表的写操作非常频繁比如每秒几十次更新而且列表规模又大CopyOnWriteArrayList就会退化得非常严重。每次写入都要复制整个数组写放大倍数远远超过加锁方案的锁竞争成本同时还会频繁产生大数组对象给 GC 增压。这种情况下我一般会选择只关心尾部存取用ConcurrentLinkedQueue基于 CAS 的无锁队列写入成本低但遍历成本偏高。关心键值访问或者去重用ConcurrentHashMap的keySet()视图或者直接以Map为主数据结构。偶尔写、经常读、且对遍历一致性要求高CopyOnWriteArrayList依然是最优选。4.3 我总结的选型决策规则选型时我会先问三个问题读写比例是多少如果读远大于写比如 100:1优先考虑 COW。列表规模有多大几千条以内复制成本完全可控几十万条以上每次写复制都很吃紧。业务能容忍多少毫秒的旧数据只要不是强一致要求COW 的弱一致性完全可接受。这三问下来大部分“读多写少”场景都能快速收敛到正确方案。5. 性能代价与避坑实录5.1 复制成本到底有没有传说中那么可怕有人一听到“每次写都要复制整个数组”就被吓退了其实要分情况算账。这里复制的是引用数组而不是复制元素对象本身。假设列表里有 10 万个字符串对象数组里存放的是 10 万个引用每个引用在 64 位 JVM 开启指针压缩时占 4 字节算上数组头等开销一次完整复制大概只需要几百 KB 的内存拷贝。字符串对象本身的内部字符数组并不会被复制新数组和老数组只是引用同一批字符串对象。真正需要担心的是写频率。如果每十分钟写一次每次几百 KB 的内存分配GC 完全可以忽略。但如果每秒写几百次每次又是几百 KB一秒就是几百 MB 的分配速率年轻代很快被打满GC 频率飙升。所以判断 COW 合不合适核心指标是“数组大小 × 写入频率”而不是单纯看列表大小。5.2 我在实际项目中踩过的三个坑第一个坑是循环add。我前面已经演示过初始化时循环add会产生大量中间数组。更隐蔽的是批量更新时也没有用addAll导致线上启动阶段偶发长时间 GC。后来用JFR看到内存分配曲线才发现是复制次数过多。第二个坑是误用addIfAbsent做去重。CopyOnWriteArrayList的addIfAbsent虽然带锁能保证原子性但它的内部实现是先遍历当前数组做equals比较等于每次调用都是 O(N) 扫描再叠加可能的 O(N) 复制。如果数据量大又频繁调用性能极差。如果业务主要是“判断在不在”和“加入集合”直接上ConcurrentHashMap.newKeySet()才是对的。第三个坑是持有旧数组视图。有些人图方便先Object[] snapshot list.getArray()再手动遍历。这在当前线程里没问题但如果你把这个snapshot传入异步任务等任务真正执行时集合可能已经发布了好几版你手里还是最老的数据。如果业务对时效敏感这种老引用会导致读到非常旧的快照。既然想要快照就用公开的iterator()或toArray()不要绕过封装。5.3 一组可以复现的压测思路如果你也想在项目里验证效果建议写一个简单的多线程压测一个线程模拟写者每 5 秒批量更新一次八个线程模拟读者不断执行contains或遍历。用ArrayList synchronized和CopyOnWriteArrayList分别跑一段时间观察读吞吐和最大延迟。在我当时的环境里synchronizedList在读线程多时出现明显的排队效应最大延迟能到几十毫秒甚至上百毫秒换成 COW 后读线程几乎不受写线程干扰延迟稳定在微秒级。这个差异在高并发网关场景里直接决定了核心接口的 P99 耗时是否达标。5.4 配合 JVM 参数做监控启用 GC 日志或者通过JFR记录分配速率重点盯两件事年轻代 GC 频率、分配缓冲区大小。如果配置列表本身不大COW 的内存分配通常不会成为瓶颈但万一 GC 曲线出现锯齿状尖峰第一反查写操作是否过于频繁第二查是否有循环add这类低效写法。结尾我个人习惯在改动并发容器之前先拿读线程数量、写线程频率、单次数据规模这三个数字做一次粗略估算。写入频率乘以数组复制成本只要这个积还在 GC 能承受的范围内CopyOnWriteArrayList就是读多写少场景里最省心的选择一旦乘积失控再好的读写分离设计也会被复制风暴拖垮。回到开头的那个问题读每秒十万次、写每十分钟一次用什么集合答案已经很明显了。但更重要的是你要理解它为什么能应付这个场景——不是因为它比锁更强而是它把读操作锁的负担彻底卸掉了。这背后的取舍智慧才是并发编程里真正值钱的东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑