
arraylist 在多线程环境下执行 add() 操作时非原子性,会导致元素丢失、size 错误或 concurrentmodificationexception,根本原因在于扩容与写入分离、缺乏同步机制。本文深入剖析问题本质,并提供 synchronizedlist、copyonwritearraylist 等工业级解决方案。
arraylist 在多线程环境下执行 add() 操作时非原子性,会导致元素丢失、size 错误或 concurrentmodificationexception,根本原因在于扩容与写入分离、缺乏同步机制。本文深入剖析问题本质,并提供 synchronizedlist、copyonwritearraylist 等工业级解决方案。
在 Java 并发编程中,ArrayList 是最常用也最容易“踩坑”的集合类之一。正如《Head First Java》中的示例所示:两个线程并发向同一个 ArrayList 添加 26 个字符(共 52 次 add),最终却可能出现 size = 51 甚至更低的结果——部分元素“神秘消失”。这不是偶发 Bug,而是 ArrayList 底层设计决定的必然行为。
? 问题根源:add() 不是原子操作
ArrayList.add(E) 方法看似简单,实则包含两个关键步骤(以 JDK 8+ 为例):
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 步骤1:检查并可能扩容(非原子!)
elementData[size++] = e; // 步骤2:赋值 + size 自增(非原子!)
return true;
}
- ensureCapacityInternal():当数组满时触发扩容——创建新数组、复制旧数据、更新引用。该过程耗时且涉及内存分配。
- elementData[size++] = e:看似一行代码,实为“读取 size → 写入 elementData[size] → size 加 1”三步,无任何锁保护。
在多线程场景下,这两个步骤均可能被并发打断。典型竞态(Race Condition)示例如下:
| 时间 | 线程 A | 线程 B |
|---|---|---|
| t₁ | 执行 ensureCapacityInternal,发现容量足够,准备写入 | — |
| t₂ | 写入 elementData[0] = 'a',但尚未执行 size++ | 同时执行 ensureCapacityInternal(仍见 size=0)→ 准备写入 elementData[0] |
| t₃ | 执行 size++ → size=1 | 执行 elementData[0] = 'A' → 覆盖 'a' |
| t₄ | — | 执行 size++ → size=2 |
结果:两个元素写入同一位置,size=2,但实际仅存 'A' —— 'a' 被覆盖丢失。这正是示例中字符缺失(如 'K')的根本原因。更严重的是,若扩容过程中被中断,还可能引发 ArrayIndexOutOfBoundsException 或 NullPointerException。
✅ 安全解决方案对比与实践
方案一:Collections.synchronizedList() —— 简单粗暴的同步包装
适用于写操作较少、读写混合且需严格顺序保证的场景。它为所有 public 方法加 synchronized,但遍历时需手动同步:
List<string> safeList = Collections.synchronizedList(new ArrayList());
// ✅ 安全添加
safeList.add("item");
// ⚠️ 遍历必须显式同步!否则仍可能抛 ConcurrentModificationException
synchronized (safeList) {
for (String s : safeList) {
System.out.println(s);
}
}</string>
? 注意:synchronizedList 仅保证单个方法原子性,复合操作(如 if (!list.contains(x)) list.add(x))仍需外部同步块。
方案二:CopyOnWriteArrayList —— 读多写少的高性能选择
专为高并发读、低频写的场景设计。每次 add/remove 时复制整个数组,写操作线程安全,读操作完全无锁:
List<string> cowList = new CopyOnWriteArrayList();
// ✅ 多线程安全添加(内部已处理同步与复制)
cowList.add("item1");
cowList.add("item2");
// ✅ 读操作无需同步,支持高效迭代
for (String s : cowList) { // 安全!不会抛 CME
System.out.println(s);
}</string>
✅ 优势:迭代绝对安全,无 ConcurrentModificationException;
❌ 缺陷:写操作内存开销大(复制数组)、实时性弱(迭代器看到的是快照);
? 适用:监听器列表、配置白名单、缓存元数据等读远多于写的场景。
方案三:显式 synchronized 块(精细控制)
当需对多个操作组合加锁,或已有共享锁对象时:
private final List<string> list = new ArrayList();
private final Object lock = new Object();
public void safeAdd(String item) {
synchronized (lock) {
list.add(item);
// 可在此处添加其他关联逻辑,保持原子性
if (list.size() > 100) cleanup();
}
}</string>
? 最佳实践总结
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 单线程或明确串行访问 | ArrayList | 零开销,性能最优 |
| 读写均衡、需强一致性 | Collections.synchronizedList() | 兼容性强,语义清晰 |
| 读 >> 写、迭代频繁 | CopyOnWriteArrayList | 无锁读,规避 CME,开发简洁 |
| 高频写、低延迟要求 | ConcurrentLinkedQueue(若允许队列语义)或分段锁自定义结构 | ArrayList 天然不适合高频并发写 |
⚠️ 避免陷阱:
- ❌ 不要使用 Vector(历史遗留类,方法级 synchronized 性能差,已不推荐);
- ❌ 不要仅靠 synchronized 修饰 addLetter() 方法——若 getLetters() 返回原始 ArrayList 引用,外部仍可绕过同步直接操作;
- ✅ 始终封装集合字段为 private final,通过受控方法暴露行为。
回到《Head First Java》的示例:只需将 new ArrayList() 替换为 new CopyOnWriteArrayList(),即可彻底解决元素丢失问题,且无需修改业务逻辑——这是现代 Java 并发编程的优雅解法。
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南











