HashMap 为什么线程不安全?ConcurrentHashMap 如何解决?

在 Java 面试中,HashMapConcurrentHashMap 几乎是绕不开的话题。很多人只会回答一句:

HashMap 线程不安全,ConcurrentHashMap 线程安全。

但继续追问"到底哪里不安全""加了什么锁""用了 ConcurrentHashMap 是否就万事大吉",往往就答不清了。

本文从实际并发写入场景出发,一次讲清两者的区别。

一、先说结论

HashMap 没有提供并发控制。多个线程同时执行 put、扩容或修改同一个桶时,可能出现:

  • 数据被覆盖或丢失;

  • 读取到旧值,存在内存可见性问题;

  • 链表、红黑树等内部结构在竞争中出现异常状态;

  • JDK 7 中并发扩容甚至可能形成环形链表,导致查询死循环。

JDK 8 的 ConcurrentHashMap 主要通过以下机制解决:

  • volatile 保证关键数据的可见性;

  • CAS 完成无竞争场景下的原子更新;

  • synchronized 锁住发生冲突的单个桶,而不是整张表;

  • 扩容时通过 ForwardingNode 和多线程协作完成数据迁移。

因此,它既能保证线程安全,也比"给整个 Map 加一把大锁"拥有更好的并发性能。

二、HashMap 为什么线程不安全?

HashMap 在 JDK 8 中由"数组 + 链表 + 红黑树"组成。调用 put 时,大致需要经历:

  1. 根据 key 计算哈希值;

  2. 定位数组中的桶;

  3. 桶为空则插入节点;

  4. 桶不为空则遍历链表或红黑树;

  5. 达到阈值后触发扩容。

这些步骤并不是一个不可分割的原子操作。如果多个线程交叉执行,就会出现问题。

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 保证 getputremove 等单个方法具有线程安全语义,但多个方法组合起来不一定是原子的。

下面是经典错误:

复制代码
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

  • 本地缓存、连接信息、在线用户表等共享状态;

  • 多线程需要频繁读取和更新;

  • 需要 putIfAbsentcomputeIfAbsentmerge 等原子复合操作。

但要注意,ConcurrentHashMap 只解决单个 JVM 内的并发问题。如果应用部署了多个实例,每个实例都有独立的 Map,它不能代替 Redis、数据库唯一约束或分布式锁。

另外,迭代器采用弱一致性语义:遍历过程中允许其他线程修改,迭代器不会像 HashMap 那样依赖快速失败来阻止并发修改,但本次遍历也不保证看到所有最新变化。因此不要用一次遍历结果实现强一致业务判断。

总结

理解这两个容器,不能只记住"一个不安全,一个安全",而要抓住三个层次:

  1. HashMap 的复合写入和扩容缺少同步,存在竞态与可见性问题;

  2. ConcurrentHashMap 通过 volatile + CAS + 桶级 synchronized 提高并发安全性;

  3. 容器线程安全不等于业务逻辑天然原子,更不等于分布式一致性。

实际开发中,先判断 Map 是否会被多个线程共享,再决定使用普通 HashMap、外部加锁,还是 ConcurrentHashMap。选对容器只是第一步,正确使用原子 API 才能真正避免并发问题。

相关推荐
我命由我123451 小时前
匈牙利命名法
java·服务器·后端·学习·java-ee·kotlin·学习方法
闲猫1 小时前
LangChain / Integrations / Integrations by component / Tool
java·数据库·langchain
小田的博客1 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器
范什么特西2 小时前
知识总结03
java
空谷有来人2 小时前
Ubuntu22.04安装jdk17,配置环境变量
java·jdk
程序员雷欧3 小时前
ThreadPoolExecutor 深度解析:从核心参数到源码实现的全面剖析
java·开发语言·jvm
谢尔登3 小时前
分享一些我常用的Skill
java·人工智能·python·actionscript
AaronJonah3 小时前
企业级线程池 traceId 闭环:上下文传递与全链路追踪实战
java·线程池·traceid跨线程传递