copyonwritearraylist 的写操作因每次均需 o(n) 数组拷贝且在 reentrantlock 临界区内执行,导致高并发下线程卡顿;同时引发内存翻倍、gc 频繁及迭代器持有旧数组致内存泄漏等问题。

写操作触发的数组拷贝到底花了多少时间
每次 add、remove 或 set 都会调用 Arrays.copyOf,复制整个底层数组。这个操作的时间复杂度是 O(n),不是常数——n 是当前数组长度,不是要加的那 1 个元素。10 万条数据时加一个元素,就要 memcpy 10 万次引用;100 万条就是百万级拷贝。
更关键的是:它发生在持有 ReentrantLock 的临界区内,所有写线程串行排队。高并发写入时,你看到的不是“慢”,而是“卡住”——线程在锁上等待,而前面那个线程还在 memcpy。
- 用 JFR(Java Flight Recorder)抓取
java.lang.System::arraycopy的调用栈,能直接定位哪次add引发了长耗时 - 避免在循环里调用
add:比如list.addAll(collection)本质是 N 次add,不是批量优化 - JDK 8+ 的
CopyOnWriteArrayList没有addAll的原子批量实现,别被名字骗了
内存占用翻倍不是错觉,是必然发生的事
CopyOnWriteArrayList 的数组引用是 volatile 的,但旧数组不会立刻被回收。新数组分配 + 旧数组待 GC,瞬时堆内存占用就是 2× 当前数据量。如果单个元素是 1KB 的对象,10 万条就多占 100MB 堆空间。
这会直接触发老年代 GC 频率上升,尤其在 CMS 或 ZGC 低延迟场景下,容易出现“写一次,停顿 200ms”的现象。
- 用
jstat -gc <pid></pid>观察OU(Old Used)和YGCT(Young GC Time)是否随写操作陡增 - 监控
Object[]实例数:频繁写入后,堆里会堆积大量生命周期短的数组实例 - 别依赖
System.gc()强制回收——它不保证立即释放旧数组,还可能干扰 JVM 自身 GC 节奏
迭代器快照导致的“看不见”的内存泄漏
你调用 iterator() 得到的不是实时视图,而是当时数组的一个快照引用。只要这个迭代器没被回收,它持有的旧数组就无法被 GC。
典型陷阱:事件分发中用 for (Listener l : listeners),每个 l.onEvent() 又启一个异步任务,而任务执行周期远长于写操作间隔——结果是每次写都留下一批“僵尸数组”,堆内存缓慢爬升。
- 用 MAT(Eclipse Memory Analyzer)分析堆转储,筛选
Object[]的 GC Roots,常能看到大量CopyOnWriteArrayList$COWIterator - 不要在 long-running 线程中长期持有迭代器;若必须遍历,考虑改用
toArray()获取副本后尽快释放引用 -
CopyOnWriteArrayList不支持removeIf这类修改迭代器状态的操作——它会抛UnsupportedOperationException
为什么“最终一致性”在配置中心里反而成了坑
所谓最终一致,是指写操作完成后,后续读操作“ eventually ”能看到新值。但这个“eventually”没有 SLA:它取决于线程调度、GC 停顿、甚至 CPU 缓存同步延迟。在集群配置中心里,不同节点看到的配置版本可能差好几秒。
更麻烦的是:它不提供版本号或更新戳,你没法判断“我读到的是不是最新版”。灰度发布时,部分节点加载了新配置,部分还在用旧配置,问题难以复现。
- 别把它当配置存储主干;更适合只读缓存层(如监听器列表),且写入频率 ≤ 1 次/秒
- 若真要用作配置容器,必须配合外部版本控制(如用
AtomicLong version手动管理) -
getArray()返回的是原始数组引用,不是副本——误用它做“手动快照”会导致并发修改风险
真正难处理的从来不是“怎么写”,而是“谁在读、读多久、读完之后还在不在用”。数组拷贝只是表象,背后是内存生命周期、GC 行为、线程调度三者的耦合。一旦迭代器持有时间不可控,或者写入节奏和 GC 周期共振,性能断崖就不是理论风险了。










