
1. 项目概述为什么ArrayList的线程安全是个大问题如果你写过Java并发程序肯定对ArrayList又爱又恨。爱的是它用起来太顺手了增删查改的API清晰明了性能在大多数场景下也足够好。恨的是它就像一颗埋在并发程序里的“定时炸弹”指不定什么时候就给你来个ConcurrentModificationException或者更诡异的脏数据问题。我见过太多线上事故根源就是开发同学图省事直接把ArrayList丢到多线程环境里共享使用结果在流量高峰时程序直接崩溃。简单来说ArrayList本身不是线程安全的。这意味着当多个线程同时对一个ArrayList实例进行结构性修改比如add、remove时其内部状态可能会被破坏导致数据丢失、元素错乱或者抛出异常。这个问题的核心在于ArrayList的底层实现比如它的size变量、modCount计数器以及数组扩容机制在多线程并发访问时缺乏必要的同步保护。因此作为一个负责任的开发者我们必须掌握如何给这个“裸奔”的容器穿上“防弹衣”让它能在多线程环境下安全、稳定地工作。这篇文章我就结合自己踩过的坑和实战经验系统梳理一下保证ArrayList线程安全的几种主流方法并深入分析它们各自的适用场景、性能代价和隐藏的“坑”。2. 核心思路线程安全的本质与实现路径要解决ArrayList的线程安全问题我们得先理解“线程安全”在集合操作中的具体含义。它主要涵盖三个方面原子性、可见性和有序性。对于ArrayList最核心的冲突点在于结构性修改。结构性修改指的是任何会改变ArrayList内部数组结构或size值的操作例如add(E e)添加元素可能触发扩容创建一个新的更大数组并拷贝数据。remove(int index)删除元素需要将后续元素前移。clear()清空所有元素。当线程A正在执行扩容创建了新数组但还未将旧数据完全拷贝过去线程B同时来读取元素就可能读到null值或旧数组的残留数据。当线程A和线程B同时执行add操作它们可能基于相同的size值计算插入位置导致其中一个添加的元素被覆盖。因此所有解决方案都围绕一个核心如何对共享的ArrayList实例的访问尤其是修改操作进行有效的同步或隔离。实现路径大致可以分为三类外部加锁在代码层面由开发者手动控制对ArrayList的访问同步。包装加锁利用Java集合框架提供的同步包装器自动为所有方法加锁。替代方案放弃ArrayList选用天生为并发设计的线程安全容器。下面我们就沿着这三条路径深入每一个方案的细节。2.1 路径选择背后的考量在选择具体方案前我们需要评估几个关键因素访问模式是读多写少还是读写都很频繁写操作是简单的添加还是复杂的条件更新性能要求对吞吐量和延迟的敏感度如何能接受多大的同步开销功能需求是否需要List接口的特定语义如按索引访问、保持插入顺序还是只需要一个能存放元素的容器复杂度控制团队对并发编程的掌握程度如何是希望一个简单粗暴的解决方案还是愿意引入更精细但稍复杂的机制没有一种方案是万能的。Collections.synchronizedList简单但性能一般CopyOnWriteArrayList在读多写少的场景下是神器但在写多的情况下会变成灾难。手动同步最灵活但也最容易出错。理解这些我们才能做出合适的选择。3. 方法一使用Collections.synchronizedList进行包装这是Java标准库内置的最直接的线程安全化方法。它的原理非常简单粗暴通过一个静态工厂方法返回一个将原List所有方法都用synchronized关键字包裹起来的代理对象。ListString syncList Collections.synchronizedList(new ArrayList());3.1 实现原理与内部机制Collections.synchronizedList返回的实际上是一个SynchronizedCollection的内部类实例。这个类内部持有一个原始的List对象本例中是ArrayList和一个最终的mutex对象默认为this即包装器对象自身。它重写了List的所有方法在每个方法体上都加上了synchronized(mutex)块。例如它的add方法大致是这样的public boolean add(E e) { synchronized (mutex) { return c.add(e); } }这意味着任何线程在调用syncList的add、remove、get、set等方法时都必须先获得mutex对象锁。这保证了同一时刻只有一个线程能执行这些方法从而实现了线程安全。3.2 优点与适用场景优点使用极其简单一行代码解决问题学习成本几乎为零。保证强一致性由于所有方法都串行化在任何时刻容器都处于一个一致的状态。兼容所有List操作它返回的依然是List接口因此所有基于List的API和第三方库都能无缝使用。适用场景并发访问压力不大的场景。需要快速上线、对性能要求不极致的原型或内部系统。读写操作都不太频繁或者你确定同步瓶颈不在此处。3.3 致命陷阱迭代器的隐式不同步这是使用synchronizedList时最容易踩坑的地方也是很多面试官喜欢问的点。方法级别的同步并不保护复合操作的原子性。最常见的复合操作就是迭代。下面这段代码是不安全的ListString syncList Collections.synchronizedList(new ArrayList()); // ... 多个线程向list中添加元素 ... // 线程A进行迭代 for (String s : syncList) { // 这里隐式调用了 iterator() System.out.println(s); }问题在于iterator()方法本身是同步的它返回的Iterator对象也是安全的。但是Iterator的hasNext()和next()方法并没有在synchronized块内被调用。整个for-each循环是由多次独立的hasNext()和next()调用组成的这些调用之间没有锁保护。如果在线程A迭代的过程中线程B通过syncList.remove(...)删除了一个元素就会立刻抛出ConcurrentModificationException。正确的做法是在迭代时必须手动对整个迭代过程加锁ListString syncList Collections.synchronizedList(new ArrayList()); // ... synchronized (syncList) { for (String s : syncList) { System.out.println(s); } } // 或者使用Java 8的 forEach 方法但同样需要外部同步 synchronized (syncList) { syncList.forEach(System.out::println); }注意这个锁对象必须是synchronizedList返回的列表本身syncList而不是原始的ArrayList或其他对象。因为包装器内部使用的mutex默认就是这个包装器对象。3.4 性能考量由于每次方法调用都需要获取和释放锁即使在读多写少的场景下频繁的get()调用也会带来可观的性能开销。在高并发争抢下线程会频繁地挂起和唤醒导致CPU时间浪费在上下文切换上吞吐量会急剧下降。因此在性能敏感的核心路径上需要谨慎使用。4. 方法二手动同步控制synchronized 或 ReentrantLock如果你需要对同步有更精细的控制或者同步块的范围超出了单个集合方法那么手动同步是更灵活的选择。其核心思想是由开发者自己定义并管理一个锁对象在访问共享ArrayList的任何代码段包括复合操作外围显式地加锁。4.1 使用 synchronized 关键字这是最经典的互斥锁实现。public class ManualSyncListDemo { private final ListString list new ArrayList(); private final Object lock new Object(); // 专门的锁对象 public void addItem(String item) { synchronized (lock) { list.add(item); } } public String getItem(int index) { synchronized (lock) { if (index 0 index list.size()) { return list.get(index); } return null; } } public void iterateAndProcess() { synchronized (lock) { // 保证整个迭代过程的原子性 for (String s : list) { // 处理每个元素 process(s); } } } private void process(String s) { // 模拟处理逻辑 } }为什么使用专门的lock对象而不是synchronized(this)使用一个私有的、最终的lock对象是更好的实践。synchronized(this)会锁住当前类实例如果这个实例的其他方法也用了synchronized(this)或者外部代码锁住了这个实例就可能引发意外的锁竞争和死锁。使用一个内部专用的锁对象可以将锁的粒度控制得更细只保护真正需要共享的资源即这个list减少不必要的阻塞。4.2 使用 ReentrantLock 实现ReentrantLock是java.util.concurrent.locks包下的一个类它提供了比synchronized更丰富的功能。import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockListDemo { private final ListString list new ArrayList(); private final ReentrantLock lock new ReentrantLock(); public void addItem(String item) { lock.lock(); // 获取锁 try { list.add(item); } finally { lock.unlock(); // 确保锁在finally块中释放防止异常导致死锁 } } public boolean tryAddItem(String item) { if (lock.tryLock()) { // 尝试获取锁立即返回成功与否 try { list.add(item); return true; } finally { lock.unlock(); } } else { // 获取锁失败执行其他逻辑如记录日志、重试、放入队列等 System.out.println(Could not acquire lock, item dropped: item); return false; } } }4.3 手动同步 vs synchronizedList特性Collections.synchronizedList手动同步易用性高一行代码搞定低需要开发者自己管理锁灵活性低锁粒度固定为方法级高可自定义锁对象、锁范围、尝试锁等复合操作安全性不安全需额外同步安全锁范围可涵盖整个复合操作性能相对固定有一定开销可通过优化锁粒度、使用tryLock等提升防死锁需注意迭代时的手动同步同样需注意锁顺序但功能更丰富实操心得对于简单的、仅涉及单个方法调用的场景synchronizedList更省心。如果业务逻辑涉及“检查-然后-执行”例如“如果不存在则添加”这本身就是一个复合操作必须用外部锁来保证原子性。这时手动同步是唯一选择。使用ReentrantLock时务必在finally块中解锁这是铁律。否则一旦临界区代码抛出异常锁将永远无法释放导致系统死锁。对于超高并发场景可以研究ReentrantLock的公平锁/非公平锁策略或者使用StampedLock一种更快的读写锁但这会大大增加复杂度。5. 方法三使用线程安全的替代容器 CopyOnWriteArrayList当你的场景是读操作极其频繁而写操作非常稀少时CopyOnWriteArrayList就是为你量身定做的神器。它来自java.util.concurrent包是ArrayList的一个线程安全变体。5.1 核心原理写时复制“写时复制”是这个容器名字的由来也是其线程安全性的基石。它的核心思想非常直观读取完全不加锁。所有读操作get、iterator都是直接访问当前内部数组的快照。写入任何会修改数组内容的操作add、set、remove都会先获取一个独占锁通常是ReentrantLock然后将当前内部数组完整地拷贝一份到新数组中在新数组上执行修改操作修改完成后再用新数组原子性地替换掉旧的内部数组引用。由于读和写操作作用于不同的数组副本所以它们之间永远不会产生冲突读操作也永远不会抛出ConcurrentModificationException。5.2 代码示例与特性import java.util.concurrent.CopyOnWriteArrayList; public class CopyOnWriteDemo { private final CopyOnWriteArrayListString cowList new CopyOnWriteArrayList(); // 写操作昂贵但安全 public void addItem(String item) { cowList.add(item); // 内部会加锁并复制数组 } // 读操作廉价且快速 public String getItem(int index) { return cowList.get(index); // 直接访问数组无锁 } // 迭代绝对安全基于迭代器创建时的数组快照 public void printAll() { for (String s : cowList) { // 安全不会抛出ConcurrentModificationException System.out.println(s); } } }关键特性迭代器弱一致性CopyOnWriteArrayList返回的迭代器在创建时就固定了当前数组的一个快照。即使在迭代过程中其他线程修改了列表创建了新数组迭代器遍历的依然是旧的快照。这保证了迭代过程不会出错但看到的数据可能不是最新的。这是一种“弱一致性”。内存占用与性能每次写操作都会复制整个底层数组。如果数组很大例如几万、几十万个元素一次add操作将带来巨大的内存分配和数组拷贝开销可能导致长时间的GC暂停。因此它绝对不适合写多或数组大的场景。元素修改CopyOnWriteArrayList只保证容器结构的线程安全。如果容器内存放的是可变对象例如User多个线程同时get到同一个User引用并修改其内部状态如user.setName(...)仍然需要额外的同步机制来保护User对象本身。5.3 适用场景与陷阱完美场景监听器列表Listener List。在事件驱动架构中经常需要维护一个监听器列表。注册写和注销写监听器的操作相对很少而事件触发时遍历通知所有监听器读的操作非常频繁。这正是CopyOnWriteArrayList的用武之地。典型陷阱场景误用在一个高频写入的实时数据处理系统中使用它性能会灾难性下降。内存溢出风险如果未能控制好列表容量频繁的写操作会导致大量临时数组对象产生给GC带来巨大压力。误解“线程安全”如前所述它不保护元素对象自身的线程安全。个人经验我曾经在一个配置中心的热更新回调监听器模块中使用CopyOnWriteArrayList。配置变更写是分钟级甚至小时级的而服务启动时拉取配置会触发所有监听器读是秒级并发的。使用它之后该模块的CPU消耗降低了超过70%并且彻底消除了之前因迭代和修改竞争导致的偶发性崩溃。6. 方法四更现代的并发容器选择除了包装ArrayListJava的并发包java.util.concurrent提供了更多专为高并发场景设计的容器它们通常使用更精细的锁机制如分段锁、CAS操作来实现更高的吞吐量。虽然它们不完全等同于ArrayList接口或语义略有不同但在很多场景下是更好的替代品。6.1 ConcurrentLinkedQueue (Deque)如果你需要的只是一个线程安全的队列先进先出或双端队列而不是一个需要按索引随机访问的列表那么ConcurrentLinkedQueue或ConcurrentLinkedDeque是性能极高的选择。原理基于无锁算法CAS实现在高并发环境下性能远超阻塞队列。特点迭代器是弱一致性的size()方法需要遍历时间复杂度是O(n)。适用场景高性能的生产者-消费者模型任务队列。6.2 ConcurrentSkipListMap (Set)如果你需要的是一个有序的、线程安全的Map或Set可以考虑ConcurrentSkipListMap和基于它实现的ConcurrentSkipListSet。原理基于跳表Skip List实现是一种空间换时间的数据结构提供平均log(n)时间复杂度的查找、插入和删除。特点键是有序的迭代器是弱一致性的。适用场景需要线程安全且有序的键值对集合。6.3 阻塞队列 (BlockingQueue)如ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue等。它们不仅线程安全还提供了丰富的阻塞操作put/take当队列满或空时线程会自动等待非常适合作为有界的任务缓冲区。选择ArrayList的替代品思路问问自己我真的需要List的get(index)功能吗很多时候我们只是需要一个能安全存放元素的容器队列或Set可能更符合业务语义且性能更好。7. 方案对比与选型指南为了更直观地对比我将上述主要方案总结如下表方案核心机制读性能写性能一致性内存开销适用场景Collections.synchronizedList方法级synchronized锁差每次读都加锁差每次写都加锁强一致性低并发度低简单易用需注意迭代同步手动同步 (synchronized)代码块级synchronized锁取决于锁粒度取决于锁粒度强一致性低需要复合操作原子性灵活性要求高手动同步 (ReentrantLock)显式锁可尝试、可中断取决于锁粒度取决于锁粒度强一致性低需要高级锁功能如超时、公平锁CopyOnWriteArrayList写时复制 独占锁极好无锁读极差复制整个数组弱一致性迭代快照高写时翻倍读极多写极少如监听器列表ConcurrentLinkedQueue无锁算法 (CAS)N/A (队列操作)好弱一致性中等纯生产者-消费者队列无需随机访问选型决策流程建议明确需求你的场景是读多还是写多是否需要严格的List语义索引访问、保持顺序对数据实时性要求多高评估性能如果写操作频率高于每分钟几次或者列表预期会很大超过1000元素请首先排除CopyOnWriteArrayList。考虑复杂度如果团队并发经验一般且性能压力不大Collections.synchronizedList记住迭代要加锁是最稳妥的起点。寻求替代如果不需要List的索引功能优先考虑ConcurrentLinkedQueue等更专业的并发容器。精细控制如果业务逻辑复杂涉及条件更新或复合操作手动同步推荐ReentrantLock是必经之路。压力测试在最终决定前用接近真实压力和数据的场景进行性能测试。并发容器的行为在高负载下可能与直觉不同。8. 实战中的典型问题与排查技巧即使选对了方案在实际编码和运维中还是会遇到各种问题。下面分享几个我亲身经历或解决的典型案例。8.1 问题一诡异的 “ConcurrentModificationException”现象在使用Collections.synchronizedList包装的列表进行迭代时程序在线上不定期抛出ConcurrentModificationException但代码审查时没发现明显的并发写操作。排查首先确认迭代代码是否在synchronized(list)块内。很多时候迭代被封装在某个工具方法里调用者忽略了同步。检查是否使用了“快速失败”的迭代器。除了for-each一些库方法内部也可能创建迭代器例如Apache Commons Lang的ListUtils.partition或者在使用Stream API的forEach时如果源头是synchronizedList且没有外部同步同样不安全。使用jstack或APM工具抓取异常发生时的线程堆栈查看是哪个线程在迭代哪个线程在修改。解决确保所有对列表的迭代包括隐式迭代都在同一个锁的保护下。如果迭代逻辑复杂可以考虑在迭代前使用List snapshot new ArrayList(syncList);先复制一份快照然后对快照进行迭代。虽然复制有成本但避免了长时间持有锁阻塞写操作。8.2 问题二使用 CopyOnWriteArrayList 导致 Full GC 频繁现象一个后台服务在每天凌晨定时任务运行时会出现规律的长时间停顿监控显示发生了Full GC。该服务使用了一个CopyOnWriteArrayList来存储所有活跃会话信息数量在10万级别。分析定时任务会清理过期会话这涉及到对CopyOnWriteArrayList的remove操作。每次remove都会复制一个近10万元素的数组产生一个约800KB假设每个引用8字节的临时垃圾对象。频繁的清理操作导致年轻代迅速被填满并可能促进对象进入老年代最终触发Full GC。解决更换数据结构这是根本解决之道。对于频繁修改的大集合CopyOnWriteArrayList不合适。可以改用ConcurrentHashMap来模拟一个集合Value存固定值或者使用手动同步的ArrayList但需要评估锁竞争。批量操作如果必须用将多次remove操作合并。例如先遍历找出所有需要删除的元素索引然后通过removeAll(Collection)一次删除。但注意removeAll内部可能也是多次复制最好自己实现一个批量删除的方法只复制一次数组。控制容量定期检查列表大小如果过大考虑归档或分片。8.3 问题三手动同步引发的死锁现象服务卡死线程Dump显示多个线程在等待锁形成了循环等待。模拟代码class ServiceA { private final ListString listA new ArrayList(); private final Object lockA new Object(); public void methodA(ServiceB b) { synchronized (lockA) { // ... 操作 listA ... b.helperMethod(); // 可能调用ServiceB中需要lockB的方法 } } } class ServiceB { private final ListString listB new ArrayList(); private final Object lockB new Object(); public void methodB(ServiceA a) { synchronized (lockB) { // ... 操作 listB ... a.helperMethod(); // 可能调用ServiceA中需要lockA的方法 } } } // 线程1: serviceA.methodA(serviceB); // 线程2: serviceB.methodB(serviceA);解决锁顺序化定义一个全局的锁获取顺序。例如规定所有线程必须先获取lockA再获取lockB。在ServiceB.methodB中需要先获取lockA再获取lockB即使它暂时不需要操作listA。使用ReentrantLock.tryLock在获取第二个锁时使用带超时的tryLock如果获取失败则释放已持有的锁回退并重试。降低锁粒度重新审视设计是否必须同时持有两个锁能否通过重构让每个方法只操作自己负责的资源8.4 性能调优小技巧缩小同步块范围在手动同步时只把真正需要互斥的代码放在synchronized块内。避免在锁内执行IO操作、网络调用或复杂的计算。使用局部变量在同步块内如果频繁访问list的某个属性如list.size()可以将其取出到一个局部变量中避免多次方法调用。针对读多写少的优化如果确实需要List语义且读远大于写但CopyOnWriteArrayList的写开销又无法接受可以考虑一种混合模式使用ReentrantReadWriteLock。读操作用读锁共享写操作用写锁独占。这样可以实现并发读但写操作仍然会阻塞所有读写操作。实现起来比CopyOnWriteArrayList复杂但写性能更好。