ArrayList 线程安全问题:多线程下的隐形陷阱
一句话警告
ArrayList 是非线程安全的。在多线程环境下使用它,就像在没有红绿灯的十字路口开车——偶尔可能没事,但迟早会出事。
三个经典的线程安全问题
问题 1:数据丢失
两个线程同时 add(),可能互相覆盖:
java
ArrayList<Integer> list = new ArrayList<>();
// 线程 A
list.add(1);
// 线程 B
list.add(2);
// 期望 size = 2
// 实际?可能 size = 1!具体发生了什么:
java
// 线程 A 执行 add
elementData[size++] = 1;
// ↑
// size 刚被读取,还没来得及递增
// 线程 B 也读取了同样的 size
// 线程 B 执行 add
elementData[size++] = 2;
// ↑
// 线程 B 用的 size 和线程 A 相同
// 结果:线程 B 覆盖了线程 A 的元素问题 2:size 不准确
即使没有覆盖,size 也可能错乱:
java
// 线程 A: size++ 分解为 3 步
int temp = size; // 读取
temp = temp + 1; // 加 1
size = temp; // 写回
// 如果线程 A 还没写回,线程 B 也读了 size
// 两个线程都读到 0,都写回 1
// 实际加了 2 个元素,但 size = 1问题 3:数组越界(扩容竞争)
最危险的情况:
java
// 两个线程同时触发扩容
// 线程 A 和 B 都发现容量不够,开始扩容
// 扩容时需要复制数组
// 但如果操作交叉执行,可能导致数组下标越界并发问题演示
java
import java.util.*;
import java.util.concurrent.*;
public class ConcurrentProblemDemo {
public static void main(String[] args) throws InterruptedException {
int threads = 100;
int perThread = 10000;
// 测试数据丢失
ArrayList<Integer> unsafe = new ArrayList<>();
CountDownLatch latch = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
new Thread(() -> {
for (int j = 0; j < perThread; j++) {
unsafe.add(j);
}
latch.countDown();
}).start();
}
latch.await();
int expected = threads * perThread;
int actual = unsafe.size();
System.out.println("预期: " + expected);
System.out.println("实际: " + actual);
System.out.println("丢失: " + (expected - actual));
}
}典型输出:
预期: 1000000
实际: 973451
丢失: 26549丢失了几万个元素,而且每次运行结果都不一样。
三种解决方案
方案 1:Collections.synchronizedList
包装一层同步,所有操作都加锁:
java
List<String> safe = Collections.synchronizedList(new ArrayList<>());
safe.add("a");
safe.add("b");
// 所有操作都是线程安全的特点:
- ✅ 线程安全
- ❌ 所有操作都加锁,高并发下性能差
- ❌ 迭代时仍需要额外同步
java
// 迭代时必须手动同步
synchronized (safe) {
for (String s : safe) {
System.out.println(s);
}
}方案 2:CopyOnWriteArrayList
写时复制。写操作会复制整个数组,读操作不加锁:
java
List<String> cow = new CopyOnWriteArrayList<>();
cow.add("a");
cow.add("b");
// 读操作,无需加锁
for (String s : cow) {
System.out.println(s);
}特点:
- ✅ 读操作完全无锁,极快
- ✅ 迭代安全,不会抛出 ConcurrentModificationException
- ❌ 写操作需要复制整个数组,慢
- ❌ 读到的可能是旧数据(最终一致性)
适用场景:读多写少(读占比 90% 以上)。
方案 3:使用 ConcurrentHashMap 存索引
如果需要并发写入元素:
java
// 把 ArrayList 换成 ConcurrentHashMap
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
AtomicInteger counter = new AtomicInteger(0);
// 多线程安全地添加
for (int i = 0; i < 100; i++) {
map.put(counter.incrementAndGet(), "value" + i);
}性能对比
java
import java.util.*;
import java.util.concurrent.*;
public class ThreadSafePerformanceDemo {
public static void main(String[] args) throws Exception {
int threads = 10;
int ops = 100_000;
// ArrayList(非安全)
System.out.println("ArrayList: " + benchmark(
() -> new ArrayList<Integer>(), threads, ops));
// Collections.synchronizedList
System.out.println("synchronizedList: " + benchmark(
() -> Collections.synchronizedList(new ArrayList<Integer>()), threads, ops));
// CopyOnWriteArrayList
System.out.println("CopyOnWriteArrayList: " + benchmark(
() -> new CopyOnWriteArrayList<Integer>(), threads, ops));
}
static String benchmark(Supplier<List<Integer>> factory, int threads, int ops)
throws Exception {
List<List<Integer>> lists = new ArrayList<>();
CountDownLatch latch = new CountDownLatch(threads);
long start = System.nanoTime();
for (int t = 0; t < threads; t++) {
new Thread(() -> {
List<Integer> list = factory.get();
for (int i = 0; i < ops; i++) {
list.add(i);
}
lists.add(list);
latch.countDown();
}).start();
}
latch.await();
return (System.nanoTime() - start) / 1_000_000 + " ms";
}
}典型结果(10 线程,每线程 10 万次添加):
ArrayList: ~200 ms(但有数据丢失)
synchronizedList: ~800 ms
CopyOnWriteArrayList: ~3000 ms(写操作复制数组)避坑指南
| 误区 | 正确做法 |
|---|---|
| 「ArrayList 不会出问题,因为我没遇到过」 | 偶发性 bug更难排查,多线程下必出问题 |
「加了 synchronized 就高枕无忧了」 | synchronizedList 迭代时还需手动同步 |
| 「CopyOnWrite 适合所有并发场景」 | 只适合读多写少(90% 以上读) |
| 「ConcurrentHashMap 比 Hashtable 快」 | ConcurrentHashMap 快 5-10 倍,且更现代 |
总结:选哪个?
单线程 → ArrayList
多线程,读多写少 → CopyOnWriteArrayList
多线程,写操作多 → Collections.synchronizedList
或者,换个思路:用 ConcurrentHashMap 代替