HashMap 在并发环境下可能出现哪些问题?
未标记
考查重点:并发写冲突与中间状态。无协调并发写会丢更新、计数不准或读到中间状态,遍历也可能异常;共享访问换并发容器或统一同步
本题目录
参考答案
核心回答
HashMap 自身不提供同步。如果多个线程无协调地并发读写,可能发生条目丢失、计数不准确、读取旧值或中间状态,以及遍历异常。根本原因是插入、链表修改、扩容和计数等步骤没有形成一个受保护的操作,也缺少必要的线程间可见性保证。
还要区分版本:JDK 7 的并发扩容存在头插迁移形成环链、导致查询无法结束的经典风险;JDK 8 改变了链表迁移方式,但 HashMap 仍不是线程安全容器,不能因为这个历史问题得到缓解就并发使用。
实际选择是让 Map 限于单线程、将构建好的只读 Map 安全发布,或对相关访问统一同步;需要共享并发更新时通常使用 ConcurrentHashMap。换成并发容器后,“先查再改”等业务复合操作仍要另外保证原子性。
不同键也可能互相覆盖
以 OpenJDK 25 的空桶插入路径作简化推演:键 A、B 不相等,但映射到同一个桶。两个线程都读取到桶为空,各自创建节点,随后先后把自己的节点赋给这个桶。后一次赋值可能覆盖前一次的入口,造成一条映射不可达。
这与单线程正常覆盖“同一个键的旧值”不同:这里即使是不同键,也会因共享桶的检查与修改没有同步而丢数据。类似竞争还可能发生在链表尾部修改和扩容迁移中。
计数也有独立问题。两个线程各新增一条映射,size 的递增却不是原子读改写,可能只增加一次。因此不能根据最终 size 判断所有插入都完整,也不能把“压测时数量刚好正确”当成线程安全证明。以上是操作交错的机制推演,具体故障是否出现取决于版本与执行时序。
为什么 get 和遍历也不能忽略
并发写入而读取方没有建立必要的 happens-before 关系时,不保证能看到期望的更新;扩容期间还涉及旧表、新表和节点迁移,普通 HashMap 没有为并发读取设计完整协议。
HashMap 迭代器在发现不符合约定的结构修改时,可能抛出 ConcurrentModificationException。但 fail-fast 是尽力诊断,既不是锁,也不保证每次竞争都被发现;没有抛异常并不表示结果正确。即使单线程在遍历期间直接向 Map 新增映射,也可能触发它,因此该异常本身也不能证明一定存在多个线程。
追问:只给引用加 volatile,或者只锁 put 行不行
把 Map 引用声明为 volatile,可以参与对象的发布和引用替换,但不会让后续对同一个 HashMap 的桶、链表和 size 修改自动变成原子操作。
只让写线程使用一把锁,而读线程仍无同步访问,也不普遍成立。应让相关访问遵循同一同步协议,例如统一使用同步包装并按要求持锁遍历;或者使用 ConcurrentHashMap,并用其原子方法完成单键的条件更新。多个键之间的业务不变量仍需要更大范围的协调。
如果数据在发布后确实不再修改,完成构建后通过合适方式安全发布的 HashMap 可以供多线程读取;“只读”的前提不能由“这个方法只调用 get”替代,仍需确认其他路径不会修改同一实例。
参考资料
- 国内:JavaGuide:Java 集合常见面试题(下):用于解释空桶插入竞争和 size 丢失更新,并将 JDK 7 环链问题与后续版本区分。
- 国内:JavaGuide:Java 内存模型:用于解释没有同步关系时的可见性问题,以及 volatile 引用无法替代容器内部并发协调的原因。
- 国内:美团技术团队:Java 8 系列之重新认识 HashMap:参考普通桶结构、结构修改和扩容,解释正文中 HashMap 缺少并发协调的风险;环链场景仍限定在 JDK 7。
- 国外:Oracle:HashMap,Java SE 25:用于核对外部同步要求及 fail-fast 无法保证每次检测到并发修改的边界。
- 国外:Java 语言规范第 17 章:Threads and Locks:用于核对数据竞争、锁和 volatile 的 happens-before 规则,区分可见性与复合操作原子性。




