HashMap 用在并发场景中会出现什么问题?
结论先行:HashMap 没有任何同步措施,多线程读写会同时踩到三类问题:结构被破坏(JDK 7 扩容成环导致 CPU 100%,JDK 8 表现为丢数据)、可见性与计数错误(size 不准、读到旧值)、迭代器快速失败(ConcurrentModificationException)。它给出的不是“偶尔算错”,而是未定义行为。
1. 问题清单
| 现象 | 根因 | 受影响版本 | 后果 |
|---|---|---|---|
| CPU 100% 死循环 | 1.7 transfer 头插法并发扩容成环 | JDK 7 及更早 | 线程卡死、请求堆积 |
| 元素凭空消失 | 1.8 并发 put 覆盖同一桶的 next 指针 | JDK 8 | size 偏小、数据不一致 |
size 不准 | ++size 不是原子操作 | 全部 | 统计、限流判断失真 |
| 读到 null / 旧值 | table、size、modCount 非 volatile,无 happens-before | 全部 | 业务误判 |
ConcurrentModificationException | modCount 与 expectedModCount 不一致 | 全部 | 遍历中断 |
| 树结构异常 | 并发 treeify / putTreeVal | JDK 8 | 查询异常、性能骤降 |
2. JDK 7 的环是怎么形成的
两个线程同时触发扩容,旧桶:A -> B -> null
线程 A 执行 transfer,头插法先把 B 搬到新表:newTab[i] = B
线程 B 也在跑,读到 A.next 仍是 B,把 A 插到 B 前面
最终:A -> B -> A -> B ... 形成环
之后 get 一个不存在的 key,遍历链表永远走不到 null,CPU 打满
头插法会把原链表顺序反转,并发时两个线程互相看到对方的中间状态,就可能把 next 指针指回前面的节点。这是 JDK 7 及更早版本最著名的线上事故来源。
3. JDK 8 换成尾插,但问题没有消失
JDK 8 用 loHead/loTail、hiHead/hiTail 两条链尾插迁移,保持相对顺序,成环的前提不再成立。但两个线程同时对同一个桶 put 时,仍可能互相覆盖 next,表现为“put 返回成功、元素却不在表里”;++size 与 ++modCount 也不是原子操作,计数会偏小。扩容期间一个线程把 table 换成新数组,另一个线程还在旧数组上写,旧数组的修改直接丢失。
4. 代码:复现丢数据
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
public class HashMapRace {
public static void main(String[] args) throws InterruptedException {
Map<Integer, Integer> map = new HashMap<>();
int threads = 8, perThread = 50_000;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int t = 0; t < threads; t++) {
final int base = t * perThread;
new Thread(() -> {
try {
start.await(); // 尽量让 8 个线程同时开跑
for (int i = 0; i < perThread; i++) {
map.put(base + i, i);
}
} catch (InterruptedException ignored) {
} finally {
done.countDown();
}
}).start();
}
start.countDown();
done.await();
// 期望 400000,实际通常小于该值,且每次运行结果不同
System.out.println(map.size());
}
}
这段代码只是演示“不保证正确”,并不是稳定的复现脚本:丢多少取决于线程调度,有时甚至刚好凑够 400000,但下次可能就少了。生产代码不能靠这种偶然性。
5. 正确做法
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public class SafeMap {
public static void main(String[] args) {
// 方案一:并发容器,首选
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
chm.merge("k", 1, Integer::sum);
// 方案二:读多写少的列表
List<String> listeners = new CopyOnWriteArrayList<>();
listeners.add("audit");
// 方案三:必须用 HashMap 时,把锁加在唯一入口
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
synchronized (map) { // 复合操作仍要外部加锁
map.put("k", 1);
}
}
}
Collections.synchronizedMap 只保证单次方法调用原子,containsKey 再 put 这种组合依旧有竞态;迭代时还必须自己 synchronized (map),否则照样抛 CME。
常见追问
- JDK 8 还会死循环吗? 不会成环;但并发写仍会丢数据、
size错误,不能当线程安全容器用。 - 为什么 HashMap 不直接加锁? 单线程下加锁会白白付出开销,线程安全的需求交给 CHM,职责分离。
ConcurrentModificationException是多线程专属吗? 不是,单线程边 for-each 边remove同样会抛。- 只读场景需要同步吗? 安全发布之后只读是安全的;但“发布后还有人写”就必须同步。
- Hashtable 能替代吗? 能保证正确性,但整表锁吞吐差,新代码应选 CHM。
- 多线程只写不同 key 安全吗? 不安全,扩容会搬动整个数组,与 key 是否重叠无关。
小结
HashMap 的并发问题可以归成一句话:没有 volatile、没有原子性、没有锁,所以结构、计数、可见性三样都不可靠。JDK 7 的成环是“结构破坏”的极端表现,JDK 8 的丢数据是“更新丢失”的常态表现。解决办法只有三个层次:换 CHM、换同步包装并自己加锁、或者干脆不让多个线程共享同一个 Map。