bigywater.tech
Java 内部原理 · 21

CopyOnWriteArrayList:真实验证迭代时改了也不报错,但代价随长度真实涨

"CopyOnWriteArrayList 适合读多写少的场景"是常见建议。真实验证它标志性的"迭代时改了也不报错"这个行为,再用真实计时把"写少"这个前提量化成具体数字。

5 → 5(不是 7)
迭代开始后真实新增 2 个元素,迭代器真实只看到原来的 5 个,不抛 ConcurrentModificationException
627ns → 7694ns
真实计时:size≈100 时单次 add 约 627ns,size≈10000 时涨到约 7694ns,涨了约 12 倍

上一篇:LinkedHashMap 实现 LRU:真实验证淘汰的是最久没访问的,不是最早插入的

真实验证:迭代期间修改,真的不报错

普通 ArrayList 在迭代期间被结构性修改会真实抛出 ConcurrentModificationException,CopyOnWriteArrayList 号称不会——真实验证一下。

真实实测 · 迭代期间修改 CopyOnWriteArrayList
迭代开始后 add 了 2 个元素,迭代器真实看到的元素个数 = 5(应为原始的 5,不包含新增的)
迭代结束后 list 真实大小 = 7(应为 7,新增元素真实都在)
迭代过程真实没有抛出 ConcurrentModificationException
真实实测 · 对照:普通 ArrayList 同样操作
真实抛出 ConcurrentModificationException: ConcurrentModificationException

真实原理是:CopyOnWriteArrayList 的迭代器持有的是创建时那一刻底层数组的引用,之后所有写操作(add/remove/set)都会先复制一份新数组、在新数组上改、再把内部引用切换过去——迭代器手里那份"旧数组"引用完全不受影响,所以它遍历到的永远是开始迭代那一刻的快照,新增的元素真实进了 list,但这次正在进行的迭代真实看不到。

真实验证:写操作的代价随长度真实增长

快照语义不是免费的——每次写操作都要把当前整个底层数组复制一份,长度越大,复制成本越高。

真实实测 · add 单次平均耗时随当前 size 变化
size≈100 时, 200 次 add 平均耗时 = 627ns
size≈1000 时, 200 次 add 平均耗时 = 1065ns
size≈5000 时, 200 次 add 平均耗时 = 3826ns
size≈10000 时, 200 次 add 平均耗时 = 7694ns

真实数据呈现出清晰的增长趋势:从 100 到 10000,耗时涨了约 12 倍,和长度增长的量级大致同步——这符合每次写操作都要真实做一次 O(n) 数组拷贝的预期。这也是为什么它的适用场景被限定得很窄:元素数量小、写操作稀少、读远多于写(比如事件监听器列表这种典型场景)。一旦列表变大或者写操作频繁,这个真实的拷贝成本会迅速抵消掉它在读路径上不加锁的优势。

那高频写、又要读安全,该用什么?系列前面验证过的ConcurrentHashMap 是更常见的选择——它用分段/CAS 级别的细粒度锁而不是整体复制来解决并发安全问题,读写都能保持较低的真实开销,只是牺牲了"迭代器看到的一定是某个时间点的完整快照"这个更强的一致性保证。

分步演示:为什么新增的元素真实"看不见"

用反射给 list.array 和 iterator.snapshot 两个数组对象分别编号(identityHashCode),串成动画:add() 会让 list.array 换成新对象,但 snapshot 是 final 字段,从迭代器创建那一刻起就再也不会指向别的数组。

iterator() → next() → add(6) → add(7) → next()×3:两个数组引用真实分道扬镳的过程(identityHashCode 编号)
cursor 当前指向的位置已经读过的位置

    参考与说明

    • 本文全部计时和结果均在本机真实运行 Java 17 得到。
    • 和ArrayList 扩容那篇一样,都是"可变长度容器,写操作代价随规模变化"这个主题下的真实验证。
    • 没有涉及:CopyOnWriteArraySet(内部就是包了一层的 CopyOnWriteArrayList)在同样场景下的表现——原理完全一致,这里不重复验证。
    京ICP备2026067502号-1京公网安备11010502063487号