为什么说ArrayList线程不安全?并发修改异常复现与分析
1. 复现并发修改异常
ArrayList 是 Java 开发中最常用的集合之一,但它并不是线程安全的。当多个线程同时对同一个 ArrayList 实例进行结构性修改(如添加、删除元素)时,很容易引发 java.util.ConcurrentModificationException 异常,或者导致数据丢失、数组越界等问题。
创建一个名为 ArrayListUnsafeDemo 的 Java 类,输入以下代码来模拟多线程并发场景。
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class ArrayListUnsafeDemo {
public static void main(String[] args) {
// 1. 初始化一个 ArrayList
List<String> list = new ArrayList<>();
// 2. 开启一个线程向列表中添加数据
new Thread(() -> {
for (int i = 0; i < 10; i++) {
list.add("Data-" + i);
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
}, "Thread-Add").start();
// 3. 开启另一个线程遍历列表
new Thread(() -> {
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
System.out.println("遍历元素: " + iterator.next());
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
}, "Thread-Iterator").start();
}
}
运行上述代码,控制台大概率会抛出如下异常信息:
Exception in thread "Thread-Iterator" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
at java.util.ArrayList$Itr.next(ArrayList.java:859)
at ArrayListUnsafeDemo.lambda$main$1(ArrayListUnsafeDemo.java:20)
...
2. 分析异常产生的原因
异常的核心在于 ArrayList 的迭代器机制。ArrayList 内部维护了一个名为 modCount 的变量,用于记录列表被结构性修改(如 add、remove)的次数。
当获取迭代器时,迭代器会将当前列表的 modCount 值赋给自身的 expectedModCount 变量。在调用 iterator.next() 方法时,系统会检查这两个值是否一致。
以下是迭代器内部检查逻辑的源码解析:
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
解析发生过程:
- 线程 A 获取迭代器,此时
modCount为 0,迭代器内的expectedModCount也为 0。 - 线程 B 执行
add操作,导致 ArrayList 的modCount自增为 1。 - 线程 A 继续执行
next()方法。 - 检测到
modCount(1) 不等于expectedModCount(0)。 - 抛出
ConcurrentModificationException。
我们可以通过以下流程图直观理解这一竞态条件:
记录 expectedModCount = modCount Note over List: 此时 modCount = N T_Add->>List: 执行 add() 操作 List->>List: modCount++ (变为 N+1) T_Itr->>List: 调用 next() List->>List: 检查 modCount == expectedModCount? alt 检查失败 List-->>T_Itr: 抛出 ConcurrentModificationException end
3. 深入源码探究
打开 ArrayList 的源码,查看 add 方法的实现,可以发现没有任何同步锁(如 synchronized)。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 确保容量足够
elementData[size++] = e; // 直接赋值
return true;
}
这里的 size++ 操作并非原子性。在多线程环境下,可能会发生以下两种常见的数据错误情况(即使没有抛出异常):
-
数据覆盖:
- 线程 A 读取
size为 10。 - 线程 B 读取
size为 10。 - 线程 A 在位置 10 放入元素 A。
- 线程 B 在位置 10 放入元素 B。
- 结果:元素 A 被覆盖,
size变为 11,但实际上丢失了一个数据。
- 线程 A 读取
-
数组越界:
ensureCapacityInternal判断容量足够。- 线程 A 和线程 B 同时通过了容量检查,但都需要扩容。
- 可能导致在复制数组时出现索引越界异常。
4. 线程安全的替代方案
为了保证多线程环境下的数据安全,有以下三种常用的解决方案:
| 方案 | 实现方式 | 性能特点 | 适用场景 |
|---|---|---|---|
| Vector | 继承自 AbstractList,所有方法都加 synchronized 锁 |
性能较差,锁粒度大 | 几乎不使用,遗留代码 |
| Collections.synchronizedList | 包装类,使用 synchronized 代码块锁 |
性能一般,锁粒度依然是大锁 | 需要兼容旧接口,读写都较频繁时 |
| CopyOnWriteArrayList | 写操作时复制一份新数组,读操作无锁 | 写性能低,读性能极高 | 读多写少的并发场景 |
推荐在并发场景下优先使用 CopyOnWriteArrayList。
修改之前的代码,使用 CopyOnWriteArrayList:
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;
public class SafeListDemo {
public static void main(String[] args) {
// 替换实现类
List<String> list = new CopyOnWriteArrayList<>();
// 添加数据线程
new Thread(() -> {
for (int i = 0; i < 10; i++) {
list.add("Data-" + i);
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
}).start();
// 遍历数据线程
new Thread(() -> {
for (String s : list) {
System.out.println("遍历元素: " + s);
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
}).start();
}
}
分析 CopyOnWriteArrayList 的原理:
- 写入:当调用
add时,它会加锁,复制出一个新的数组(长度+1),修改新数组,最后将引用指向新数组。这保证了写操作的原子性和可见性。 - 读取:直接读取当前数组引用,无需加锁,速度非常快。
由于写操作需要复制整个数组,如果数据量非常大且写操作非常频繁,会造成内存占用过高和 GC 压力。因此,务必在“读多写少”的场景下使用。

暂无评论,快来抢沙发吧!