CopyOnWriteArrayList 的读写分离,更新开销有多大
「CopyOnWriteArrayList 的读写分离,更新开销有多大」
去年在优化一个即时通讯模块时,我遇到了一个典型的并发修改崩溃。后台线程通过 WebSocket 不断收到新消息,直接往一个 ArrayList 里 add,而 UI 线程里 RecyclerView 的 Adapter 正在用 for-each 遍历这个 List 做数据绑定。结果在低端机上必现 java.util.ConcurrentModificationException,堆栈直指 java.util.ArrayList$Itr.next。当时的思路很直接:既然并发读写冲突,那就找个线程安全的 List。于是我把 ArrayList 换成了 java.util.concurrent.CopyOnWriteArrayList,重新打包,异常确实消失了。但上线第二天,性能监控就报警了——部分用户的消息列表在加载历史记录时出现了明显的掉帧,Systrace 上主线程有一长串橙色色块,仔细一跟,全落在 CopyOnWriteArrayList.add 里。
那时我才意识到,这个类名字里的 "Copy" 不是白叫的。读写分离的代价,远比"加锁"要重得多。
源码里的 add 操作:每次都在重写整个数组
翻开 Android SDK(基于 OpenJDK 11 实现)里 CopyOnWriteArrayList 的源码,add(E e) 的实现非常耿直:
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(elements, len + 1)。Arrays.copyOf 内部会申请一块全新的连续内存,再调用 System.arraycopy 把老数组里的所有引用逐个拷贝过去。也就是说,哪怕你只是往一个已经有一万个元素的 List 里追加一条数据,它也会把这一万个引用全部复制一遍。时间复杂度是严格的 O(n),而不是普通 ArrayList 均摊后的 O(1)。
这里有个细节很多人容易忽略:在 newElements 被创建出来但还没被 setArray 赋值上去的那一瞬间,堆内存里同时存在两个长度接近的大数组。老数组等待 GC,新数组正在使用。如果你的 List 里存的是大对象引用(比如 Bitmap、Drawable、或者自定义的 Message 对象),这一瞬间的内存峰值会直接翻倍。我在 Android Studio 的 Memory Profiler 里观察过,在一个持有 5000 条消息引用的 CopyOnWriteArrayList 上做连续 add,内存曲线呈现出非常规律的锯齿状,Allocations 栏里 Object[] 的实例在疯狂产生,GC 也远比普通 ArrayList 频繁。对于 Dalvik/ART 来说,大数组的分配和回收本身就是压力,因为需要找到足够大的连续内存块,这还可能触发堆压缩或者引起更久的 GC pause。
remove 操作更刺激。如果删除的不是最后一个元素,它要把 index 前后的两段分别复制到新数组里,源码里同样是一条 Arrays.copyOf 或者分段 System.arraycopy。set(int index, E element) 看起来只是改一个元素,但实现上依然是全量复制:先拷整个数组,再在新数组的指定位置替换元素,最后把 volatile 数组引用指向新数组。所以写操作在这个类里没有"轻量"一说,只要是写,几乎都要付出复制全数组的代价。
读操作无锁,但"无锁"不等于"零成本"
CopyOnWriteArrayList 的读操作确实没有显式加锁:
public E get(int index) {
return get(getArray(), index);
}
final Object[] getArray() {
return array;
}读线程拿到的是 array 这个字段的当前引用,直接按索引取值。由于 array 被声明为 volatile,写线程在 setArray 里的写入对所有读线程立即可见,这就保证了不会读到正在构造中的"半成品"数组。从正确性角度来说,这套机制很干净。
但在 Android 的 ARM64 设备上,volatile 读的代价和 x86 并不一样。x86 的 TSO 内存模型本身就有较强的顺序保证,读 volatile 变量和普通变量的汇编差异不大;而 ARM 的弱内存模型需要插入内存屏障指令(dmb/isb 之类)来保证可见性。虽然现代 Cortex 核心和 ART 的优化让这部分开销已经很低,但如果写操作非常频繁,volatile 变量的缓存一致性流量(cache coherency traffic)还是会在多核之间产生微妙的影响。更重要的是,读线程拿到的是一个"快照"。如果主线程正在遍历这个 List 做 UI 渲染,而后台线程刚好完成了一次 add 并切换了 array 引用,读线程不会受到任何干扰,因为它手里握着的是老数组的引用——这既是特性,也是隐患。
迭代器里的快照陷阱
CopyOnWriteArrayList 的迭代器是我踩过的第二个坑。它的 iterator() 实现长这样:
public Iterator<E> iterator() {
return new COWIterator<E>(getArray(), 0);
}COWIterator 在构造的那一刻就把当前 array 的引用和长度缓存了下来。遍历过程中,即使其他线程把 List 改得面目全非,迭代器也浑然不觉,因为它操作的是那个已经"死去"的老数组。这确实彻底杜绝了 ConcurrentModificationException,但代价是迭代器可能读到旧数据,或者在某些