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 List<String> 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) { Iterator<String> 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块进行了同步。
List<String> 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()返回的迭代器,并不具备线程安全性。官方文档明确要求,在遍历时必须手动同步整个迭代过程。
这一点极其容易被忽略,是很多线上问题的根源。List<String> syncList = Collections.synchronizedList(new ArrayList<>()); // 必须这样遍历! synchronized (syncList) { Iterator<String> it = syncList.iterator(); while (it.hasNext()) { // 操作it.next() } } - 复合操作非原子:像“若不存在则添加”(
if (!list.contains(item)) list.add(item))这样的复合操作,即使每个方法调用是线程安全的,整个复合操作也不是。你仍然需要在外层用synchronized包裹整个逻辑。 - 性能:它本质上和手动加锁没有区别,存在相同的性能瓶颈。
提示:
Collections.synchronizedList是一个“条件线程安全”的类,它保证了单个方法的原子性,但无法保证用户逻辑的复合操作的原子性。把它当作一个“语法糖”或临时方案,而不是终极解决方案。
4. 方法二:写时复制(CopyOnWriteArrayList)——读多写少的王者
如果我们的场景是读操作非常频繁,而写操作(增、删、改)极少,那么CopyOnWriteArrayList(COW)就是为此而生的神器。它在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 ReadWriteLockArrayList<E> { private final List<E> 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.synchronizedList | CopyOnWriteArrayList | Vector | ReentrantReadWriteLock |
|---|---|---|---|---|---|
| 核心原理 | 代码块/方法级别互斥锁 | 方法级别互斥锁(包装器) | 写时复制,读写分离 | 方法级别互斥锁(类内部) | 读写分离锁 |
| 读并发性能 | 差(互斥) | 差(互斥) | 极好(无锁) | 差(互斥) | 好(共享读锁) |
| 写并发性能 | 差(互斥) | 差(互斥) | 差(复制开销大) | 差(互斥) | 中(排他写锁) |
| 内存开销 | 低 | 低 | 高(写时复制整个数组) | 低 | 低 |
| 数据一致性 | 强一致性 | 强一致性 | 弱一致性(读快照) | 强一致性 | 强一致性 |
| 迭代器安全性 | 需外部同步整个迭代过程 | 需外部同步整个迭代过程 | 安全(弱一致迭代器) | 需外部同步整个迭代过程 | 需用读/写锁保护迭代过程 |
| 复合操作 | 需外部同步 | 需外部同步 | 需外部同步(写操作本身安全,但“检查再行动”逻辑不安全) | 需外部同步 | 需用写锁保护 |
| 适用场景 | 并发度低,快速原型,简单场景 | 快速改造旧代码,临时方案 | 读极多,写极少,容忍数据延迟(监听器、配置缓存) | 历史遗留代码维护 | 读多写少,且要求强一致性 |
选型流程建议:
- 首先问:写操作频率如何?数据量多大?
- 如果写非常少,读很多,且数据量不大 -> 优先考虑
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快速查找,用ConcurrentHashMap<Integer, Config>远比用CopyOnWriteArrayList<Config>然后遍历查找要高效得多。
9. 实战中的“坑”与最佳实践
结合我多年的经验,这里有一些在确保List线程安全时容易踩的坑和最佳实践:
防御性编程与不可变性:最简单的线程安全就是“不共享可变状态”。如果可能,尽量将数据设计为不可变的(Immutable)。例如,如果列表内容在初始化后就不变,那么即使被多线程访问也绝对安全。如果必须共享,考虑返回列表的不可变视图或拷贝。
// 返回一个只读的拷贝,避免调用方意外修改 public List<String> getConfigList() { return Collections.unmodifiableList(new ArrayList<>(internalList)); } // 或者返回一个快照拷贝 public List<String> 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这类基础组件尤其如此。没有一种方案是银弹,关键在于深刻理解每种机制的原理、代价和适用边界,然后根据你实际的应用场景、数据特性和性能要求做出权衡。从粗粒度的锁到精细化的读写分离,再到完全不同的数据结构选型,这条演进路径本身就体现了并发编程从“能用”到“高效”的思考过程。下次当你面对一个需要共享的列表时,希望这份指南能帮你做出更自信、更稳健的选择。
