简介:一场围绕 HashMap 并发化改造的面试深挖,远比背八股更考验设计权衡。本文复盘从数组加链表手写实现,到读写分离、无锁引用更新、分段拷贝,再到链表局部更新与一致性设计的完整推理链路,帮助读者理解高并发读多写少场景下数据结构改造的关键取舍,并掌握面试中如何跟随追问逐步收敛方案。
很多面试都会让候选人手写数据结构,常见题目包括实现一个简化版 HashMap 的 put、get、resize,或者实现 LRU 缓存。单看题目本身并不难,真正的难点在于后续追问是否能够沿着工程化方向持续深入。
一次典型的面试过程如下:面试官先要求手写 HashMap 的 put 方法,只实现数组加链表即可,不要求红黑树。此时重点通常不在功能是否完整,而在于:
例如,一个简化版本可以写成:
class Node {final int hash;final String key;String value;Node next;Node(int hash, String key, String value, Node next) {this.hash = hash;this.key = key;this.value = value;this.next = next;}}class SimpleHashMap {private Node[] table;private int size;public SimpleHashMap(int capacity) {this.table = new Node[capacity];}public void put(String key, String value) {int hash = spread(key.hashCode());int index = hash & (table.length - 1);Node head = table[index];if (head == null) {table[index] = new Node(hash, key, value, null);size++;return;}Node current = head;while (true) {if (current.key.equals(key)) {current.value = value;return;}if (current.next == null) {current.next = new Node(hash, key, value, null);size++;return;}current = current.next;}}private int spread(int h) {return h ^ (h >>> 16);}}
面试官看到这种基础实现时,通常会表示“基础功能可以”。但接下来真正的考察才刚刚开始。
HashMap 在多线程环境下存在明显问题,例如多个线程同时扩容时可能形成环链,或者一个线程正在遍历链表时另一个线程修改了链表结构,导致读到的数据不一致甚至进入死循环。
当面试官提出“现在要改造成支持并发的结构”时,很多候选人会直接背诵并发容器实现原理,比如分段锁、CAS、synchronized 优化等。如果面试官不希望听到八股,他会快速打断,并给出明确场景:
并发量很大,读多写少,QPS 很高。
这时候问题的边界已经发生变化。不能只看“并发安全”,还要看“并发性能”。如果所有操作都加同一把大锁,读读之间也无法并行,吞吐量会很低。
于是自然进入第一个优化方向:读写分离。
读多写少的典型优化是读写锁。思路是:
这样,大量读请求可以并发执行,只有写请求会阻塞其他请求。传统读写锁在某些场景下确实有效,但一旦读请求量很大,写线程可能出现饥饿;同时,每次读都要获取读锁和释放读锁,同步器本身也会带来开销。
当数据量继续增大、请求量继续上升时,读写锁仍然不够。面试官继续追问:
能不能让读性能更高?
此时可以引入一个关键问题:是否允许读取到短时间内的旧数据?
如果业务允许弱一致性,就可以选择更激进的并发策略。
允许读旧数据后,可以采用写时复制策略。核心思想是:
这样做的优势是读请求完全无锁,性能非常高。缺点是写操作要复制数据,如果数据量很大,写压力会很大,并且会占用较多内存。
实现上,通常先使用 synchronized 保证写写互斥,同时避免多个写线程同时复制导致数据覆盖:
class CopyOnWriteMap {private volatile Node[] table;public void put(String key, String value) {synchronized (this) {Node[] newTable = copyTable(table);// 在 newTable 上执行插入insert(newTable, key, value);table = newTable;}}public String get(String key) {Node[] current = table;int index = spread(key.hashCode()) & (current.length - 1);Node node = current[index];while (node != null) {if (node.key.equals(key)) {return node.value;}node = node.next;}return null;}}
面试官接着问:
能不能不用锁?
这是因为在某些写多一点的场景下,synchronized 会让写线程之间串行执行。如果使用 CAS 更新引用,可以实现写线程之间的无锁竞争。
例如:
public void put(String key, String value) {while (true) {Node[] expect = table;Node[] newTable = copyTable(expect);insert(newTable, key, value);if (casTable(expect, newTable)) {return;}}}
这样写线程不再阻塞等待锁,而是通过 CAS 自旋竞争更新权。失败则重新基于最新数据复制,再继续尝试。
Copy-on-Write 有一个明显问题:如果整个表很大,每次只修改一个键却要复制整张表,写放大非常严重,内存分配压力也会很大。
解决思路是借鉴分段思想。表不再作为一个整体进行复制,而是拆成多个段,每个段独立维护。写操作只复制发生修改的那个段,然后使用 CAS 或 volatile 更新该段的引用。
示意图如下:
+--------+--------+--------+--------+| seg[0] | seg[1] | seg[2] | seg[3] |+--------+--------+--------+--------+| | | |v v v vArrays Arrays Arrays Arrays
当修改某个 key 时,先定位到具体段:
int segmentIndex = hash & (segmentCount - 1);
然后复制该段内部的数组,在该数组上完成插入,最后只替换该段的引用。其他段完全不受影响。
这种方式能够在读多写少场景下,将写时复制的开销从“全表复制”降低到“局部复制”,同时保持读操作无锁。
如果数据不是按数组桶组织,而是某个桶内部使用链表,并且链表长度很大,那么复制整个链表同样代价高。面试官可能会进一步追问:
如果链表很长,只想修改链表中间某个节点,应该怎么设计?
这部分往往容易卡住。很多候选人第一反应是“链表最多 8 个节点就转树,不会太长”,但当题目已经假设链表很长,就必须跳出传统实现,考虑更一般化的数据结构设计。
链表与数组不同。数组可以通过复制整个数组后再修改一个位置实现写时复制,因为数组可以通过下标快速定位,复制后的新数组与原数组互不影响。
链表则不同。链表节点之间通过引用连接。如果直接复制中间某个节点,前后的连接关系可能仍然指向旧节点,难以做到真正隔离。
一种思路是采用函数式链表。函数式链表中的修改操作不修改已有节点,而是返回一个新的链表。比如要更新链表中某个节点,可以复制从头部到目标节点的路径上的所有节点,然后让新链表的尾部继续指向原链表未修改部分:
原链表:A -> B -> C -> D -> E更新 C:A' -> B' -> C' -> D -> E
复制的是从 A 到 C 这一段节点,后续节点 D、E 仍然可复用。这样更新的复杂度与目标位置相关,而不是与整个链表长度相关。
这种方式适合链表结构作为不可变数据结构的场景。读操作可以直接沿链表遍历,不需要关心是否有写操作正在发生。
另一种思路是不要直接暴露长链表。当链表过长时,可以在链表之上增加索引层或将其拆分为多个子段。这样修改一个节点时,只需要复制对应子段,而不会影响整条链表。
这也是很多数据结构从简单结构演进到更复杂结构的常见过程。例如链表过长时不会一直维持单层链表,而会引入树形结构、跳表或者多级索引来降低单次修改和查询成本。
此时再回头看整个面试过程,可以看出面试官并不是在考察某个具体容器实现,而是在考察一种能力:
给定一个数据结构,当并发性能、读写比例、一致性要求发生变化时,如何从锁、复制、分段、局部化、引用发布等多个维度逐步优化?
从全局锁到读写锁,再到写时复制和无锁引用更新,再到分段复制,再到链表局部复制,整个链路体现的是工程中常见的“逐步减小冲突域”的思路:
在采用无锁引用更新时,必须保证新引用发布后,其他线程能看到完整的新数据。这里需要依赖 volatile 语义或 CAS 的可见性保证。
例如:
private volatile Node[] table;
volatile 写会将新数据完整刷回主存,volatile 读会从主存读取最新数据,从而避免线程内看到的仍是旧对象状态。
如果只把引用替换,但没有正确保证可见性,那么可能出现“引用已经更新,但数据还没有刷出”的情况,读线程可能读到未完全初始化的对象。因此,引用发布不能是普通写。
虽然无锁方案听起来很强,但并不是所有业务都适合。例如:
因此,设计时必须围绕业务假设进行。面试中常见的做法也是先确认场景:
只有明确这些假设,才能选择一个合适的方案。
很多候选人在面试失败后,会归因于“没背过”“没遇到过”。但真正的问题是,当面试官把已知题目逐步改造、逐步加大约束时,能不能跟着约束的变化调整方案。
这要求候选人具备以下几点:
如果不清楚 HashMap 为什么线程不安全,就很难理解后续并发化改造的动机。理解“什么地方会出现冲突”比单纯记住“HashMap 不安全”更重要。
synchronized、ReentrantReadWriteLock、volatile、CAS、ThreadLocal、并发队列等,都是可选工具。面试官不一定要求逐一实现,但必须知道它们能解决什么问题,以及各自的代价。
不要一上来就“加锁”,也不要一上来就“无锁”。先判断读写比例、数据规模、一致性要求,再决定优化方向。
很多候选人以为“并发 Map”已经有标准答案,直接背实现。但面试官说的是“高并发、读多写少、允许旧数据、不要锁”,这已经不是标准容器的原样问题,而是一个新的设计题。继续背标准实现反而会跑偏。
如果重新面对同样的面试题,可以按如下方式组织回答:
这样的回答,展示了从“会写数据结构”到“能基于场景做结构设计”的跨越。
从头到尾看,面试官其实不是要一个“更好的 HashMap”,而是要观察候选人在约束变化时能否持续收敛方案。初始手写题只是入口,真正的得分点在于后续的并发化改造和局部化设计。
如果把这道题抽象出来,核心关键词只有几个:
这些关键词可以迁移到缓存设计、配置管理、元数据管理、内存索引、高效只读数据集分发等多个真实业务场景。因此,这类面试题并不是“故意刁难”,而是在模拟一个常见的高性能服务端设计过程。
理解这个链路之后,再遇到类似问题,就不会只停留在“背过没有”,而是能从问题约束出发,不断推导出更合适的方案。