在日常开发中,ArrayList 是我们最常用的集合类之一。它提供了动态数组的功能,使用方便、效率高。然而在多线程环境下,很多初学者可能会忽略一个关键问题:
ArrayList 是线程安全的吗?
答案是:不是!
本文将带你深入理解为什么 ArrayList 不是线程安全的,以及在多线程并发操作中可能引发哪些典型问题。不只是告诉你结论,而是让你彻底理解背后的原理。
一、什么是线程安全?
在多线程环境下,当多个线程同时访问某个对象,如果不通过任何额外的同步手段(如加锁),也不会出现数据不一致、异常崩溃等问题,我们就称这个对象是“线程安全”的。
相反,如果并发访问会导致数据错乱、覆盖、异常等问题,就是“线程不安全”的。
二、ArrayList 是线程安全的吗?
直接说结论:
❌ ArrayList 是线程不安全的。
它内部并没有对关键操作进行同步控制,也就是说在多个线程同时调用 add()、remove() 等方法时,不会自动进行加锁保护。
三、线程不安全会带来什么问题?
我们来看下面这段示例的代码:
import java.util.ArrayList;
import java.util.List;
public class Demo {
public static void main(String[] args) throws InterruptedException {
// 创建一个ArrayList用于存放整数
List<Integer> list = new ArrayList<>();
// 定义一个实现Runnable接口的内部类
class MyRunnable implements Runnable {
@Override
public void run() {
for (int i=0;i<100;i++){
list.add(i); //向list集合中添加元素
}
}
}
// 创建两个线程,每个线程都执行MyRunnable任务
Thread t1 = new Thread(new MyRunnable());
Thread t2 = new Thread(new MyRunnable());
// 启动线程
t1.start();
t2.start();
// 等待两个线程都执行完再往下执行
t1.join();
t2.join();
// 打印list内容
System.out.println(list);
System.out.println(list.size());
}
}
这段代码的目的是:通过两个线程并发地向一个共享的 ArrayList 添加整数 0 到 99,每个线程各添加一次,共计 200 个元素。理论上,若线程安全得到保证,list 的最终大小应该是 200,且每个整数 0 到 99 都会出现两次。
但实际执行中,结果往往是——
-
size与add的次数不一致,size<200
-
抛出ArrayIndexOutOfBoundsException数组越界异常 -
输出元素出现null
其中一次执行结果如下:
[0, 4, 5, 6, 7, 8, 9, 10, 1, 13, 2, 16, 17, 3, 19, 4, 24, 5, 26, 27, 28, 29, 8, 33, 9, 35, 10, 37, 38, 12, 40, 41, 14, 15, 16, 17, 18, 19, 20, 45, 46, 47, 48, 49, 50, 25, 52, 53, 54, 27, 69, 70, 28, 72, 29, 74, 30, 76, 77, 78, 79, 80, 33, 81, 82, 83, 36, 85, 37, 87, 88, 39, 90, 40, null, null, null, null, null, null, 98, 99, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99]
141
接下来我们将从多个角度分析线程不安全造成的问题以及其背后的根本原因:
1️⃣ 数据覆盖(脏数据)
“数据覆盖”,又叫“脏数据”,指的是在多线程并发操作时,一个线程添加(写入)的数据还没来得及被持久保留,另一个线程紧跟着用相同的位置写入了自己的数据,结果导致前面线程加进去的数据被后面线程的新数据盖掉了,前一个线程的写入被丢失。
原因分析:
我们来看 ArrayList 中 add() 方法的核心逻辑(已简化):
public boolean add(E e) {
// 1. 确保内部数组容量足够,若不够则进行扩容
ensureCapacityInternal(size + 1);
// 2. 将新元素 e 添加到数组的当前位置
elementData[size] = e;
// 3. 元素计数器 size 自增1,指向下一个可用位置
size++;
return true;
}
举个例子:
假设此时 size = 10,两个线程 A 和 B 几乎同时执行 list.add(),但它们的执行被 CPU 切换,操作交错进行:
| 步骤 | 线程A 操作 | 线程B 操作 |
|---|---|---|
| ① | 读取 size = 10 | |
| ② | 读取 size = 10 | |
| ③ | 写入 elementData[10] = A | |
| ④ | 写入 elementData[10] = B | |
| ⑤ | 更新 size +=1为11 | |
| ⑥ | 更新 size +=1为12 |
最终结果:
-
elementData[10]中只保留了一个值(A 或 B),另一个被覆盖,数据丢失; -
size实际上添加了两次元素,自增到了 12,但实际上只记录了一个; -
集合中的数据出现了脏写现象,也就是我们常说的“数据覆盖”。
本质问题:
-
elementData[size] = e和size++是两个独立步骤,没有被原子化封装; -
所以多个线程可能在同一个位置添加数据,互相覆盖,这就是“线程不安全”的典型体现。
结论:
当集合中出现
null元素时,往往是“脏数据”或“写丢失”的典型症状,反映了多个线程的写操作相互干扰,导致部分add操作被悄然丢弃。例如上述情况中,elementData[11]出现null,就是因为对应位置的写入未能成功完成。
2️⃣ 数组越界(ArrayIndexOutOfBoundsException)
在 Java 中,数组下标从 0 开始。如果你访问的下标小于 0 或大于等于数组的长度,JVM 就会抛出 ArrayIndexOutOfBoundsException,意思是“你想用非法下标访问数组”。
原因分析:
ArrayList 是基于数组实现的。每次添加元素时,都会先检查容量是否足够,然后再把元素放到数组的下一个位置,再把 size 增加 1。
但是,在多线程环境下,“检查容量”“写入数据”“size++”这几个关键步骤之间没有同步控制,不是原子操作。
如果两个线程几乎同时检测和写入,就有可能出现:
- 线程1和线程2都判断当前数组还有空间,于是都准备写入同一个下标;
- 线程1先写入,并把 size++;
- 线程2随后执行时,size 已经增加,试图在超出数组容量的位置写入新数据;
- 结果导致 Java 抛出 ArrayIndexOutOfBoundsException。
高风险并发场景示例
假设当前 ArrayList 的内部数组容量为 10,size = 9,即数组还有最后一个可用位置:
| 步骤 | 线程A 操作 | 线程B 操作 |
|---|---|---|
| ① | 检查:size = 9,容量足够 → 不扩容 | |
| ② | 检查:size = 9,容量足够 → 不扩容 | |
| ③ | 准备将元素写入 elementData[9] | |
| ④ | 写入 elementData[9]后,并执行 size++ → size = 10 | |
| ⑤ | 此时再写入 时 size = 10,尝试访问 elementData[10],准备将元素写入 elementData[10] | |
| ⑥ | 🚨 抛出 ArrayIndexOutOfBoundsException |
本质原因分析
-
ArrayList的add()方法中,判断容量、写入数据、更新 size 这几个步骤之间缺乏原子性; -
多个线程可能同时通过容量判断,然后抢占同一写入位置;
-
有线程已经写入并更新了
size,但另一个线程仍在基于旧的判断进行写入,结果访问了非法下标。
3️⃣ size 大小与 add 元素次数不符(并发丢值)
在多线程环境下使用
ArrayList.add(),即使程序没有抛出异常,也可能出现“添加了 N 次,最终 size 却小于 N”的诡异情况。
问题本质
在 Java 中,size++ 实际上是一个 非原子性操作,可以拆解为以下三个步骤:
int temp = size; // 读取当前值
temp = temp + 1; // 加一
size = temp; // 写回结果
模拟并发场景:线程 A 与线程 B 的冲突
假设我们使用两个线程向同一个 ArrayList 添加元素,初始 size = 9,数组容量充足,接下来看发生了什么:
| 步骤 | 线程 A 操作 | 线程 B 操作 |
|---|---|---|
| ① | 读取 size = 9 | |
| ② | 读取 size = 9 | |
| ③ | 写入数据到 elementData[9] | |
| ④ | 写入数据到 elementData[9](覆盖 A 写入内容) | |
| ⑤ | 执行 int temp = size;,temp = 9 | |
| ⑥ | 执行 int temp = size;,temp = 9 | |
| ⑦ | 执行 temp = temp + 1;,temp = 10 | |
| ⑧ | 执行 temp = temp + 1;,temp = 10 | |
| ⑨ | 执行 size = temp;,此时 size = 10 | |
| ⑩ | 执行 size = temp;,此时再次写入 size = 10 |
当线程 A 和 B 几乎同时执行这三步操作时,就会发生:
-
都读取到 size = 9
-
各自加 1 得到 10
-
都将 10 写回 size
结果就变成了:虽然执行了两次 add,但 size 只增加了 1。
更糟的是,两个线程还可能写入了相同的下标 elementData[9],导致前一个值被覆盖,后一个值“看似成功”,其实整体上丢掉了一次写入操作。
现象表现
-
list.size()小于实际执行的add()次数; -
list中部分数据丢失或重复(被覆盖); -
极难察觉,没有异常抛出但数据质量已受损。
四、 数据覆盖 vs. 并发丢值 —— 容易混淆?一表看懂!
💬 读者可能会疑惑:为什么在讲“数据覆盖”问题时,我们把 size++ 当作一个整体操作,而在讲“并发丢值”时却要将其拆解为三步?
实际上,这种差异是由于分析角度的侧重点不同。
“数据覆盖”聚焦于多个线程同时写入同一数组索引所导致的值被覆盖问题,因此我们关注的是elementData[size] = e的冲突,size++在此语境下只是顺带完成,并不深入分析。而“并发丢值”的前提是多个线程在读取到相同的 size 值后,同时进行自增操作。此时,每个线程都执行了
elementData[size] = e,一定会发生数据覆盖且由于size++不是原子操作,最终只有一个线程的自增结果被保留下来,另一个被覆盖,导致集合整体长度不准确。因此,在分析“并发丢值”时,必须细化
size++的底层三步操作:读取、加一、写回,才能解释为何多次add()最终只记录了一次,数据悄然丢失。
数据覆盖 vs 并发丢值 对比总结表
| 对比项 | 情况一:仅发生数据覆盖 | 情况二:发生数据覆盖 + 并发丢值 |
|---|---|---|
| 并发线程操作 | 两个线程读取相同的 size,依次写入同一索引 | 两个线程读取相同的 size,写入同一索引 + 并发更新 size |
| 写入位置 | 相同索引,后写覆盖前写 | 相同索引,后写覆盖前写 |
| size 自增 | 各自执行 size++,最终 size 增加 2 | size++ 被拆解为多步操作,写回被覆盖,最终只增加 1 |
| 最终 size 值 | 和实际add 次数相同(例如执行了 2 次 add,size 执行2次自增为 12,但只写入 1 个有效数据,还有一个为null) | 比实际 add 次数少(执行了 2 次 add,但 size 仍只增1次) |
| 数据表现 | 有效元素被覆盖,尾部出现空洞 (null) | 一个元素被覆盖,另一个直接丢失 |
| 本质问题 | elementData[size] = e 操作线程不安全 | elementData[size] = e + size++ 全流程线程不安全 |
| 典型后果 | 集合中出现null值 | 集合中出现丢值、数据不完整 |
| 影响范围 | 读到的数据可能不是自己写的 | 一些元素完全丢失,严重影响业务逻辑 |
需要特别说明的是:size++ 本质上在任何情况下都不是原子操作,它始终会被拆解为三步 —— 读取、加一、写回。但在仅发生“数据覆盖”而未发生“丢值”的场景中,并不意味着 size++ 就变成了原子操作,而是线程间刚好错开了对 size 的写入时机:
即:线程 A 完整执行完
size++的三步操作后,线程 B 才开始执行自己的size++,由于没有写回冲突,整个自增过程表面上“看似正常”,未出现异常。
但一旦两个线程几乎同时读取同一个 size 值并竞争写回阶段,就可能出现 size 的覆盖,导致并发丢值。因此,不要被“size 正常递增”所迷惑——这只是线程调度时机恰好未出错,并不能掩盖其潜在风险。
小结一句话:
-
“数据覆盖”:同一个位置被多个线程同时写,前一个线程写入的值被覆盖。
-
“并发丢值”:多个线程的
size++操作冲突,导致最终size小于实际执行的 add 次数,部分数据根本就没保存进来。
五、如何解决 ArrayList 线程不安全?
在并发场景下直接使用 ArrayList 极易引发各种线程安全问题,如数据覆盖、并发丢值、越界异常等。那么,有哪些可靠的解决方案呢?
我们将介绍最常见的三种解决方案,并讲清每种方案的原理与适用场景,最后附上对比表格,帮助你快速选型。
方案一:使用 Vector(方法级同步)
解决方式:
直接使用线程安全的集合类 Vector 替代 ArrayList。
List<String> list = new Vector<>();
原理解析:
Vector 所有方法(如 add()、get())都通过 synchronized 加锁,这意味着在同一时刻,只有一个线程能够执行 Vector 的某个方法。
public synchronized boolean add(E e) {
// 代码实现
}
优缺点
优点:
- 线程安全:
Vector的所有方法都是同步的,保证了在多线程环境下的安全性。 - 简单易用:使用
Vector不需要显式地处理锁定,开发者不需要担心并发问题。
缺点:
- 性能开销:由于所有方法都被同步,
Vector在高并发场景下可能导致性能瓶颈。对于频繁的读操作,任何尝试读取的线程都需要等待正在写入的线程完成,这可能会导致线程阻塞。 - 内存使用:
Vector的默认增长因子是 100%(即扩展为原有大小的两倍),这可能会导致在使用造成内存浪费,而ArrayList的默认增长策略更为平衡。
方案二:Collections.synchronizedList(包装同步)
解决方式:
通过工具类对 ArrayList 进行同步包装。
List<String> list = Collections.synchronizedList(new ArrayList<>());
原理解析:
Collections.synchronizedList 方法返回的是一个经过包装的线程安全 List,其内部是通过 synchronized 关键字 实现方法级同步:
-
对所有操作(如
add()、remove()、get()等)都进行了加锁保护; -
保证了在任一时刻,只有一个线程能访问列表的底层数据结构;
-
底层通过代理模式包装原始 List,例如:
public List<E> synchronizedList(List<E> list) {
// 创建并返回一个包装后的 SynchronizedList 实例
return new SynchronizedList<>(list);
}
static class SynchronizedList<E> implements List<E>, Serializable {
// 原始的非线程安全 List(如 ArrayList)
private final List<E> list;
//自定义的锁对象
private final Object mutex;
public synchronized boolean add(E e) {
synchronized (mutex) {
return list.add(e);
}
}
// 其他方法同理
}
优缺点
优点:
- 简单易用:只需将原始的
ArrayList传入Collections.synchronizedList方法,就可以很容易地创建一个线程安全的列表,避免了深入实现的复杂性。 - 适用于小规模并发:在多线程环境中处理少量线程和较少的并发操作时,性能开销相对可接受。
缺点:
-
迭代时需要手动同步:
Collections.synchronizedList仅在调用各个方法时自动同步,但在迭代整个列表时,仍需要程序员手动加锁。这是因为在迭代过程中可能会其他线程修改列表,导致ConcurrentModificationException。
synchronized (list) { // 必须加锁 for (String value : list) { // 处理值 } } -
性能瓶颈:所有的访问都被锁定,特别是在高并发环境下,这可能导致性能瓶颈。多个线程竞写相同的列表时,只有一个能够执行其他都需等待,从而可能影响整体性能。
-
可能会造成过度同步:在某些情况下,过度使用同步可能导致不必要的性能损失,如部分线程可能在处理读操作时被阻塞。
方案三:CopyOnWriteArrayList(写时复制)
解决方式:
使用并发包中的线程安全容器 CopyOnWriteArrayList 来替代 ArrayList。
List<String> list = new CopyOnWriteArrayList<>();
原理解析:
CopyOnWriteArrayList 实现了一种特殊的并发策略:写时复制(Copy-On-Write),即:
-
读取操作(如
get()、size())直接读取内部数组引用,无需加锁,因此非常高效且不会阻塞; -
写入操作(如
add()、remove()、set())则通过加锁实现互斥,每次写操作会:-
获取锁(
ReentrantLock); -
拷贝当前数组,创建新副本;
-
在副本上执行修改操作;
-
更新底层引用,使其指向新副本;
-
释放锁。
-
这种机制确保了:
-
写操作互斥,避免多线程并发写时数据互相覆盖;
-
读操作与写操作之间没有锁冲突,读线程访问的是旧数组,不受写入影响;
-
读操作不会看到数据结构的“中间状态”,避免了脏读、不可重复读等问题。
来看部分源码(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); // 替换底层引用
return true;
} finally {
lock.unlock(); // 🔓 解锁
}
}
虽然每次写操作都涉及数组的复制和替换,但 CopyOnWriteArrayList 使用了ReentrantLock 对写操作进行加锁保护,确保同一时刻只有一个线程能够执行写入逻辑,其余线程会被阻塞等待。
因此,即使多个线程同时执行写操作,每次都完整复制、修改和替换,彼此之间互不干扰,不会造成线程安全问题。
优缺点
优点:
-
线程安全:写操作加锁、读操作无锁,在并发环境下保证数据安全;
-
读性能极高:读操作无需加锁,且始终读取稳定的快照,适合读多写少的场景;
-
无读写冲突:写操作不会阻塞读操作,读操作也不会看到写操作的中间状态;
-
避免ConcurrentModificationException:由于迭代的是旧数组的副本,迭代过程中不受写入干扰。
缺点:
-
写操作性能差:每次写操作都复制整个数组,时间复杂度为 O(n),写入频繁时效率低;
-
内存开销大:频繁写操作会产生大量副本,占用内存并增加 GC 压力;
-
不适合写多的场景:在高频写入或大量数据变动的场景下,性能远不如其他并发集合(如
ConcurrentLinkedQueue)。
小结对比:
以下是 Vector、Collections.synchronizedList 和 CopyOnWriteArrayList 在解决线程不安全问题时的主要区别的表格:
| 特性 | Vector | Collections.synchronizedList | CopyOnWriteArrayList |
|---|---|---|---|
| 线程安全性 | 是 (所有方法被同步) | 是 (通过 synchronized 包装实现) | 是 (写时复制机制) |
| 方法实现 | 每个方法都实现为 synchronized | 使用 synchronizedList 的代理方法 | 通过创建数组副本更新实现 |
| 读取操作 | 受锁限制,多个线程读取时可能阻塞 | 受锁限制,需手动同步迭代 | 无锁读取,允许多个线程并发读取 |
| 写入操作 | 阻塞所有读取和写入 | 阻塞所有读取和写入 | 在新副本上写入,高成本但不阻塞读取 |
| 扩容策略 | 扩容为原有大小的 2 倍 | 根据原始列表的扩容策略 | 每次写入都复制当前数组,内存开销大 |
| 性能特点 | 在高并发写入情况下性能较差 | 读操作性能较差,需加锁 | 适合读多写少的场景,写操作开销高 |
| 内存使用 | 相对较低 | 内存开销与基础 List 实现相似 | 由于副本机制,内存消耗较高 |
| 适用场景 | 一般的线程安全需求 | 简单的线程安全需求 | 读多写少的场景 |
| 抛出异常 | 可能抛出 ConcurrentModificationException 在迭代 | 可能抛出 ConcurrentModificationException 迭代 | 不会抛出,基于快照进行迭代 |
附加说明
- Vector 是较老的集合类,自 Java 1.0 起便存在,它通过将所有方法都设为同步来实现线程安全,适用于简单的多线程环境。
- Collections.synchronizedList 允许将任何实现了
List接口的集合包装为线程安全的集合,通过对每个方法加锁,实现简单的线程安全功能。 - CopyOnWriteArrayList 是 JDK 1.5 引入的一种新型集合类,在高读并发场景中表现优异,通过写时复制策略处理写操作,因此在读操作时几乎不阻塞。
六、 总结
在多线程并发编程中,ArrayList 是非线程安全的,主要原因在于其底层操作没有同步机制,比如 add() 和 size++ 不是原子操作,可能导致数据丢失、越界异常、数据覆盖等问题。通过真实案例和源码分析,我们深入理解了线程不安全的本质。
为了应对这些风险,我们也介绍了三种常见的解决方案,包括使用早期的 Vector、通过 Collections.synchronizedList() 包装同步,以及更现代、更优雅的 CopyOnWriteArrayList 写时复制机制。每种方案都有其适用场景和局限性,理解它们背后的原理,是选择合适解决方案的前提。
需要特别注意的是,没有一种“万能”的线程安全容器。选择何种方案,取决于你的具体业务需求——是读多写少?是对性能敏感?还是需要更高的并发吞吐能力?只有理解清楚问题的本质、方案的特性,才能做出合理权衡。
总之,并发安全从来都不是一句 synchronized 就能解决的简单问题,而是架构设计与性能取舍的综合体现。
希望这篇文章能帮助你更深入理解 ArrayList 在并发场景下的风险与应对策略,为日后的高质量编程打下扎实基础。

9319

被折叠的 条评论
为什么被折叠?



