Skip to content

HashMap 是不是线程安全的?如果让你来实现一个线程安全的 HashMap 你要怎么设计?如果不用加锁你要怎么设计? ​

  • 标签:后端、系统设计、场景题

🏛 业务架构师 ​

结论先行:HashMap 线程不安全。JDK 7 并发扩容会因头插法成环导致死循环;JDK 8 改尾插修复了成环,但并发 put 仍会丢数据、size 不准、迭代时 ConcurrentModificationException。业务上的教训是:并发修改 HashMap 的 bug 在测试环境往往发现不了,压测或大促才爆发,属于延迟暴露型线上事故。

加锁实现线程安全

  • 粗粒度:Collections.synchronizedMap 方法级 synchronized,简单但读写在锁上排队,吞吐差,只适合低并发场景。
  • 正确答案:ConcurrentHashMap 的思路——JDK 7 分段锁(Segment 级),JDK 8 优化为 CAS 插入空桶 + 非空桶仅锁单链表头节点(synchronized),锁粒度从"整表"降到"单个桶",读操作完全无锁(volatile 保证可见性),并发度接近理想值。
java
// 要点:锁的粒度 = 冲突的粒度,桶与桶之间互不影响

不用加锁的思路

  • ThreadLocal:每个线程独立副本,最后合并结果,适合"计算密集、结果可合并"的场景如聚合统计。
  • CopyOnWrite:写时复制整个数组,读无锁,只适合读极多写极少(如黑白名单配置,一天更新几次、每秒读几万次)。

架构师的立场:这题我会主动加一句——生产中直接用 ConcurrentHashMap,不要自研。并发容器的内存语义(happens-before、伪共享、扩容协助)水极深,自研版本的验证成本远超收益;面试考的是理解权衡(锁粒度 vs 内存开销 vs 读多写少),不是真的让你造轮子。识别"该用哪个现成容器"本身就是架构能力。

🏗 系统架构师 ​

HashMap 本身线程安全吗?不安全:并发写可能丢数据,1.7 头插法扩容甚至会成环导致 CPU 100%,1.8 改尾插解决了成环但依旧不安全。

方案一:分段锁(JDK7 ConcurrentHashMap 思路) 把 table 拆成 16 个 Segment,每个 Segment 一把独立锁,写操作只锁自己那段,并发度 = 段数。优点是思路直观,缺点是内存布局复杂、定位要两次 hash。

方案二:CAS + 桶级锁(JDK8 的答案,我会首选)

  • 空桶插入:CAS 无锁竞争成功才创建节点,无竞争场景零开销。
  • 非空桶:synchronized 只锁该桶的头节点,不同桶完全不互斥——锁粒度从"段"细化到"桶",且 synchronized 在 JDK8 之后有偏向/轻量级锁优化,无竞争时退化为一次 CAS。
  • size() 用 CounterCell 分散计数(LongAdder 思想),避免计数热点。
  • 扩容:多线程协助迁移,每个线程认领一段桶区间(transferIndex),这是无独占锁扩容的经典实现。

方案三:真正"不加锁"的读优先结构 如果读远多于写,直接上 copy-on-write:写时复制新数组后原子替换引用(volatile 语义保证可见性),读永远无锁。代价是每次写 O(n) 复制,只适合白名单、配置这类小规模、低频变更数据。

我的总结:锁的演进方向就是粒度越来越细(全局→段→桶→无),选型看写竞争强度和读写比;工程上直接用 ConcurrentHashMap,只有读多写少的小数据集才考虑 COW。

💻 全栈开发专家 ​

HashMap 本身不是线程安全的:并发写可能丢数据,JDK7 头插法扩容还会成环直接 CPU 100%,JDK8 改尾插修了成环但丢数据依旧。

让我实现线程安全的 HashMap,分两档讲。

第一档:加锁思路(简单直接)

  • 全表一把锁:读写全串行,等于 HashTable,性能差,直接排除。
  • 分段锁(JDK7 ConcurrentHashMap 的做法):把 table 分 16 个 Segment,每个 Segment 独立加锁,写冲突概率降为 1/16,读无锁。工程上够用,但内存浪费、预定段数不灵活。

第二档:不加锁(JDK8 ConcurrentHashMap 的实现,我细讲)

java
// 1. 空桶:CAS 无锁插入
else if ((f = tabAt(tab, i)) == null) {
    if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
        break;                   // CAS 成功即插入完成,无锁
}
// 2. 非空桶:只锁"桶的头节点",锁粒度=一个桶
else synchronized (f) { ... 链表/红黑树操作 ... }

核心设计:

  • CAS 插空桶:无竞争时完全无锁,这是最常见的快路径。
  • synchronized 锁单个桶头:冲突只发生在同一桶,不同桶完全并行,锁粒度比分段锁更细。
  • get 全程无锁:Node 的 val、next 用 volatile 保证可见性。
  • size 用 CounterCell 分散计数(LongAdder 思路),避免计数成为热点。
  • 多线程协助扩容:扩容时每个线程认领一段桶区间(stride)迁移,transferIndex 协调,避免单线程迁移卡顿。

如果面试官追问还能怎么无锁:读写分离——CopyOnWrite 思路(写时复制整个数组),适合读极多写极少的小配置表;但写 O(n) 且占双倍内存,通用 map 不合适。

总结:无锁不是真的零锁,而是把锁压到最小粒度 + 快路径 CAS。