别再让ArrayList在多线程里‘丢数据’了!手把手教你用synchronizedList和CopyOnWriteArrayList搞定并发List
2026/4/19 17:41:56 网站建设 项目流程

多线程环境下如何安全操作List?从ArrayList的坑到解决方案实战

记得刚入行Java开发那会儿,有一次在用户行为分析系统中遇到了一个诡异的Bug——系统运行一段时间后,收集到的用户点击事件总会莫名其妙地少那么几百条。当时排查了半天才发现,原来是在高并发场景下直接使用了ArrayList导致的。这个经历让我深刻认识到,在并发编程中,集合类的选择绝非小事。

1. 为什么ArrayList在多线程中会"丢数据"?

让我们先来看一个简单的例子。假设我们有一个用户行为日志收集系统,需要记录用户的点击事件:

List<String> userClicks = new ArrayList<>(); // 模拟100个用户同时点击 for (int i = 0; i < 100; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { userClicks.add("click_" + System.currentTimeMillis()); } }).start(); }

理论上,最终userClicks的size应该是100,000,但实际上你可能会遇到两种意外情况:

  1. 数组越界异常:控制台抛出ArrayIndexOutOfBoundsException
  2. 数据丢失:最终size小于预期值,比如只有98,763

1.1 ArrayList的底层实现分析

要理解这个问题,我们需要看看ArrayList的add方法实现:

public boolean add(E e) { ensureCapacityInternal(size + 1); // 确保容量足够 elementData[size++] = e; // 添加元素并增加size return true; }

这里有两个关键操作:

  • ensureCapacityInternal:检查并扩容
  • elementData[size++] = e:赋值并自增

在多线程环境下,这两个操作都可能出现问题:

问题类型产生原因具体表现
数组越界多个线程同时检查容量,都认为不需要扩容最终实际插入位置超出数组边界
数据丢失size++不是原子操作,多个线程可能覆盖相同位置最终元素数量少于预期

提示:ArrayList的size字段没有volatile修饰,且elementData数组也不是线程安全的,这是问题的根源。

2. 解决方案一:Collections.synchronizedList

Java集合框架提供了一个简单的方法来创建线程安全的List:

List<String> safeList = Collections.synchronizedList(new ArrayList<>());

2.1 实现原理

synchronizedList通过在方法级别加锁来保证线程安全:

public boolean add(E e) { synchronized (mutex) { return c.add(e); } }

所有修改操作(add、remove等)都会在同一个锁上进行同步,确保同一时间只有一个线程能修改集合。

2.2 使用场景与性能考量

synchronizedList适合读写均衡的场景。它的特点是:

  • 优点

    • 实现简单,使用方便
    • 所有操作都是线程安全的
    • 不需要额外的内存开销
  • 缺点

    • 读写操作都需要获取锁
    • 高并发下可能成为性能瓶颈

性能对比测试(单位:毫秒,100线程各执行1000次操作):

操作类型ArrayListsynchronizedList
纯写入23145
纯读取12132
读写混合18138

3. 解决方案二:CopyOnWriteArrayList

对于读多写少的场景,Java提供了更高效的CopyOnWriteArrayList:

List<String> cowList = new CopyOnWriteArrayList<>();

3.1 写时复制机制

CopyOnWriteArrayList的核心思想是:每次修改操作都会创建底层数组的新副本。

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); return true; } finally { lock.unlock(); } }

这种实现带来了几个重要特性:

  1. 读写不互斥:读操作不需要锁,直接访问当前数组
  2. 弱一致性:读取操作可能看不到最新的修改
  3. 内存开销:每次修改都会创建新数组

3.2 适用场景与最佳实践

CopyOnWriteArrayList最适合以下场景:

  • 读取频率远高于写入(如配置信息缓存)
  • 集合大小通常较小(避免频繁复制大数组)
  • 能够容忍短暂的数据不一致

实际项目中的典型使用模式:

// 初始化配置(不常变化) CopyOnWriteArrayList<ConfigItem> configs = loadInitialConfigs(); // 多个线程频繁读取配置 configs.forEach(item -> processConfig(item)); // 偶尔更新配置 configs.addAll(fetchNewConfigs());

4. 两种方案的深度对比与选型建议

4.1 技术特性对比

特性synchronizedListCopyOnWriteArrayList
线程安全机制方法级同步写时复制+锁
读性能需要获取锁无锁,直接访问数组
写性能需要获取锁复制数组,较慢
内存占用高(每次修改都创建新数组)
数据一致性强一致性弱一致性
迭代器安全性需要外部同步安全的

4.2 选型决策树

根据你的业务场景,可以按照以下流程选择:

  1. 是否需要线程安全

    • 否 → 使用ArrayList
    • 是 → 进入下一步
  2. 读写比例如何

    • 读远多于写(≥10:1)→ CopyOnWriteArrayList
    • 读写均衡 → synchronizedList
  3. 数据量大小

    • 大(≥10,000元素)→ 优先考虑synchronizedList
    • 小 → 两者都可
  4. 是否需要强一致性

    • 是 → synchronizedList
    • 否 → CopyOnWriteArrayList

4.3 性能优化技巧

无论选择哪种方案,都有一些优化技巧:

对于synchronizedList

  • 批量操作时手动同步:
    synchronized(list) { list.addAll(newItems); }
  • 避免在同步块内执行耗时操作

对于CopyOnWriteArrayList

  • 批量添加时使用addAll而非多次add
  • 考虑设置合理的初始容量
  • 对于频繁修改的场景,考虑使用ConcurrentLinkedQueue等替代方案

5. 真实案例:用户行为分析系统的改造

让我们回到开头的用户行为日志系统,看看如何改造:

5.1 原始问题代码

// 不安全的实现 List<UserAction> actions = new ArrayList<>(); public void logAction(UserAction action) { actions.add(action); // 多线程下可能丢失数据 }

5.2 改造方案选择

根据业务特点:

  • 写入频率高(每次用户操作都会记录)
  • 读取频率较低(定时批量处理)
  • 数据量可能较大(高活跃度系统)

因此,synchronizedList是更合适的选择:

// 线程安全改造 List<UserAction> actions = Collections.synchronizedList(new ArrayList<>()); public void logAction(UserAction action) { actions.add(action); // 现在安全了 } // 批量处理时也需要同步 public List<UserAction> getRecentActions() { synchronized(actions) { return new ArrayList<>(actions); } }

5.3 进一步优化

对于特别高并发的场景,可以考虑:

  1. 缓冲队列:先用ConcurrentLinkedQueue接收请求,再定期批量写入
  2. 分区处理:按用户ID分片到不同的List,减少锁竞争
  3. 异步写入:使用单独的写入线程处理
// 更高级的优化方案 ConcurrentMap<Integer, BlockingQueue<UserAction>> shardedQueues = new ConcurrentHashMap<>(); public void logAction(UserAction action) { int shard = action.userId() % 16; shardedQueues.computeIfAbsent(shard, k -> new LinkedBlockingQueue<>()) .offer(action); }

在实际项目中,选择哪种并发List取决于你的具体场景。记住没有放之四海而皆准的最佳方案,只有最适合当前需求的选择。我个人的经验是:对于大多数业务系统,synchronizedList已经足够好用;而对于配置中心、特征开关这类读多写少的场景,CopyOnWriteArrayList则能提供更好的读取性能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询