深度解析 CopyOnWriteArrayList:并发编程中的读写分离利器
目录
- 一、引言:并发世界中的 ConcurrentModificationException
- 二、什么是 CopyOnWriteArrayList?
- 三、核心原理与源码剖析
- 1. 核心成员变量
- 2. 读操作 (get 方法)
- 3. 写操作 (add 方法)
- 4. 迭代器 (iterator 方法)
- 四、代码实战与应用场景
- 1. 实战:演示线程安全与迭代器快照
- 2. 最佳应用场景
- 五、优缺点与对比
- 优点
- 缺点
- 与 Collections.synchronizedList / Vector 的对比:
- 六、常见陷阱与最佳实践
- 七、总结
🌟我的其他文章也讲解的比较有趣😁,如果喜欢博主的讲解方式,可以多多支持一下,感谢🤗!
其他优质专栏: 【🎇SpringBoot】【🎉多线程】【🎨Redis】【✨设计模式专栏】…等
如果喜欢作者的讲解方式,可以点赞收藏加关注,你的支持就是我的动力
✨更多文章请看个人主页: 码熔burning
一、引言:并发世界中的 ConcurrentModificationException
在Java开发中,ArrayList
是我们最常使用的集合之一。然而,在多线程环境下,它却是一位“脆弱的公主”。如果我们尝试在一个线程遍历 ArrayList
的同时,让另一个线程去修改它(增加或删除元素),那么很有可能会遭遇一个令人头疼的异常——java.util.ConcurrentModificationException
。
// 这是一个会导致 ConcurrentModificationException 的经典例子
public class ArrayListUnsafeExample {public static void main(String[] args) throws InterruptedException {List<String> list = new ArrayList<>();list.add("A");list.add("B");list.add("C");// 线程一:遍历Listnew Thread(() -> {for (String item : list) {System.out.println("遍历元素: " + item);try {// 模拟耗时操作,增加并发修改的概率Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}}}).start();// 主线程稍等片刻,让遍历开始Thread.sleep(50);// 线程二:修改Listnew Thread(() -> {System.out.println("尝试添加元素 D...");list.add("D"); // 在遍历期间修改}).start();}
}
运行上述代码,可以看到有异常 ConcurrentModificationException
被抛出。
这是因为 ArrayList
的迭代器在设计时使用了 fail-fast
机制,它内部维护了一个 modCount
变量。一旦在迭代期间检测到 modCount
被修改,就会立刻抛出异常,以防止在不确定的数据状态下继续操作。
为了解决这个问题,JDK的JUC(java.util.concurrent
)包为我们提供了一系列线程安全的集合类,而 CopyOnWriteArrayList
正是其中解决“并发读写list”场景的一把瑞士军刀。
二、什么是 CopyOnWriteArrayList?
CopyOnWriteArrayList
,顾名思义,就是“写时复制”(Copy-On-Write)的 ArrayList
。它是一种线程安全的 List
实现,其核心思想是:
- 读操作:完全不加锁,直接读取底层数组的数据。由于读取时不加锁,所以它的读取性能非常高。
- 写操作(
add
,set
,remove
等):执行写操作时,它会先加锁,然后复制一份当前底层数组的新副本。接着,在新副本上执行修改操作。最后,将指向底层数组的引用原子地切换到这个新数组上,并释放锁。
这种机制可以看作是一种读写分离的思想:读操作在原始数据上进行,写操作在数据的副本上进行。读写之间互不干扰,从而实现了高效率的并发读取。
一个生动的比喻:
想象一下你在编辑一份多人共享的在线文档。
- 不安全的方式 (
ArrayList
):所有人都直接在原始文档上编辑,A正在阅读第一段,B突然删除了第二段,导致A的阅读体验混乱不堪,甚至程序(文档阅读器)崩溃。 Vector
/synchronizedList
的方式:为了安全,规定同一时间只能有一个人操作文档(无论是读还是写)。当A在阅读时,B想写就必须等待,反之亦然。这虽然安全,但效率极低。CopyOnWriteArrayList
的方式:A在阅读文档时,实际上是在看一个“快照版本”。当B需要编辑时,系统会为B复制一份全新的文档副本。B在副本上修改完成后,系统会原子地将共享文档的链接指向B编辑好的新版本。正在阅读旧版本的A不受任何影响,继续读完他的快照;之后再来访问的人,就会看到B修改后的新版本了。
三、核心原理与源码剖析
要真正理解 CopyOnWriteArrayList
,深入其源码是必经之路。
1. 核心成员变量
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {// 用于保证写操作的互斥性final transient ReentrantLock lock = new ReentrantLock();// 核心数据结构,使用 volatile 保证其在多线程间的可见性private volatile transient Object[] array;// ... 其他代码
}
lock
: 一个可重入锁,用于在写操作时保证线程安全。注意,读操作完全不使用这个锁。array
:volatile
修饰的数组,存储列表中的元素。volatile
关键字至关重要,它确保了当一个线程修改了array
的引用(指向新数组)后,这个变化能立即对其他所有线程可见。
2. 读操作 (get 方法)
public E get(int index) {return get(getArray(), index);
}final Object[] getArray() {return array;
}private E get(Object[] a, int index) {return (E) a[index];
}
get
方法的实现极其简洁,没有任何锁!它直接访问 array
成员变量。由于 array
是 volatile
的,每次读取都能拿到最新的数组引用,然后从该数组中获取元素。这就是 CopyOnWriteArrayList
读操作高性能的根源。
3. 写操作 (add 方法)
add
方法是体现“Copy-On-Write”精髓的地方。
public boolean add(E e) {final ReentrantLock lock = this.lock;lock.lock(); // 1. 加锁try {Object[] elements = getArray(); // 2. 获取旧数组int len = elements.length;// 3. 复制出一个新数组,长度+1Object[] newElements = Arrays.copyOf(elements, len + 1);// 4. 在新数组上添加元素newElements[len] = e;// 5. 将 array 引用指向新数组setArray(newElements);return true;} finally {lock.unlock(); // 6. 解锁}
}final void setArray(Object[] a) {array = a;
}
步骤分解:
- 加锁:确保同一时间只有一个线程能执行写操作,防止多个线程同时复制和修改,导致数据错乱。
- 获取旧数组:拿到当前
volatile
的array
引用。 - 复制新数组:调用
Arrays.copyOf
创建一个全新的数组,内容是旧数组的完整拷贝,并且长度加一。这是成本最高的一步。 - 添加元素:在新的数组末尾放入新元素。
- 切换引用:将
array
的引用指向newElements
。因为array
是volatile
的,这个赋值操作是一个原子操作,并且其结果对所有线程立即可见。此时,其他线程调用get
方法就会从新数组中读取了。 - 解锁:在
finally
块中释放锁,保证锁一定会被释放。
4. 迭代器 (iterator 方法)
CopyOnWriteArrayList
的迭代器是其另一个重要特性。
public Iterator<E> iterator() {return new COWIterator<E>(getArray(), 0);
}static final class COWIterator<E> implements ListIterator<E> {// 迭代器持有一个数组的快照private final Object[] snapshot;// ...COWIterator(Object[] elements, int initialCursor) {this.snapshot = elements; // 在创建时就固定了要遍历的数组//...}public boolean hasNext() {// ...}public E next() {//... 直接从 snapshot 中获取数据}public void remove() {// 不支持修改操作!throw new UnsupportedOperationException();}// ...
}
关键点在于:
- 快照(Snapshot):当调用
iterator()
方法时,CopyOnWriteArrayList
会将当前的底层数组(一个快照)传递给迭代器的构造函数。 - fail-safe:迭代器遍历的是这个快照,而不是实时变化的
array
。因此,即使在迭代过程中,其他线程通过add
或remove
修改了CopyOnWriteArrayList
,也只是生成了新的数组,并不会影响这个迭代器所持有的旧数组快照。因此,它永远不会抛出ConcurrentModificationException
,这种机制被称为fail-safe
。 - 数据一致性:这也带来一个重要的特性——迭代器看到的数据是创建它那一刻的“快照”,它无法感知到在它创建之后列表发生的变化。这是一种弱一致性或最终一致性的表现。
四、代码实战与应用场景
1. 实战:演示线程安全与迭代器快照
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;public class CopyOnWriteArrayListDemo {public static void main(String[] args) throws InterruptedException {// 使用 CopyOnWriteArrayList 替代 ArrayListList<Integer> list = new CopyOnWriteArrayList<>();// 初始化列表for (int i = 0; i < 5; i++) {list.add(i);}ExecutorService executor = Executors.newFixedThreadPool(5);// 任务一:遍历并打印列表Runnable readerTask = () -> {System.out.println("---------- 遍历开始 ----------");for (Integer item : list) {System.out.print(item + " ");try {Thread.sleep(200); // 模拟耗时,给写操作机会} catch (InterruptedException e) {Thread.currentThread().interrupt();}}System.out.println("\n---------- 遍历结束 ----------");};// 任务二:在列表末尾添加一个元素Runnable writerTask = () -> {try {Thread.sleep(100); // 确保遍历已经开始System.out.println("\n>> 写线程尝试添加元素 99 <<");list.add(99);System.out.println(">> 元素 99 添加成功, 当前列表: " + list + " <<");} catch (InterruptedException e) {Thread.currentThread().interrupt();}};// 启动读写线程executor.submit(readerTask);executor.submit(writerTask);// 再启动一个读线程,看看它能读到什么executor.submit(() -> {try {Thread.sleep(500); // 等待写操作完成System.out.println("\n---------- 第二个读线程开始遍历 ----------");System.out.println("第二个读线程看到的内容: " + list);System.out.println("---------- 第二个读线程结束 ----------");} catch (InterruptedException e) {Thread.currentThread().interrupt();}});executor.shutdown();}
}
输出结果:
结论:
- 没有
ConcurrentModificationException
:即使在遍历过程中添加了元素,程序也安然无恙。 - 迭代器快照:第一个读线程的遍历任务开始后,即使写线程添加了
99
,它依然遍历完了原始的[0, 1, 2, 3, 4]
。因为它持有了添加99
之前的数组快照。 - 数据可见性:添加操作完成后,第二个读线程看到的是包含
99
的新列表,证明了volatile
的可见性保证。
2. 最佳应用场景
CopyOnWriteArrayList
的特性决定了它并非万金油,它特别适用于以下场景:
- 读多写少:这是最重要的前提。例如,系统的配置信息、事件监听器列表、黑白名单等。这些数据一旦加载,很少会发生变动,但会被频繁地读取。在这种场景下,读操作无锁的高性能优势被发挥到极致,而写操作的成本可以被接受。
- 数据量不大:由于写操作需要复制整个数组,如果列表中的元素非常多,一次复制的开销(时间和内存)会非常大,可能导致服务暂停(STW)或内存溢出(OOM)。
- 对数据一致性要求不高:能够容忍读到“旧”数据。一个线程读取到的数据可能是被其他线程修改前的版本。如果业务要求强一致性,即一旦写入成功,所有读取必须立即看到新值,那么
CopyOnWriteArrayList
可能不适合。
五、优缺点与对比
优点
- 高并发读取性能:读操作无锁,性能远超
Vector
和Collections.synchronizedList
。 - 线程安全:保证了多线程环境下的数据一致性。
- 无
ConcurrentModificationException
:迭代器是fail-safe
的,非常安全。
缺点
- 内存消耗大:每次写操作都会创建一个新数组,如果数据量大且写操作频繁,会造成严重的内存占用。
- 写操作性能低:写操作既要加锁,又要执行数组复制,成本高昂。
- 数据一致性问题:只能保证最终一致性,无法保证实时一致性。
与 Collections.synchronizedList / Vector 的对比:
特性 | CopyOnWriteArrayList | Collections.synchronizedList / Vector |
---|---|---|
锁机制 | 写操作加锁,读操作不加锁 | 读、写、迭代等所有操作都加锁 |
读性能 | 极高 | 较低(因加锁导致串行化) |
写性能 | 较低(锁+数组复制) | 较低(因加锁导致串行化) |
并发度 | 读读并发,读写并发 | 所有操作互斥,无并发 |
迭代器 | fail-safe(快照,不抛异常) | fail-fast(可能抛 ConcurrentModificationException ) |
一致性 | 弱一致性(最终一致性) | 强一致性 |
适用场景 | 读多写少,数据量小 | 读写都很少,或者对并发要求不高的遗留系统 |
六、常见陷阱与最佳实践
-
禁止用于写密集型场景:切勿在写操作远多于读操作的场景下使用它,这会成为性能瓶颈和内存杀手。
-
警惕大对象列表:如果
CopyOnWriteArrayList
存储的是非常大的对象,并且频繁写入,内存开销会急剧上升。 -
批量操作的优化:如果你需要进行多次添加或删除,应该使用
addAll()
或removeAll()
等批量方法。这些方法内部也只进行一次加锁和数组复制,比你循环调用add()
效率高得多。// 不推荐的方式 for(int i = 0; i < 100; i++) {list.add(i); // 会导致100次复制 }// 推荐的方式 List<Integer> batch = new ArrayList<>(); for(int i = 0; i < 100; i++) {batch.add(i); } list.addAll(batch); // 只会复制一次
七、总结
CopyOnWriteArrayList
是 Java 并发包中一个设计精巧的容器。它通过“写时复制”的策略,实现了读写分离,为“读多写少”的并发场景提供了近乎完美的解决方案。它的读操作无锁,性能卓越;它的迭代器安全可靠,永不抛出 ConcurrentModificationException
。
然而,没有银弹。它的高内存消耗和低写性能也限制了其使用范围。作为一名专业的开发者,我们需要深刻理解其背后的工作原理、优缺点和适用场景,才能在实际项目中扬长避短,物尽其用,编写出健壮、高效的并发程序。
进阶学习建议:
- 阅读
CopyOnWriteArraySet
的源码,其底层就是CopyOnWriteArrayList
。 - 探索其他JUC容器,如
ConcurrentHashMap
,ConcurrentLinkedQueue
,理解它们各自使用了何种并发策略(如CAS、分段锁等)。 - 深入学习
volatile
关键字和Java内存模型(JMM),这是理解所有并发容器的基础。