在 Java 面试中,HashMap 和 ConcurrentHashMap 几乎是绕不开的话题。很多人只会回答一句:
HashMap 线程不安全,ConcurrentHashMap 线程安全。
但继续追问"到底哪里不安全""加了什么锁""用了 ConcurrentHashMap 是否就万事大吉",往往就答不清了。
本文从实际并发写入场景出发,一次讲清两者的区别。
一、先说结论
HashMap 没有提供并发控制。多个线程同时执行 put、扩容或修改同一个桶时,可能出现:
-
数据被覆盖或丢失;
-
读取到旧值,存在内存可见性问题;
-
链表、红黑树等内部结构在竞争中出现异常状态;
-
JDK 7 中并发扩容甚至可能形成环形链表,导致查询死循环。
JDK 8 的 ConcurrentHashMap 主要通过以下机制解决:
-
volatile保证关键数据的可见性; -
CAS完成无竞争场景下的原子更新; -
synchronized锁住发生冲突的单个桶,而不是整张表; -
扩容时通过
ForwardingNode和多线程协作完成数据迁移。
因此,它既能保证线程安全,也比"给整个 Map 加一把大锁"拥有更好的并发性能。
二、HashMap 为什么线程不安全?
HashMap 在 JDK 8 中由"数组 + 链表 + 红黑树"组成。调用 put 时,大致需要经历:
-
根据 key 计算哈希值;
-
定位数组中的桶;
-
桶为空则插入节点;
-
桶不为空则遍历链表或红黑树;
-
达到阈值后触发扩容。
这些步骤并不是一个不可分割的原子操作。如果多个线程交叉执行,就会出现问题。
1. 并发 put 可能造成数据丢失
假设线程 A 和线程 B 同时向同一个空桶写入不同的 key:
线程 A:发现 table[i] == null
线程 B:发现 table[i] == null
线程 A:写入节点 A
线程 B:写入节点 B
两个线程都认为桶为空,后写入的节点可能覆盖先写入的节点,最终只保留一份数据。这就是典型的竞态条件。
即使两个线程操作不同的桶,在同时更新 size、判断扩容阈值时,也可能出现统计不准确等问题。
2. 扩容不是线程安全的
当元素数量超过 capacity × loadFactor 时,HashMap 会创建更大的数组,并把旧数组中的节点迁移过去。
扩容涉及创建新表、迁移节点和替换表引用等多个步骤。多个线程同时扩容时,可能基于不同的中间状态操作,造成节点丢失或结构异常。
这里要区分 JDK 版本:
-
JDK 7 使用头插法迁移链表,并发扩容可能使链表形成环,后续
get一直循环; -
JDK 8 改为尾插与高低位拆分,规避了 JDK 7 经典的环形链表问题,但
HashMap仍没有并发安全保证,不能因此用于并发写入。
"JDK 8 的 HashMap 不会形成 JDK 7 那种环"不等于"JDK 8 的 HashMap 线程安全"。
3. 还存在内存可见性问题
HashMap 的普通读写没有建立必要的 happens-before 关系。一个线程完成 put 后,另一个线程不一定立即看到最新结果。
因此,问题不只是"同时修改导致冲突",还包括"一个线程写了,另一个线程未必及时看见"。
下面这段代码的结果就不可靠:
Map<Integer, Integer> map = new HashMap<>();
IntStream.range(0, 10_000)
.parallel()
.forEach(i -> map.put(i, i));
System.out.println(map.size());
理论上应该得到 10000,实际可能小于该值,甚至可能出现其他异常。不要把它当成稳定复现脚本,因为并发问题与运行环境、线程调度和 JDK 版本有关。
三、直接给 HashMap 加锁不行吗?
可以使用:
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
它会用互斥锁保护常用方法,实现方式简单,但并发访问基本围绕同一把锁展开。线程数量较多、读写频繁时,锁竞争会比较明显。
另一种方式是手动加锁:
synchronized (map) {
map.put("order:1001", "PAID");
}
这种方式容易把锁的范围写得过大,也要求所有访问者遵守同一套加锁规则。实际并发项目中,通常优先考虑 ConcurrentHashMap。
四、ConcurrentHashMap 如何解决?
1. JDK 7:Segment 分段锁
JDK 7 的 ConcurrentHashMap 采用 Segment 数组。每个 Segment 类似一张小型 HashMap,并继承自 ReentrantLock。
不同 key 如果落在不同 Segment,可以被多个线程同时修改;只有落入同一 Segment 的写操作才需要竞争同一把锁。这种设计比锁住整张表更细粒度,因此被称为分段锁。
2. JDK 8:CAS + synchronized
JDK 8 取消了 Segment,数据结构改为与 HashMap 相似的"数组 + 链表 + 红黑树",但并发控制更加精细。
桶为空:使用 CAS 插入
线程定位到的桶为空时,会使用 CAS 尝试放入新节点。
CAS 可以理解为:只有当当前位置仍然是我刚才看到的值时,才执行更新。成功则无需加锁;失败说明其他线程已经修改,当前线程重新尝试。
这使没有哈希冲突的写入可以减少锁竞争。
桶不为空:锁住桶的头节点
如果目标桶中已经存在节点,说明需要遍历或修改链表、红黑树。此时 JDK 8 会对桶的头节点使用 synchronized:
synchronized (f) {
// 修改当前桶中的链表或红黑树
}
锁的只是当前桶。多个线程操作不同桶时,仍然可以并发执行。
需要注意:这里使用 synchronized 并不代表性能差。JDK 对内置锁进行了大量优化,而且桶级锁的范围较小、竞争也比全表锁低。
volatile:保证读取可见性
ConcurrentHashMap 对表引用及节点中的关键字段采用了 volatile 语义,并配合专门的原子访问方法,使线程能够正确观察其他线程发布的节点和更新结果。
它的读取操作通常不需要获取互斥锁,因此在读多写少的场景下具有较好的吞吐量。
扩容:多个线程协作迁移
ConcurrentHashMap 扩容时,会在已经迁移的桶中放置 ForwardingNode,表示该桶的数据正在或已经迁移到新表。
其他线程访问该位置时,可以识别迁移状态;写线程还可能参与 helpTransfer,共同完成扩容,而不是全部等待一个线程迁移整张表。
这种设计既避免旧表被无保护地并发修改,也降低了大表扩容时单线程迁移的压力。
五、用了 ConcurrentHashMap,组合操作就一定安全吗?
不一定。
ConcurrentHashMap 保证 get、put、remove 等单个方法具有线程安全语义,但多个方法组合起来不一定是原子的。
下面是经典错误:
if (!map.containsKey(key)) {
map.put(key, value);
}
线程 A 和线程 B 可能同时判断 key 不存在,然后都执行 put。如果业务要求"只初始化一次",应该改为:
map.putIfAbsent(key, value);
需要根据旧值计算新值时,可以使用:
map.compute(key, (k, oldValue) ->
oldValue == null ? 1 : oldValue + 1
);
计数场景还可以结合 LongAdder:
ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();
counter.computeIfAbsent("success", key -> new LongAdder())
.increment();
这比"先 get、再加一、最后 put"更加可靠。
六、为什么 ConcurrentHashMap 不允许 null?
HashMap 允许 key 和 value 为 null,而 ConcurrentHashMap 不允许。
在并发环境中,如果 get(key) 返回 null,无法仅凭结果判断:
-
这个 key 不存在;
-
key 存在,但对应 value 就是
null; -
value 刚刚被其他线程删除。
虽然还可以调用 containsKey,但两次调用之间状态可能已经改变。禁止 null 可以消除这种歧义,让 get 返回 null 明确表示当前没有映射。
七、工程中应该怎么选?
使用 HashMap
-
方法内的局部变量,不会被多个线程共享;
-
初始化完成后只读,并且对象被安全发布;
-
已经由外部锁完整保护所有访问。
使用 ConcurrentHashMap
-
本地缓存、连接信息、在线用户表等共享状态;
-
多线程需要频繁读取和更新;
-
需要
putIfAbsent、computeIfAbsent、merge等原子复合操作。
但要注意,ConcurrentHashMap 只解决单个 JVM 内的并发问题。如果应用部署了多个实例,每个实例都有独立的 Map,它不能代替 Redis、数据库唯一约束或分布式锁。
另外,迭代器采用弱一致性语义:遍历过程中允许其他线程修改,迭代器不会像 HashMap 那样依赖快速失败来阻止并发修改,但本次遍历也不保证看到所有最新变化。因此不要用一次遍历结果实现强一致业务判断。
总结
理解这两个容器,不能只记住"一个不安全,一个安全",而要抓住三个层次:
-
HashMap的复合写入和扩容缺少同步,存在竞态与可见性问题; -
ConcurrentHashMap通过volatile + CAS + 桶级 synchronized提高并发安全性; -
容器线程安全不等于业务逻辑天然原子,更不等于分布式一致性。
实际开发中,先判断 Map 是否会被多个线程共享,再决定使用普通 HashMap、外部加锁,还是 ConcurrentHashMap。选对容器只是第一步,正确使用原子 API 才能真正避免并发问题。