HashMap非线程安全,多线程下put/get或扩容易致死循环、数据丢失或ConcurrentModificationException;根本原因是JDK7链表头插法成环、JDK8迁移未加锁。

为什么直接在多线程里用 HashMap 会出问题
HashMap 不是线程安全的。多个线程同时执行 put()、get() 或触发扩容(resize())时,可能引发死循环(JDK 7)、数据丢失、或 ConcurrentModificationException。典型现象是程序卡死在某个线程、CPU 占用飙高,或者读到 null 值却没报错——这比崩溃更难排查。
根本原因是:扩容时链表头插法 + 多线程竞争导致环形链表(JDK 7),或节点迁移逻辑未加锁(JDK 8)。这不是偶发 bug,而是设计使然。
三种常用替代方案对比:选哪个取决于场景
不是所有并发场景都要上重量级锁。关键看读写比例、是否需要强一致性、以及是否允许短暂不一致:
-
ConcurrentHashMap:绝大多数情况首选。JDK 8 起用synchronized+ CAS 替代分段锁,读操作无锁,写操作粒度细化到桶(bin)。适合读多写少,且不要求全局原子性操作的场景。 -
Collections.synchronizedMap(new HashMap()):给整个 map 加一把大锁。读写都串行,吞吐量低,但能保证迭代时的线程安全(需手动同步entrySet().iterator())。仅适用于并发不高、或已有代码改造成本低的过渡方案。 -
java.util.concurrent.locks.ReentrantLock+ 手动保护HashMap:灵活性最高,但容易漏锁、死锁。除非你要做复合操作(比如“先 get 再 put”必须原子),否则不推荐——ConcurrentHashMap的computeIfAbsent()、replace()等方法已覆盖大部分需求。
ConcurrentHashMap 的几个关键用法和坑
它不是 HashMap 的简单线程安全版,行为有差异:
立即学习“Java免费学习笔记(深入)”;
-
size()返回的是估算值,可能不准;要精确计数请用mappingCount()(返回long)。 -
containsKey()和containsValue()是弱一致性:可能看不到其他线程刚写入的值,但不会抛异常或返回脏数据。 - 遍历时若用
for (Entry e : map.entrySet()),底层是弱一致性快照,不会抛ConcurrentModificationException,但可能漏掉新插入项——这点和HashMap迭代器行为完全不同。 - 避免用
new ConcurrentHashMap(initialCapacity)直接传初始容量。它的构造参数其实是「预估并发线程数」,真正控制桶数量的是concurrencyLevel(JDK 8 已弱化该参数,但传错仍影响初始化性能)。
什么时候真得自己加锁?一个典型例子
比如实现一个带过期时间的本地缓存,要求“查不到就加载并写入”,且加载过程耗时(如远程调用)。这时 ConcurrentHashMap.computeIfAbsent() 就很合适:
cache.computeIfAbsent(key, k -> {
// 这个 lambda 只会在 key 不存在时执行一次,且整个过程原子
return loadFromRemote(k);
});
但如果逻辑更复杂——例如要先检查某个标志位、再更新两个不同 key、最后发通知——那就超出 ConcurrentHashMap 能力范围了。此时建议用 ReentrantLock 包裹整个业务块,并注意锁粒度别太大(比如别锁住整个 map 实例,而应按 key 哈希分段锁)。
最常被忽略的一点:即使用了 ConcurrentHashMap,如果 value 是可变对象(比如 ArrayList),对 value 内部的修改仍然需要额外同步——容器线程安全 ≠ 元素线程安全。










