【Java并发】为什么ArrayList和HashMap不是线程安全的
面试高频问题: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<>();
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(); }
System.out.println("Size: " + list.size()); }
|
运行结果往往是:Size: 98534(每次不同,但几乎总是小于100000)
原因分析
看ArrayList的add方法源码:
1 2 3 4 5
| public boolean add(E e) { ensureCapacityInternal(size + 1); elementData[size++] = e; return true; }
|
size++ 不是原子操作,它实际上是三步:
- 读取size的值
- size + 1
- 写回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
| void transfer(Entry[] newTable) { for (Entry<K,V> e : table) { while (e != null) { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
|
单线程下头插法扩容:
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 ▲ │ └───────┘
|
之后任何对这个桶的查询都会陷入死循环:
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(); }
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;
}
|
并发问题:
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;
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); elementData[size++] = e; return true; }
|
关键问题:
- check-then-act:检查容量和实际添加不是原子操作
- 复合操作:
size++ 是读-改-写三步,不是原子操作
- 无同步保护:没有任何锁或volatile
HashMap不安全的根本原因
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| if ((p = tab[i]) == null) tab[i] = newNode(...);
++size;
table = newTab;
e.next = newTable[i]; newTable[i] = e;
|
解决方案
ArrayList的替代方案
| 方案 |
实现方式 |
适用场景 |
Vector |
synchronized方法 |
已过时,不推荐 |
Collections.synchronizedList() |
包装器+全局锁 |
简单场景 |
CopyOnWriteArrayList |
写时复制 |
读多写少 |
1 2 3 4 5
| List<String> list = Collections.synchronizedList(new ArrayList<>());
List<String> list = new CopyOnWriteArrayList<>();
|
HashMap的替代方案
| 方案 |
实现方式 |
适用场景 |
Hashtable |
synchronized方法 |
已过时,不推荐 |
Collections.synchronizedMap() |
包装器+全局锁 |
简单场景 |
ConcurrentHashMap |
分段锁/CAS |
高并发场景(推荐) |
1 2 3 4 5
| Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
Map<String, Integer> map = new ConcurrentHashMap<>();
|
面试回答要点
问:ArrayList为什么线程不安全?
答:
add() 方法中 size++ 不是原子操作,多线程可能导致size不准确
- 检查容量和添加元素不是原子操作,可能导致数组越界
- 可能读到null值(size增加了但元素还没写入)
- 迭代时修改会抛出ConcurrentModificationException
问:HashMap为什么线程不安全?
答:
- JDK 7:扩容时头插法可能导致链表成环,造成死循环,CPU 100%
- JDK 8:虽然改用尾插法避免了死循环,但仍有问题:
- put时桶为空的判断不是原子操作,可能覆盖数据
- size++不是原子操作,导致size不准确
- 扩容时其他线程可能访问到不完整的数据
- 通用问题:没有任何同步措施,所有复合操作都不是线程安全的
问:如何解决?
答:
- ArrayList →
CopyOnWriteArrayList(读多写少)或 Collections.synchronizedList()
- HashMap →
ConcurrentHashMap(推荐)或 Collections.synchronizedMap()