Java ArrayList线程安全实战:从synchronized到CopyOnWriteArrayList

发布时间:2026/7/30 8:26:09
Java ArrayList线程安全实战:从synchronized到CopyOnWriteArrayList 1. 从一次线上事故说起为什么ArrayList不是线程安全的那天下午系统监控突然报警一个核心服务的错误率飙升。登录服务器一看日志里全是诡异的ArrayIndexOutOfBoundsException和ConcurrentModificationException而报错的位置指向一个我们用了很久的、看似人畜无害的ArrayList操作。这个ArrayList被多个线程共享用来缓存一些实时变动的配置信息。在低并发测试时一切正常一到生产环境流量高峰它就“原形毕露”了。这次事故让我彻底明白在Java并发编程里对ArrayList的线程安全性抱有侥幸心理无异于在代码里埋下一颗不定时炸弹。ArrayList是Java集合框架中使用频率最高的类之一它基于动态数组实现提供了快速的随机访问能力。然而它的设计初衷并非为了在多线程环境下安全使用。几乎所有Java开发者都知道它“非线程安全”但真正能清晰、完整地说出如何在多线程场景下安全使用它并理解每种方案背后代价的人并不多。这不仅仅是面试八股文更是保障线上服务稳定性的基本功。本文将结合我踩过的坑和实战经验彻底拆解让ArrayList变得线程安全的几种主流方法并深入分析它们各自的适用场景、性能开销和那些容易忽略的细节。2. ArrayList线程不安全的根源深入JVM与CPU缓存视角要解决问题必须先理解问题。ArrayList的线程不安全根源在于其内部状态主要是elementData数组和size变量在多线程下的读写缺乏同步保护。这会导致几种经典问题数据覆盖当两个线程同时执行add操作时可能基于相同的size值计算写入位置导致后一个线程的写入覆盖前一个线程的数据。大小不一致size变量的递增size并非原子操作可能造成size最终小于实际元素数量。扩容灾难在扩容grow过程中内部数组引用elementData被替换若其他线程仍持有旧数组引用进行读取或写入后果不可预测。迭代器快速失败ArrayList的迭代器Iterator基于一个expectedModCount来实现快速失败fail-fast机制。如果在迭代过程中其他线程修改了列表结构增删元素会导致modCount变化从而在迭代器下一次操作时抛出ConcurrentModificationException。仅仅知道这些现象还不够。从更底层看这涉及到Java内存模型JMM和现代CPU架构。ArrayList的字段如elementData,size,modCount都存储在堆内存中。在多核CPU环境下每个线程都有自己的工作内存可以理解为CPU高速缓存它们会从主内存中拷贝变量的副本进行操作。当一个线程修改了size这个修改可能暂时只写回其工作内存并未立即同步到主内存其他线程也就无法立即看到这个最新值。这种“可见性”问题加上size这类“读-改-写”操作的“原子性”问题共同构成了并发隐患的温床。注意很多人误以为使用volatile修饰ArrayList的引用就能解决线程安全问题这是完全错误的。volatile只能保证引用本身即指向哪个数组对象的可见性对于引用所指向的那个数组对象内部元素的状态变更volatile完全无能为力。线程安全必须保护的是对共享数据状态的“复合操作”。3. 方法一外部同步锁synchronized——最直接的控制这是最经典、最直观的线程安全实现方式。思路很简单既然ArrayList内部没有锁那我们就在使用它的代码外围手动加上同步锁。3.1 代码层面的同步块你可以使用synchronized关键字锁住整个ArrayList对象或者一个专门的锁对象。public class SynchronizedArrayListDemo { private final ListString list new ArrayList(); private final Object lock new Object(); // 专门的锁对象 public void addItem(String item) { synchronized (lock) { // 或者 synchronized (list) list.add(item); } } public String getItem(int index) { synchronized (lock) { if (index 0 index list.size()) { return list.get(index); } return null; } } // ... 其他所有访问list的方法都需要同步 }为什么有效synchronized保证了互斥性和可见性。同一时刻只有一个线程能进入同步块并且线程在退出同步块时会将工作内存中的修改强制刷新到主内存在进入同步块时会清空工作内存从主内存重新加载变量。这完美解决了原子性和可见性问题。实操心得与坑点锁对象的选择我强烈建议使用一个专门的、private final的锁对象如上面的lock而不是直接锁list本身。因为list的引用可能被外部代码获取并用于其他同步导致意外的死锁。使用私有锁对象可以将同步策略完全封装在类内部。锁粒度上述示例锁住了整个方法粒度较粗。在某些场景下如果get操作远多于add操作你可以考虑使用读写锁ReentrantReadWriteLock来提升并发读的性能我们会在方法四详细讨论。迭代的陷阱即使每个单独的add、get操作都同步了遍历迭代操作仍然需要特别小心。你必须将整个迭代过程通常是for循环或iterator的hasNext/next调用放在同一个同步块中否则在迭代过程中列表仍可能被其他线程修改。// 错误迭代过程非原子 synchronized (lock) { IteratorString it list.iterator(); // 获取迭代器时加了锁 } // 但迭代过程循环调用it.next()可能在其他线程修改list时发生导致ConcurrentModificationException while (it.hasNext()) { // 这里没在同步块内 System.out.println(it.next()); } // 正确整个迭代过程同步 synchronized (lock) { for (String item : list) { System.out.println(item); } }性能考量粗粒度的synchronized在竞争激烈的高并发场景下会带来显著的性能下降因为大量线程会阻塞在锁上。它适用于并发度不高或者写操作不频繁的场景。3.2 使用Collections.synchronizedList包装器Java标准库提供了一个便捷的包装器方法Collections.synchronizedList(new ArrayList())。它会返回一个线程安全的List视图其内部所有方法如add,get,set,remove等都通过synchronized块进行了同步。ListString syncList Collections.synchronizedList(new ArrayList()); // 现在可以安全地在多线程中调用 syncList.add(), syncList.get() 等内部实现揭秘它返回的是一个SynchronizedList一个静态内部类。这个类内部持有一个原始的List你的ArrayList和一个最终的锁对象mutex。所有方法都像下面这样实现public void add(int index, E element) { synchronized (mutex) { list.add(index, element); } }这个方法好用吗对于快速改造遗留代码它是一个不错的起点。但你必须清醒地认识到它的局限性迭代器仍需手动同步和手动加锁一样通过iterator()或listIterator()返回的迭代器并不具备线程安全性。官方文档明确要求在遍历时必须手动同步整个迭代过程。ListString syncList Collections.synchronizedList(new ArrayList()); // 必须这样遍历 synchronized (syncList) { IteratorString it syncList.iterator(); while (it.hasNext()) { // 操作it.next() } }这一点极其容易被忽略是很多线上问题的根源。复合操作非原子像“若不存在则添加”if (!list.contains(item)) list.add(item)这样的复合操作即使每个方法调用是线程安全的整个复合操作也不是。你仍然需要在外层用synchronized包裹整个逻辑。性能它本质上和手动加锁没有区别存在相同的性能瓶颈。提示Collections.synchronizedList是一个“条件线程安全”的类它保证了单个方法的原子性但无法保证用户逻辑的复合操作的原子性。把它当作一个“语法糖”或临时方案而不是终极解决方案。4. 方法二写时复制CopyOnWriteArrayList——读多写少的王者如果我们的场景是读操作非常频繁而写操作增、删、改极少那么CopyOnWriteArrayListCOW就是为此而生的神器。它在java.util.concurrent包中。4.1 核心原理读写分离与副本机制“写时复制”这个名字完美诠释了其原理读操作完全无锁。所有读方法get,indexOf,iterator等都直接在当前内部数组的快照上进行。因此多个线程可以并发读性能极高。写操作首先会获取一个独占锁ReentrantLock然后将当前的内部数组完整地拷贝一份在新的副本数组上执行修改操作修改完成后再用这个新的副本数组原子性地替换掉旧的内部数组引用。最后释放锁。// CopyOnWriteArrayList.add 方法的简化逻辑 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); // 原子性替换引用volatile write return true; } finally { lock.unlock(); } }为什么有效写操作通过锁保证互斥并且通过复制和原子替换保证了写操作完成后所有后续的读操作都能立即看到一致的新数据。由于读操作总是在一个不可变的数组快照上进行所以永远不会读到写了一半的中间状态也永远不会抛出ConcurrentModificationException。4.2 适用场景与性能权衡CopyOnWriteArrayList的优势和代价都非常明显优势极高的读并发性能读操作完全无锁、无阻塞在读多写少的场景下如监听器列表、配置信息缓存性能远超加锁方案。迭代器安全其迭代器Iterator直接持有创建时刻的内部数组快照。在整个迭代过程中即使其他线程修改了列表迭代器遍历的仍然是旧的快照因此不会抛出ConcurrentModificationException。这种迭代器被称为“弱一致性”迭代器——它不反映迭代创建后的修改但保证了遍历过程的安全和稳定。代价与注意事项内存开销每次写操作都会复制整个底层数组。如果数组很大例如几万、几十万个元素频繁的写操作会导致巨大的内存压力和GC开销。数据延迟读操作看到的数据不是实时的而是写操作完成那一刻的快照。对于强一致性要求的场景如金融交易这可能不适用。写操作性能复制数组的成本是O(n)写操作尤其是addAll,clear在数据量大时非常昂贵。元素引用可变性COW保证的是数组引用的线程安全如果数组元素本身是可变对象例如一个User对象多个线程同时修改同一个User对象的字段仍然需要额外的同步。实战选型建议非常适合事件监听器列表、只读为主的配置缓存、黑/白名单等。在这些场景中写操作通常只在初始化或偶尔更新时发生。绝对避免频繁写入的队列、实时交易数据列表等。一个经典误区不要用它来做“队列”。虽然它有序但其remove操作也是复制整个数组性能极差。队列请使用ConcurrentLinkedQueue或LinkedBlockingQueue。5. 方法三并发集合Vector——历史的遗留方案Vector是一个古老的类从Java 1.0时代就存在。它的内部实现几乎和ArrayList一样但关键区别在于它的所有公共方法add,get,remove,size等都使用了synchronized关键字修饰。// Vector 的方法签名示例 public synchronized boolean add(E e) { modCount; ensureCapacityHelper(elementCount 1); elementData[elementCount] e; return true; } public synchronized E get(int index) { if (index elementCount) throw new ArrayIndexOutOfBoundsException(index); return elementData(index); }为什么现在不推荐使用Vector锁粒度粗性能差和手动使用synchronized(list)一样Vector的锁是对象级别的且每个方法都加锁。即使在只有读操作的场景下读线程之间也会相互阻塞这在现代高并发应用中是不可接受的性能瓶颈。复合操作仍需外部同步和Collections.synchronizedList一样像if (!v.contains(x)) v.add(x)这样的操作仍然不是原子的程序员容易误以为它是完全线程安全的。迭代器问题它的迭代器也是“快速失败”的并且需要在外部同步才能安全地在多线程中使用否则会抛出ConcurrentModificationException。API设计陈旧Vector有一些独特的方法名如elementAt,addElement与后来的List接口标准不太一致。虽然它实现了List接口但混用两种风格会让代码显得杂乱。在现代Java开发中Vector基本上已经被视为一个“历史遗留类”。在新的代码中如果你需要一个全方法同步的列表更推荐使用Collections.synchronizedList(new ArrayList())至少这样明确了你使用的是包装器并且迭代器需要额外同步的警示更明显。而大多数情况下你应该根据场景选择CopyOnWriteArrayList或下面要介绍的显式锁方案。6. 方法四显式锁ReentrantReadWriteLock——精细化的并发控制当你的应用场景是“读非常多写也不少但写操作并非极度频繁”时CopyOnWriteArrayList的复制开销可能无法承受而synchronized或Vector的粗粒度锁又限制了读并发。这时读写锁ReadWriteLock就派上用场了。Java并发包提供了ReentrantReadWriteLock实现。6.1 读写锁的工作原理读写锁的核心思想是区分读锁和写锁读锁共享锁允许多个线程同时持有读锁。只要没有线程持有写锁任意数量的线程都可以同时获取读锁。写锁排他锁一次只允许一个线程持有写锁。当有线程持有写锁时其他线程无法获取读锁或写锁。这完美匹配了“读多写少”且“要求数据强一致性”的场景读操作可以完全并发写操作则独占。6.2 使用读写锁封装ArrayList我们可以用ReentrantReadWriteLock来封装一个自定义的线程安全ArrayList。public class ReadWriteLockArrayListE { private final ListE list new ArrayList(); private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); private final Lock readLock rwLock.readLock(); private final Lock writeLock rwLock.writeLock(); public void add(E element) { writeLock.lock(); try { list.add(element); } finally { writeLock.unlock(); } } public E get(int index) { readLock.lock(); try { return list.get(index); } finally { readLock.unlock(); } } public boolean contains(Object o) { readLock.lock(); try { return list.contains(o); } finally { readLock.unlock(); } } // 迭代操作也需要用读锁或写锁保护取决于你是否允许在迭代时修改 public void iterate() { readLock.lock(); // 如果迭代过程中不允许修改用读锁 try { for (E e : list) { // 处理元素e } } finally { readLock.unlock(); } } }为什么比单纯的synchronized好在大量并发读、少量写的场景下读线程不会相互阻塞可以并行执行极大地提升了系统的吞吐量。只有在写操作发生时才会短暂地阻塞所有读和写。6.3 进阶考量锁降级与锁升级读写锁有一些高级特性需要了解锁降级允许一个持有写锁的线程在保持写锁的同时获取读锁然后释放写锁从而“降级”为读锁。这是允许的并且是一个有用的特性可以保证在修改数据后其他写线程被阻塞而当前线程还能以读锁身份继续查看数据确保数据一致性。writeLock.lock(); try { // 修改数据... // 在释放写锁前获取读锁锁降级 readLock.lock(); } finally { writeLock.unlock(); // 降级完成 } try { // 此时仍持有读锁可以安全地读取数据 } finally { readLock.unlock(); }锁升级试图在持有读锁的情况下获取写锁。ReentrantReadWriteLock不支持锁升级尝试这样做会导致死锁。因为如果允许多个读锁同时升级它们会相互等待对方释放读锁从而永远无法获取写锁。如果你的逻辑需要先读后写并且写依赖于读的结果你应该在开始时就直接尝试获取写锁或者释放读锁后再尝试获取写锁但这中间数据可能已被其他线程修改。实战心得评估写竞争如果写操作也非常频繁读写锁的优势会大打折扣因为写锁是排他的频繁的写会导致读线程也经常被阻塞。此时可能需要考虑更细粒度的并发数据结构如ConcurrentHashMap如果你需要键值对或分段锁的思想。避免在锁内执行耗时操作无论是读锁还是写锁持有锁的时间都应尽可能短。绝对不要在锁内进行IO操作、网络调用或复杂的计算。公平性与非公平性ReentrantReadWriteLock可以构造为公平或非公平锁。非公平锁吞吐量高但可能造成线程饥饿公平锁保证线程按申请顺序获取锁但吞吐量较低。默认是非公平的在绝大多数高并发场景下这是更好的选择。7. 方法对比与选型决策矩阵面对这么多方案到底该怎么选我总结了一个决策矩阵你可以根据你的核心场景对号入座特性/方案外部同步锁 (synchronized)Collections.synchronizedListCopyOnWriteArrayListVectorReentrantReadWriteLock核心原理代码块/方法级别互斥锁方法级别互斥锁包装器写时复制读写分离方法级别互斥锁类内部读写分离锁读并发性能差互斥差互斥极好无锁差互斥好共享读锁写并发性能差互斥差互斥差复制开销大差互斥中排他写锁内存开销低低高写时复制整个数组低低数据一致性强一致性强一致性弱一致性读快照强一致性强一致性迭代器安全性需外部同步整个迭代过程需外部同步整个迭代过程安全弱一致迭代器需外部同步整个迭代过程需用读/写锁保护迭代过程复合操作需外部同步需外部同步需外部同步写操作本身安全但“检查再行动”逻辑不安全需外部同步需用写锁保护适用场景并发度低快速原型简单场景快速改造旧代码临时方案读极多写极少容忍数据延迟监听器、配置缓存历史遗留代码维护读多写少且要求强一致性选型流程建议首先问写操作频率如何数据量多大如果写非常少读很多且数据量不大 - 优先考虑CopyOnWriteArrayList。如果写频繁或数据量巨大- 排除CopyOnWriteArrayList。其次问对读性能要求有多高要求极高读吞吐且能接受弱一致性-CopyOnWriteArrayList是唯一选择。要求强一致性且读并发很高- 选择ReentrantReadWriteLock。读并发要求一般 - 可以考虑synchronized或Collections.synchronizedList。最后问是不是历史项目或简单场景是且改动成本要小 -Collections.synchronizedList。全新开发追求清晰和性能 - 根据上面两点选择CopyOnWriteArrayList或ReentrantReadWriteLock。8. 超越ArrayList为何不直接使用ConcurrentLinkedQueue或ConcurrentHashMap在讨论ArrayList的线程安全方案时一个更根本的问题是你真的需要一个线程安全的“列表”List吗List的核心特性是有序、可重复、支持随机访问通过索引。但在很多并发场景下我们使用List只是为了一个“容器”其核心需求可能只是生产者-消费者模式你需要一个队列。那么ConcurrentLinkedQueue非阻塞或LinkedBlockingQueue阻塞是更专业、性能更好的选择。缓存或快速查找你需要根据键快速获取值。那么ConcurrentHashMap是线程安全Map的最佳实现它的并发性能远高于任何线程安全List的遍历查找。去重集合你需要一个不重复的集合。那么ConcurrentHashMap.newKeySet()返回的线程安全Set更适合。实战建议在设计并发程序时不要执着于“如何让ArrayList线程安全”而是先退一步思考“我的业务场景到底需要哪种数据结构”。通常选择为并发而生的专用数据结构java.util.concurrent包下的类会比改造非线程安全的数据结构获得更优的性能和更简洁的代码。例如一个全局的配置项列表如果需要根据ID快速查找用ConcurrentHashMapInteger, Config远比用CopyOnWriteArrayListConfig然后遍历查找要高效得多。9. 实战中的“坑”与最佳实践结合我多年的经验这里有一些在确保List线程安全时容易踩的坑和最佳实践防御性编程与不可变性最简单的线程安全就是“不共享可变状态”。如果可能尽量将数据设计为不可变的Immutable。例如如果列表内容在初始化后就不变那么即使被多线程访问也绝对安全。如果必须共享考虑返回列表的不可变视图或拷贝。// 返回一个只读的拷贝避免调用方意外修改 public ListString getConfigList() { return Collections.unmodifiableList(new ArrayList(internalList)); } // 或者返回一个快照拷贝 public ListString getConfigSnapshot() { synchronized (lock) { return new ArrayList(internalList); } }警惕“隐藏的迭代器”很多方法内部会使用迭代器例如toString(),equals(),hashCode()以及containsAll(),removeAll()等批量操作。如果你的线程安全列表如synchronizedList没有在调用这些方法时加锁它们仍然可能抛出ConcurrentModificationException。性能测试与监控无论选择哪种方案一定要在模拟真实并发压力的环境下进行性能测试。监控指标应包括吞吐量QPS、平均/百分位延迟P99 Latency、CPU使用率、GC情况。对于CopyOnWriteArrayList要特别关注写操作时的内存和GC波动。CompletableFuture与并行流中的陷阱在使用CompletableFuture或并行流parallelStream进行异步/并行计算时如果任务中需要修改一个共享的ArrayList即使你用了synchronizedList也很容易因为任务在公共的ForkJoinPool中执行而导致锁竞争激烈性能下降。在这种情况下通常更好的模式是让每个任务处理自己的数据副本最后再进行合并Reduce而不是共享一个可变集合。线程安全无小事对于ArrayList这类基础组件尤其如此。没有一种方案是银弹关键在于深刻理解每种机制的原理、代价和适用边界然后根据你实际的应用场景、数据特性和性能要求做出权衡。从粗粒度的锁到精细化的读写分离再到完全不同的数据结构选型这条演进路径本身就体现了并发编程从“能用”到“高效”的思考过程。下次当你面对一个需要共享的列表时希望这份指南能帮你做出更自信、更稳健的选择。