抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

面试高频问题:ArrayList和HashMap为什么不是线程安全的?并发环境下会出现什么问题?本文从源码角度深入分析这两个集合在多线程下的各种问题。

ArrayList的并发问题

问题1:数据丢失

多线程同时add元素时,可能出现数据丢失。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(String[] args) throws InterruptedException {
List<Integer> list = new ArrayList<>();

// 启动1000个线程,每个线程添加100个元素
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 100; j++) {
list.add(j);
}
});
threads[i].start();
}

// 等待所有线程完成
for (Thread t : threads) {
t.join();
}

// 期望:100000,实际:远小于100000
System.out.println("Size: " + list.size());
}

运行结果往往是:Size: 98534(每次不同,但几乎总是小于100000)

原因分析

看ArrayList的add方法源码:

1
2
3
4
5
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 步骤1:确保容量
elementData[size++] = e; // 步骤2:赋值并递增size
return true;
}

size++ 不是原子操作,它实际上是三步:

  1. 读取size的值
  2. size + 1
  3. 写回size
1
2
3
4
5
6
7
8
9
10
11
12
13
14
假设当前 size = 10

线程A 线程B
───────────────────────────────────────────
读取 size = 10
读取 size = 10
计算 10 + 1 = 11
计算 10 + 1 = 11
写入 size = 11
elementData[10] = valueA
写入 size = 11 ← 覆盖了!
elementData[10] = valueB ← 覆盖了valueA!

结果:两个线程都写入了位置10,size只增加了1

问题2:数组越界异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
List<Integer> list = new ArrayList<>();

// 两个线程同时添加
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
list.add(i);
}
}).start();

new Thread(() -> {
for (int i = 0; i < 10000; i++) {
list.add(i);
}
}).start();

可能抛出:

1
2
Exception in thread "Thread-0" java.lang.ArrayIndexOutOfBoundsException: 15
at java.util.ArrayList.add(ArrayList.java:463)

原因分析

1
2
3
4
5
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 检查容量
elementData[size++] = e; // 赋值
return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
假设当前 size = 9, elementData.length = 10(还能放1个)

线程A 线程B
─────────────────────────────────────────────────
ensureCapacityInternal(10)
// 容量够,不扩容
ensureCapacityInternal(10)
// 容量够,不扩容
elementData[9] = valueA
size = 10
elementData[10] = valueB
// 数组越界!数组长度只有10

两个线程都检查通过了(都认为不需要扩容),但第二个线程写入时数组已满。

问题3:读到null值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
List<Integer> list = new ArrayList<>();

// 写线程
new Thread(() -> {
for (int i = 0; i < 10000; i++) {
list.add(i);
}
}).start();

// 读线程
new Thread(() -> {
for (int i = 0; i < list.size(); i++) {
Integer value = list.get(i);
if (value == null) {
System.out.println("读到了null!index=" + i);
}
}
}).start();

原因分析

1
2
3
4
5
6
7
8
9
线程A (写)                         线程B (读)
─────────────────────────────────────────────────
size = 10
// 还没来得及写入elementData[10]
读取 size = 10
list.get(9) → 正常
// 循环继续...
list.get(10) → 读到null!
elementData[10] = value

size先增加了,但值还没写入,读线程就读到了null。

问题4:ConcurrentModificationException

遍历时修改会触发快速失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));

// 一个线程遍历
new Thread(() -> {
for (String s : list) {
System.out.println(s);
}
}).start();

// 另一个线程修改
new Thread(() -> {
list.add("d");
}).start();
1
2
Exception in thread "Thread-0" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)

原因分析

ArrayList有一个 modCount 字段记录修改次数。迭代器创建时会保存当前的 modCount,每次迭代都会检查:

1
2
3
4
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}

这是快速失败(fail-fast)机制,目的是尽早发现并发修改问题。

HashMap的并发问题

HashMap的并发问题更加严重,特别是在JDK 7中。

JDK 7:死循环(最严重的问题)

JDK 7的HashMap在扩容时采用头插法,多线程并发扩容可能导致链表成环,之后查询该位置会陷入死循环,CPU 100%。

头插法扩容过程

1
2
3
4
5
6
7
8
9
10
11
12
// JDK 7 扩容的transfer方法(简化)
void transfer(Entry[] newTable) {
for (Entry<K,V> e : table) {
while (e != null) {
Entry<K,V> next = e.next; // 1. 保存next
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; // 2. 头插:当前节点指向新桶的头
newTable[i] = e; // 3. 当前节点成为新桶的头
e = next; // 4. 处理下一个节点
}
}
}

单线程下头插法扩容:

1
2
3
4
5
6
7
原链表:A → B → C (假设扩容后仍在同一个桶)

处理A:newTable[i] = A, A.next = null
处理B:newTable[i] = B, B.next = A
处理C:newTable[i] = C, C.next = B

结果:C → B → A (顺序反转,但没问题)

并发下链表成环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
初始状态:桶[3] → A → B → null

线程1 线程2
─────────────────────────────────────────────────
e = A, next = B
// 线程1被挂起...
// 线程2完成整个扩容
// 扩容后:B → A → null
// (因为头插法,顺序反转)

// 线程1恢复,继续用旧的e和next
e = A, next = B(但现在B.next = A了!)

处理A:
newTable[i] = A
A.next = null

e = B(next指向B)

处理B:
newTable[i] = B
B.next = A // B → A

e = A(因为线程2把B.next改成了A)

处理A(又处理A!):
newTable[i] = A
A.next = B // A → B

结果:A → B → A → B → ... 死循环!

图示:

1
2
3
4
5
6
7
8
9
正常链表:
A → B → null

成环后:
┌───────┐
│ ▼
A ───▶ B
▲ │
└───────┘

之后任何对这个桶的查询都会陷入死循环:

1
map.get(key);  // 如果key落在成环的桶,永远循环,CPU 100%

JDK 8:不再死循环,但仍有问题

JDK 8改用尾插法,避免了链表成环,但并发问题依然存在。

问题1:数据丢失

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) throws InterruptedException {
Map<Integer, Integer> map = new HashMap<>();

Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
final int start = i * 100;
threads[i] = new Thread(() -> {
for (int j = 0; j < 100; j++) {
map.put(start + j, j);
}
});
threads[i].start();
}

for (Thread t : threads) {
t.join();
}

// 期望:10000,实际:小于10000
System.out.println("Size: " + map.size());
}

原因分析

看JDK 8的putVal源码关键部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;

// ...

if ((p = tab[i = (n - 1) & hash]) == null) // 检查桶是否为空
tab[i] = newNode(hash, key, value, null); // 为空则直接放入
else {
// 桶不为空,遍历链表/红黑树...
}

// ...

++size; // size递增,非原子操作

// ...
}

并发问题:

1
2
3
4
5
6
7
8
9
10
假设桶[5]为空

线程A 线程B
─────────────────────────────────────────────────
检查 tab[5] == null ✓
检查 tab[5] == null ✓
tab[5] = nodeA
tab[5] = nodeB // 覆盖了nodeA!

结果:nodeA丢失

问题2:size不准确

++size--size 都不是原子操作:

1
2
3
4
5
6
7
8
线程A                              线程B
─────────────────────────────────────────────────
读取 size = 100
读取 size = 100
size = 101
size = 101 // 应该是102

结果:插入了2个元素,size只增加了1

问题3:扩容时数据丢失

1
2
3
4
5
6
7
8
9
10
11
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// ...
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; // 先把table指向新数组

if (oldTab != null) {
// 然后转移数据...
}
return newTab;
}
1
2
3
4
5
6
7
8
线程A(扩容)                        线程B(put)
─────────────────────────────────────────────────
table = newTab(新数组,还是空的)
// 还没转移数据...
往table(新数组)put数据
成功放入 newTab[x]
继续转移旧数据到newTab
可能覆盖线程B刚放入的数据!

问题4:get到null

1
2
3
4
5
6
7
线程A(扩容)                        线程B(get)
─────────────────────────────────────────────────
table = newTab(空数组)
get(key)
// 在新数组找不到,返回null
// 但数据其实在旧数组里!
转移数据中...

源码层面的总结

ArrayList不安全的根本原因

1
2
3
4
5
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 问题1:check-then-act不是原子
elementData[size++] = e; // 问题2:size++不是原子
return true; // 问题3:两步操作之间可能被打断
}

关键问题:

  1. check-then-act:检查容量和实际添加不是原子操作
  2. 复合操作size++ 是读-改-写三步,不是原子操作
  3. 无同步保护:没有任何锁或volatile

HashMap不安全的根本原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 桶为空的检查和放入不是原子操作
if ((p = tab[i]) == null)
tab[i] = newNode(...); // 可能覆盖其他线程的数据

// 2. size操作不是原子
++size;

// 3. 扩容过程不是原子
table = newTab; // 先切换引用
// 然后转移数据(期间其他线程可能访问不完整的数据)

// 4. JDK 7的头插法导致链表成环
e.next = newTable[i]; // 并发下可能形成环
newTable[i] = e;

解决方案

ArrayList的替代方案

方案 实现方式 适用场景
Vector synchronized方法 已过时,不推荐
Collections.synchronizedList() 包装器+全局锁 简单场景
CopyOnWriteArrayList 写时复制 读多写少
1
2
3
4
5
// 方案1:同步包装器
List<String> list = Collections.synchronizedList(new ArrayList<>());

// 方案2:写时复制(推荐用于读多写少)
List<String> list = new CopyOnWriteArrayList<>();

HashMap的替代方案

方案 实现方式 适用场景
Hashtable synchronized方法 已过时,不推荐
Collections.synchronizedMap() 包装器+全局锁 简单场景
ConcurrentHashMap 分段锁/CAS 高并发场景(推荐)
1
2
3
4
5
// 方案1:同步包装器
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());

// 方案2:并发HashMap(推荐)
Map<String, Integer> map = new ConcurrentHashMap<>();

面试回答要点

问:ArrayList为什么线程不安全?

答:

  1. add() 方法中 size++ 不是原子操作,多线程可能导致size不准确
  2. 检查容量和添加元素不是原子操作,可能导致数组越界
  3. 可能读到null值(size增加了但元素还没写入)
  4. 迭代时修改会抛出ConcurrentModificationException

问:HashMap为什么线程不安全?

答:

  1. JDK 7:扩容时头插法可能导致链表成环,造成死循环,CPU 100%
  2. JDK 8:虽然改用尾插法避免了死循环,但仍有问题:
    • put时桶为空的判断不是原子操作,可能覆盖数据
    • size++不是原子操作,导致size不准确
    • 扩容时其他线程可能访问到不完整的数据
  3. 通用问题:没有任何同步措施,所有复合操作都不是线程安全的

问:如何解决?

答:

  • ArrayList → CopyOnWriteArrayList(读多写少)或 Collections.synchronizedList()
  • HashMap → ConcurrentHashMap(推荐)或 Collections.synchronizedMap()