当前位置: 首页 > news >正文

深度解析 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 实现,其核心思想是:

  1. 读操作:完全不加锁,直接读取底层数组的数据。由于读取时不加锁,所以它的读取性能非常高。
  2. 写操作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 成员变量。由于 arrayvolatile 的,每次读取都能拿到最新的数组引用,然后从该数组中获取元素。这就是 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;
}

步骤分解:

  1. 加锁:确保同一时间只有一个线程能执行写操作,防止多个线程同时复制和修改,导致数据错乱。
  2. 获取旧数组:拿到当前 volatilearray 引用。
  3. 复制新数组:调用 Arrays.copyOf 创建一个全新的数组,内容是旧数组的完整拷贝,并且长度加一。这是成本最高的一步。
  4. 添加元素:在新的数组末尾放入新元素。
  5. 切换引用:将array的引用指向newElements。因为arrayvolatile的,这个赋值操作是一个原子操作,并且其结果对所有线程立即可见。此时,其他线程调用 get 方法就会从新数组中读取了。
  6. 解锁:在 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。因此,即使在迭代过程中,其他线程通过 addremove 修改了 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();}
}

输出结果

在这里插入图片描述

结论

  1. 没有 ConcurrentModificationException:即使在遍历过程中添加了元素,程序也安然无恙。
  2. 迭代器快照:第一个读线程的遍历任务开始后,即使写线程添加了 99,它依然遍历完了原始的 [0, 1, 2, 3, 4]。因为它持有了添加 99 之前的数组快照。
  3. 数据可见性:添加操作完成后,第二个读线程看到的是包含 99 的新列表,证明了 volatile 的可见性保证。
2. 最佳应用场景

CopyOnWriteArrayList 的特性决定了它并非万金油,它特别适用于以下场景:

  • 读多写少:这是最重要的前提。例如,系统的配置信息、事件监听器列表、黑白名单等。这些数据一旦加载,很少会发生变动,但会被频繁地读取。在这种场景下,读操作无锁的高性能优势被发挥到极致,而写操作的成本可以被接受。
  • 数据量不大:由于写操作需要复制整个数组,如果列表中的元素非常多,一次复制的开销(时间和内存)会非常大,可能导致服务暂停(STW)或内存溢出(OOM)。
  • 对数据一致性要求不高:能够容忍读到“旧”数据。一个线程读取到的数据可能是被其他线程修改前的版本。如果业务要求强一致性,即一旦写入成功,所有读取必须立即看到新值,那么 CopyOnWriteArrayList 可能不适合。

五、优缺点与对比

优点
  1. 高并发读取性能:读操作无锁,性能远超 VectorCollections.synchronizedList
  2. 线程安全:保证了多线程环境下的数据一致性。
  3. ConcurrentModificationException:迭代器是 fail-safe 的,非常安全。
缺点
  1. 内存消耗大:每次写操作都会创建一个新数组,如果数据量大且写操作频繁,会造成严重的内存占用。
  2. 写操作性能低:写操作既要加锁,又要执行数组复制,成本高昂。
  3. 数据一致性问题:只能保证最终一致性,无法保证实时一致性。
与 Collections.synchronizedList / Vector 的对比:
特性CopyOnWriteArrayListCollections.synchronizedList / Vector
锁机制写操作加锁,读操作不加锁读、写、迭代等所有操作都加锁
读性能极高较低(因加锁导致串行化)
写性能较低(锁+数组复制)较低(因加锁导致串行化)
并发度读读并发,读写并发所有操作互斥,无并发
迭代器fail-safe(快照,不抛异常)fail-fast(可能抛 ConcurrentModificationException
一致性弱一致性(最终一致性)强一致性
适用场景读多写少,数据量小读写都很少,或者对并发要求不高的遗留系统

六、常见陷阱与最佳实践

  1. 禁止用于写密集型场景:切勿在写操作远多于读操作的场景下使用它,这会成为性能瓶颈和内存杀手。

  2. 警惕大对象列表:如果 CopyOnWriteArrayList 存储的是非常大的对象,并且频繁写入,内存开销会急剧上升。

  3. 批量操作的优化:如果你需要进行多次添加或删除,应该使用 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),这是理解所有并发容器的基础。

http://www.dtcms.com/a/390684.html

相关文章:

  • 直接看 rstudio里面的 rds 数据 无法看到 expr 表达矩阵的详细数据 ,有什么办法呢
  • 【示例】通义千问Qwen大模型解析本地pdf文档,转换成markdown格式文档
  • 企业级容器技术Docker 20250919总结
  • 微信小程序-隐藏自定义 tabbar
  • leetcode15.三数之和
  • 强化学习Gym库的常用API
  • ✅ Python微博舆情分析系统 Flask+SnowNLP情感分析 词云可视化 爬虫大数据 爬虫+机器学习+可视化
  • 红队渗透实战
  • 基于MATLAB的NSCT(非下采样轮廓波变换)实现
  • 创建vue3项目,npm install后,运行报错,已解决
  • 设计模式(C++)详解—外观模式(1)
  • pnpm 进阶配置:依赖缓存优化、工作区搭建与镜像管理
  • gitlab:从CentOS 7.9迁移至Ubuntu 24.04.2(版本17.2.2-ee)
  • 有哪些适合初学者的Java项目?
  • 如何开始学习Java编程?
  • 【项目实战 Day3】springboot + vue 苍穹外卖系统(菜品模块 完结)
  • 华为 ai 机考 编程题解答
  • Docker多容器通过卷共享 R 包目录
  • 【保姆级教程】MasterGo MCP + Cursor 一键实现 UI 设计稿还原
  • Unity 性能优化 之 理论基础 (Culling剔除 | Simplization简化 | Batching合批)
  • react+andDesign+vite+ts从零搭建后台管理系统
  • No007:构建生态通道——如何让DeepSeek更贴近生产与生活的真实需求
  • 力扣Hot100--206.反转链表
  • Java 生态监控体系实战:Prometheus+Grafana+SkyWalking 整合全指南(三)
  • 生活琐记(3)
  • 在 Elasticsearch 和 GCP 上的混合搜索和语义重排序
  • 借助Aspose.HTML控件,使用 Python 将 HTML 转换为 DOCX
  • 设计测试用例的万能公式
  • 黑马头条_SpringCloud项目阶段三:HTML文件生成以及素材文章CRUD
  • 精准模拟,实战赋能-比亚迪秦EV整车检测与诊断仿真实训系统