真实面经题目 · 原创解析

设计一个线程安全的HashMap?

设计线程安全的 HashMap,核心不是简单给每个方法加 synchronized,而是先明确一致性、吞吐量、内存、迭代语义等需求边界,再选择合适的锁粒度和扩容协调方案。一个可落地的设计通常会从桶级锁或分段锁出发,保证单个 key 的 put、get、remove 线性化,同时通过安全发布、volatile、CAS、锁顺序和 resize 协议避免数据丢失、死锁、读到半迁移结构等问题;如果追求工业级性能和复杂场景,通常应优先使用 ConcurrentHashMap,而不是自行实现。

出现于:阿里巴巴 · 后端开发

60 秒回答模板

回答这题要先澄清需求:这个 HashMap 需要支持哪些操作、是否允许 null、读是否必须强一致、迭代是否需要快照一致、容量多大、读写比例如何。如果只是保证并发下不破坏内部结构,最简单是全表锁,但吞吐量很差;更合理的方案是分段锁或桶级锁,把不同 key 的操作尽量拆到不同锁上。get 操作要保证读到的是安全发布后的节点,put/remove 对同一个桶加锁并在线性化点更新链表或树结构,size 可以用分段计数或 LongAdder 风格的近似计数。resize 是最容易出错的地方,需要让扩容只在受控状态下发生:要么全局 resize 锁协调,要么按段扩容,要么使用迁移标记让线程协助迁移,并保证任何读写都能识别旧表与新表的状态,不能读到半迁移数据。迭代器方面,需要明确语义:弱一致迭代性能最好,可以容忍遍历时看到部分新旧数据;快照迭代语义最清楚,但成本更高。最后要说明取舍:真实业务里优先使用 JDK 的 ConcurrentHashMap,因为它已经处理了扩容、可见性、锁竞争和边界条件;自己设计更多是为了说明并发容器的关键问题,而不是重复造一个生产级容器。

考点 需求边界先行
主线 全表锁方案
易错点 只回答给 put/get 加 synchronized…

深入解析

01

需求边界先行

线程安全并不是一个单一目标,必须先定义安全到什么程度。比如 get 是否必须看到最近一次完成的 put,size 是否必须精确,迭代期间是否允许结构变化,是否允许 null key/null value,以及失败时是阻塞、重试还是抛异常。这些边界决定了实现复杂度:强一致、精确 size、快照迭代会显著增加锁竞争和内存成本;弱一致、最终可见则可以换取更高吞吐。回答中先讲边界,能体现不是只会背容器名字,而是在做并发系统设计。

02

全表锁方案

最朴素的实现是所有 public 方法都用同一把锁保护,包括 get、put、remove、resize 和迭代。这种方案容易证明正确性,因为任何时刻只有一个线程能访问内部数组和链表,不会出现结构损坏、丢数据或扩容交错问题。缺点也明显:读读之间也互斥,读多写少场景下吞吐量很差,长时间迭代还会阻塞所有读写。因此全表锁适合作为正确性基线,不适合作为高并发设计的最终答案。

03

分段锁设计

分段锁把整个表拆成多个 segment,每个 segment 持有一部分桶和一把锁,不同 segment 上的操作可以并行执行。定位时先用 hash 找 segment,再在 segment 内找桶,put/remove 只锁对应 segment,get 可以在满足可见性要求时无锁或少锁读取。它的优点是实现复杂度适中,扩容也可以按 segment 独立进行;缺点是并发度上限受 segment 数限制,如果热点 key 集中在同一 segment,仍然会出现锁竞争。

04

桶级锁设计

桶级锁进一步细化锁粒度,每个桶或一组桶对应一把锁,理论并发度更高。put/remove 只锁目标桶,更新链表、红黑树或数组节点时保持互斥;get 可以选择无锁读配合 volatile 字段和不可变节点,也可以短暂加桶锁换取更简单的一致性。桶级锁的问题在于 resize 更复杂,因为迁移会同时影响很多桶,如果没有统一协调,读写线程可能访问到旧桶、新桶或迁移中的桶,导致数据不可见或重复操作。

05

内存可见性

线程安全不仅是互斥,还包括可见性和安全发布。数组引用、节点 next、value、迁移状态等关键字段必须通过 volatile、锁释放/获取、CAS 或 final 字段语义保证其他线程可见。put 插入新节点时,要确保节点内容初始化完成后再发布到桶中;remove 或 update value 时,要保证后续 get 不会读到撕裂状态。很多手写 HashMap 的并发问题不是锁没加,而是锁覆盖范围与读路径不一致,导致读线程绕过了写线程的可见性保障。

06

扩容协调

resize 是设计中最关键的风险点,因为它会移动大量节点并替换 table 引用。简单方案是在扩容时持有全局 resize 锁,并阻塞相关写操作,保证迁移完成后再发布新表;更细的方案可以按段扩容,降低停顿范围;更复杂的方案可以让线程协助迁移,但需要 forwarding 节点或迁移标记,让任何线程都知道某个桶已经转移到新表。无论哪种方案,都必须保证一个 key 在迁移前后不会丢失、不会重复插入到不一致的位置,并且读线程能找到迁移中的数据。

07

迭代一致性

迭代器必须明确语义。强一致迭代可以在遍历期间锁住整个表或复制快照,结果清晰但成本高,会影响并发读写;弱一致迭代不抛并发修改异常,允许看到遍历开始前、开始后的一部分更新,适合高并发容器。通常线程安全 HashMap 更倾向弱一致迭代,因为它不会长时间阻塞业务操作,但需要文档明确说明不能把迭代结果当作某个瞬间的全局快照。

08

与 ConcurrentHashMap 的取舍

如果是生产环境,优先使用 ConcurrentHashMap。它已经在 JDK 层面处理了哈希扰动、桶内结构转换、CAS 插入、锁竞争、扩容协助、弱一致迭代和计数等复杂问题。自己实现的价值主要是为了特定约束,例如定制过期策略、固定容量、特殊一致性语义或教学研究;否则手写实现很难覆盖边界条件,维护成本和线上风险都更高。

易错点

  • 只回答给 put/get 加 synchronized,没有讨论锁粒度、性能和扩容。
  • 认为只要写操作加锁,读操作就天然安全,忽略内存可见性。
  • 没有说明 resize 期间如何处理并发读写,导致方案无法落地。
  • 把线程安全等同于不报错,却没有定义一致性语义。
  • 承诺 size 永远精确,但没有解释高并发下的计数协调成本。
  • 迭代器语义含糊,没有区分快照一致、弱一致和 fail-fast。
  • 忽略热点 key 导致的锁竞争,只看理论锁数量。
  • 在实际工程场景中盲目自研,而没有说明与 ConcurrentHashMap 的取舍。

面试官追问

get 操作一定要加锁吗?

不一定。如果节点和桶引用能通过 volatile、final 字段、CAS 或锁释放建立安全发布关系,get 可以做无锁读取或乐观读取,从而提高读性能。但如果实现没有严密处理可见性,get 加锁会更容易保证正确性,只是吞吐量会下降。

为什么 resize 比 put/remove 更难?

put/remove 通常只影响一个桶,而 resize 会影响整个 table 或大量桶,并且会改变 key 到桶的映射位置。扩容期间如果读写线程不知道数据正在迁移,就可能在旧表写入、新表读取,或者迁移覆盖并发更新,所以必须有全局或分段的协调协议。

分段锁和桶级锁怎么选择?

分段锁实现简单,扩容和计数都更容易控制,适合中等并发场景。桶级锁并发度更高,但锁对象更多,resize 协调更复杂,也更容易出现边界错误;如果没有极高并发需求,分段锁通常是更稳妥的设计。

如何避免死锁?

首先尽量让一次操作只持有一把锁,例如只锁目标 segment 或目标 bucket。必须持有多把锁时,要规定全局顺序,比如按 segment 下标或 bucket 下标递增加锁,并在异常路径上确保释放锁。resize、clear、迭代这类跨桶操作尤其要遵守统一锁顺序。

迭代器应该设计成 fail-fast 还是弱一致?

普通非并发集合常见 fail-fast,用于尽早发现并发修改,但它不是强线程安全保证。并发 Map 更常见弱一致迭代,遍历时不抛异常,允许看到部分并发更新;如果业务需要稳定结果,应显式创建快照。

为什么不直接继承 HashMap 再加锁?

继承 HashMap 很难完整控制内部结构变化,尤其是 resize、迭代器、视图集合和组合操作的并发语义。更可靠的方式是组合一个内部结构并明确控制所有访问路径,或者直接使用成熟并发容器。