"CopyOnWriteArrayList 适合读多写少的场景"是常见建议。真实验证它标志性的"迭代时改了也不报错"这个行为,再用真实计时把"写少"这个前提量化成具体数字。
上一篇:LinkedHashMap 实现 LRU:真实验证淘汰的是最久没访问的,不是最早插入的
普通 ArrayList 在迭代期间被结构性修改会真实抛出 ConcurrentModificationException,CopyOnWriteArrayList 号称不会——真实验证一下。
迭代开始后 add 了 2 个元素,迭代器真实看到的元素个数 = 5(应为原始的 5,不包含新增的) 迭代结束后 list 真实大小 = 7(应为 7,新增元素真实都在) 迭代过程真实没有抛出 ConcurrentModificationException
真实抛出 ConcurrentModificationException: ConcurrentModificationException
真实原理是:CopyOnWriteArrayList 的迭代器持有的是创建时那一刻底层数组的引用,之后所有写操作(add/remove/set)都会先复制一份新数组、在新数组上改、再把内部引用切换过去——迭代器手里那份"旧数组"引用完全不受影响,所以它遍历到的永远是开始迭代那一刻的快照,新增的元素真实进了 list,但这次正在进行的迭代真实看不到。
快照语义不是免费的——每次写操作都要把当前整个底层数组复制一份,长度越大,复制成本越高。
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) 数组拷贝的预期。这也是为什么它的适用场景被限定得很窄:元素数量小、写操作稀少、读远多于写(比如事件监听器列表这种典型场景)。一旦列表变大或者写操作频繁,这个真实的拷贝成本会迅速抵消掉它在读路径上不加锁的优势。
用反射给 list.array 和 iterator.snapshot 两个数组对象分别编号(identityHashCode),串成动画:add() 会让 list.array 换成新对象,但 snapshot 是 final 字段,从迭代器创建那一刻起就再也不会指向别的数组。