☰
HashMap底层原理、扩容机制与并发避坑全解析
2026/9/29 21:26:46 网站建设 项目流程

第一次被 HashMap 拖到凌晨三点,是在一个看似毫无波澜的版本上线之后。监控面板上某个订单查询接口的 P99 从 40 毫秒慢慢爬到了两秒,随后几台机器的 CPU 直接被打到 100%,线程栈抓下来,一堆线程卡在HashMap.get上转圈。那是 JDK7 时代,一个多线程共享的缓存容器在并发扩容时被插成了环形链表,get 操作顺着环永远走不到头。从那次事故之后,我才真正愿意坐下来把 HashMap 的每一行源码读完,把hashCode、equals、负载因子、扩容阈值这些看起来只是面试八股的东西,当成线上稳定性的必修课。

这篇内容就是那次“补课”的产物。我会把 HashMap 的底层实现原理、扩容机制从头到尾拆一遍,把源码里那些藏在注释和位运算背后的设计意图挖出来,再把我这些年面试别人和被面试时反复遇到的高频问题整理成一份可以直接对照的答题清单。同时,我也会分享几个真实踩过的坑:容量初始化写成多少才合适、可变对象当 key 会出什么幺蛾子、为什么扩容时元素要么原地不动要么整体后移一个 oldCap,以及 JDK7 和 JDK8 在链表插入顺序上的差别到底意味着什么。

不管你是刚学完集合框架想弄明白“为什么容量非得是 2 的幂”的新手,还是写了几年业务代码、想搞清楚线上偶发卡顿到底是不是哈希冲突引起的进阶选手,这篇内容都能给你一份能直接拿去用的参考。我把原理、源码、参数计算、面试答法和避坑经验放在一起讲,尽量做到看完能自己讲出来,也能自己动手写一个最小可用的版本。

1. 从一个真实的线上问题说起:为什么值得死磕 HashMap

1.1 那次 CPU 打满的凌晨到底发生了什么

那次事故的根因并不复杂:一个静态的HashMap被当成了本地缓存,多个业务线程同时往里放数据,正好撞上扩容。JDK7 的transfer方法在迁移链表时用的是头插法,也就是新桶里的节点顺序和原链表完全颠倒。并发场景下两个线程同时做迁移,指针相互引用,最终形成了A.next = B且B.next = A的环形结构。之后所有落到这个桶上的查询都会陷入死循环,CPU 自然被打满,而且这种问题不会抛异常、不会打日志,只表现为线程卡死,排查成本极高。

这件事给我最大的教训是:HashMap 的设计目标是单线程下的高性能散列表,它从来没有承诺过并发安全。JDK8 把插入方式改成了尾插法,解决了环形链表这个致命问题,但并发下的数据覆盖、size 计数不准、扩容丢失节点这些毛病依然存在。如果你真的需要并发场景下的键值容器,那ConcurrentHashMap才是正解,用分段锁或者 CAS 加 synchronized 保证桶级别的并发安全,比你自己在外面套一把大锁要高效得多。

理解这个边界之后,再回头看 HashMap 的源码,你会发现它的每一个设计细节都在为“单线程下尽可能快”这个目标服务:位运算替代取模、容量强制 2 的幂、扰动函数压缩高位信息、链表到红黑树的转换阈值。这些不是炫技,而是在真实数据分布下反复权衡的结果,值得逐个拆开看。

1.2 HashMap 到底解决了什么问题

从数据结构的角度讲,HashMap 要解决的是查找效率问题。数组的随机访问是 O(1),但按值查找是 O(n);链表的插入删除是 O(1),但查找是 O(n)。哈希表把两者的优点拼起来:用哈希函数把 key 映射成一个整数,再用这个整数定位到数组下标,理想情况下一次就能命中,查找、插入、删除的平均复杂度都逼近 O(1)。

它提供的能力很朴素:以键值对的形式存储数据,通过 key 快速拿到 value,允许一个 null key 和多个 null value,不保证遍历顺序稳定。但正是这种朴素让它在工程里无处不在:配置缓存、对象去重、分组统计、图结构建模、去重计数、词频统计、接口返回结果的本地索引,几乎所有需要“按某个标识快速找东西”的场景,第一反应都是 HashMap。

代价也很明确:哈希冲突无法完全避免。两个不同的 key 算出同一个下标,就必须有一个位置能挂多个元素,这就是链地址法。冲突一旦变多,链表变长,查找就退化到 O(n)。所以 HashMap 真正的技术含量不在“怎么用数组存”,而在“怎么把冲突控制在可接受范围内”,以及“冲突严重时怎么兜底”。扩容机制和红黑树转换,本质上都是这个问题的答案。

1.3 这篇内容适合谁,以及我建议的阅读顺序

如果你是完全的新手,建议从第 2 章开始顺着读,先把“数组加链表加红黑树”这个骨架建立起来,再看第 3 章的 put/get 流程,最后回到第 4 章的扩容。扩容是整个 HashMap 里最绕的部分,前置知识不够直接啃会很痛苦。如果你是准备面试的,可以直接跳到第 5 章的题目清单和答题框架,再回头补第 4 章,因为扩容几乎是每一轮技术面必问的深水区。

如果你是在线上出过问题的老兵,第 6 章的踩坑记录可能对你最有用:容量预设怎么写、loadFactor 什么时候该调、可变 key 有多坑、什么时候干脆换掉 HashMap。这些内容在教科书里基本看不到,都是一次次故障和性能压测换来的。

提示:本文涉及的源码基于 JDK8 的 HashMap 实现,个别细节在 JDK7 中不同,我会在对应位置标注出来。不同厂商的 JDK 发行版在实现上可能有微调,但整体设计思路一致。

2. 拆开 HashMap 的骨架:数组、链表、红黑树是怎么配合的

2.1 数组是骨架,链表是补丁,红黑树是保险

把 HashMap 想象成一栋楼的信箱区,最外层是数组Node<K,V>[] table,也就是信箱墙本身。每个数组位置叫一个“桶”(bucket),桶里放的是一个链表或者一棵红黑树。你拿着 key 算出一个下标,就知道该去哪个桶里翻找,桶里可能有零个、一个或者多个元素。

数组的长度决定了桶的数量,也决定了冲突的概率。桶越多,冲突越少,但内存占用越大;桶越少,内存省了,冲突变多,链表变长,查找变慢。这就是为什么需要扩容,也是为什么需要一个负载因子来权衡。链表是解决冲突的第一层手段,也就是所谓的“拉链法”或者链地址法:冲突的元素挂在同一个桶下面,形成一个单向链表。当链表长度达到阈值 8 且数组容量达到 64 时,链表会转换成红黑树,把最坏情况下的查找复杂度从 O(n) 优化到 O(log n)。

注意:链表转红黑树的条件是两个,缺一不可。链表长度达到 8 只是其一,如果当前数组容量小于 64,HashMap 会选择先扩容而不是树化,因为小容量下冲突多本来就是容量太小导致的,扩容比树化更划算。

红黑树是一种自平衡二叉搜索树,插入删除时通过变色和旋转维持大致平衡,最坏查找路径长度被限制在 2 倍最短路径以内。用在这里的好处是即使哈希函数被恶意构造、大量 key 落到同一个桶,查找性能也不会崩到线性。代价是每个树节点比普通链表节点多维护 parent、left、right、prev、red 五个字段,内存开销明显更大,所以只有链表足够长时才值得转换。

2.2 容量为什么非得是 2 的幂

这是 HashMap 里最经典的设计之一,答案有两层。第一层是索引计算:HashMap 用(n - 1) & hash来算数组下标,这里的 n 是容量。当 n 是 2 的幂时,n - 1的二进制是低位全 1,比如 16 - 1 = 15 = 0b1111,此时&运算的结果恰好等价于hash % n,但位运算比取模快得多。计算机里除法和取模是相对昂贵的操作,能用位运算替代就能省下可观的 CPU 周期。

第二层是扩容时的元素迁移。容量翻倍后,元素的新下标只可能是两个值:原来的下标,或者原下标加上旧容量。原因在于新容量是旧容量的两倍,newCap - 1比oldCap - 1在二进制上多了一个高位 1,这个位正好来自 hash 中对应位置的 bit。判断方法极其简单:(e.hash & oldCap) == 0就留在原位,否则移动到原下标 + oldCap。不需要重新计算 hash,也不需要重新取模,这在扩容时是巨大的性能节省。

反过来说,如果容量不是 2 的幂,n - 1的二进制里就会出现 0 位,与运算后某些下标永远取不到,数组空间被浪费,同时冲突分布也会变差。所以哪怕你通过构造方法传入一个不是 2 的幂的初始容量,HashMap 也会用tableSizeFor把它向上取整到最接近的 2 的幂。比如传 17,实际容量会是 32;传 100,实际容量会是 128。

2.3 hash() 里的那次右移 16 位到底在干嘛

JDK8 的哈希扰动函数只有一行:

static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }

核心是h ^ (h >>> 16),也就是把 hashCode 的高 16 位无符号右移到低位,然后和原值做异或。这么做的原因是:数组下标计算只用到 hash 的低位(因为n - 1通常是低位全 1),而很多对象的hashCode实现里,低位的信息量并不充分,高位反而更有区分度。如果直接拿原始 hashCode 去取低位,高位的信息就白白浪费了,冲突概率会上升。

把高 16 位移到低位再异或,相当于让高位也参与下标计算,用一个几乎零成本的操作提升了散列均匀度。这里用异或而不是与、或,是因为异或能同时保留两种比特的信息:两个 bit 不同时为 1,相同时为 0,混合效果最好。JDK7 的扰动要复杂得多,做了一次右移 20、一次右移 12、一次右移 7 和一次右移 4,再叠加多次异或,还引入了hashSeed来防止哈希碰撞攻击,但收益并不明显,JDK8 就简化成了这一行。

提示:key == null时返回 0,所以 null key 永远落在下标 0 的位置。这也是 HashMap 允许一个 null key 的实现方式,而 Hashtable 直接抛空指针。

2.4 Node 与 TreeNode 的字段设计各有讲究

普通节点Node<K,V>只有四个字段:final int hash、final K key、V value、Node<K,V> next。hash 用 final 修饰是因为它在节点创建时就固定了,后续不再变化,这样在扩容和查找时可以直接复用,不用重新计算。key 声明为 final 是为了保证哈希一致性,如果 key 能被替换,就可能出现同一个节点算不出原来下标的尴尬情况。value 不加 final,因为可以覆盖。

红黑树节点TreeNode<K,V>继承自LinkedHashMap.Entry,而后者又继承自Node,所以它天然带着 next 指针。额外的字段有parent、left、right、prev和boolean red。其中prev这个前驱指针不是红黑树本身需要的,它主要用于树化过程中的链表结构维护,以及红黑树退化成链表时能快速重建链表。树化后节点之间的next关系依然被保留,所以在红黑树状态下遍历仍然可以按链表方式走,这也是为什么遍历 HashMap 时不会因为树化而出现奇怪的行为。

3. put 和 get 的完整执行链路拆解

3.1 putVal 逐步拆解,每一行都有理由

先看 JDK8 中putVal的主体逻辑,我把它按执行顺序梳理一遍。第一步是判断table == null || (n = table.length) == 0,如果是就调用resize()完成初始化,这是懒加载设计,构造方法只记录阈值,真正的数组分配推迟到第一次插入。这样做的好处是如果创建了对象却一直没放数据,就不会白白占用数组内存。

第二步是计算下标i = (n - 1) & hash,取出桶头节点 p。如果p == null,直接新建一个节点放进桶里,这是最理想的情况,一次插入就完成。如果桶头不为空,就比较桶头节点:p.hash == hash && (p.key == key || (key != null && key.equals(p.key))),成立说明是同一个 key,记下引用 e 准备覆盖 value。

第三步是冲突处理。如果桶头是 TreeNode 类型,调用putTreeVal走红黑树的插入逻辑。否则就沿着链表往后走,用binCount计数,每走一步加一,如果走到链表尾部还没找到相同 key,就新建节点挂到尾部;同时判断binCount >= TREEIFY_THRESHOLD - 1,也就是链表长度达到 8,触发treeifyBin。

第四步是收尾。如果 e 不为空,说明命中了已有 key,把新值写入并返回旧值,modCount和size都不变。如果是新增节点,++modCount、++size,再判断size > threshold,超过阈值就扩容。整个流程是“先找,找到了就换,没找到就加,加完再看要不要扩容”,逻辑非常清晰。

3.2 桶内比较为什么先比 hash 再比 equals

注意p.hash == hash && (...)这个顺序,先比较 hash 值再比较 key。这不是随意写的,而是一种典型的短路优化。hash 是 int,比较是一次 CPU 指令级别的基本操作,成本极低;而equals可能涉及字段逐个比较,甚至包含字符串逐字符扫描,成本高得多。绝大多数情况下,同一个桶里的节点 hash 值都不同,所以先用 hash 过滤能挡掉绝大部分无意义的 equals 调用。

这也解释了为什么重写 equals 必须重写 hashCode。如果两个对象 equals 相等但 hashCode 不同,它们会被分配到不同的桶,HashMap 永远找不到彼此,集合里会出现“明明相等却存了两份”的诡异现象。反过来,hashCode 相同而 equals 不同的两个 key 是允许的,它们会落到同一个桶形成冲突,这是正常的散列现象。

注意:p.key == key这个引用相等判断也不能省。对于同一个对象引用,直接用==就能判定相等,省掉一次 equals 调用。这是很多 JDK 集合类里通用的微优化手段,在 key 大量复用同一实例的场景下收益明显。

3.3 getNode 的查找路径比你想的更直白

get方法最终调用getNode(hash(key), key),逻辑几乎是 put 的查找部分。先判断表非空、桶头非空,然后三步走:桶头的 hash 和 key 都匹配就直接返回;如果桶头是 TreeNode,走getTreeNode;否则沿着链表遍历,逐个比对 hash 和 equals,命中就返回 value,走到 null 就返回 null。

这里有个很多人忽略的细节:get返回 null 有两种可能,一是 key 不存在,二是 key 存在但 value 恰好是 null。要区分这两种情况,必须用containsKey。这也是为什么containsKey不是多余的 API,它在业务代码里判断“这个 key 到底有没有配过”时非常关键。如果你写if (map.get(k) != null)来做存在性判断,遇到 value 为 null 的键值对就会误判。

查找效率上,理想情况是一次定位、一次比较,O(1)。最坏情况是桶里挂着长链表,O(n);树化之后最坏是 O(log n)。所以提升查找性能的核心就是降低冲突率,而降低冲突率的手段无非两个:提高哈希函数质量,以及让容量足够大。

3.4 remove 与 modCount:fail-fast 是怎么实现的

删除逻辑同样先定位桶,然后分链表和红黑树两条路径处理。链表删除需要维护前驱节点,找到目标后把前驱的 next 指向目标的 next;红黑树删除调用removeTreeNode,内部会判断树节点数量是否退化到 6 以下,如果满足就转回链表。删除成功后++modCount、--size。

modCount这个字段记录的是结构性修改次数,凡是有节点增删都会加一。迭代器在创建时会把当前的modCount保存为expectedModCount,每次调用next()都检查两者是否一致,不一致就抛ConcurrentModificationException。这就是 fail-fast 机制,它不保证一定能检测到并发修改,但能在大多数情况下尽早暴露问题,避免出现难以复现的数据错乱。

提示:想边遍历边删除,不要直接用map.remove,要用Iterator.remove(),它会同步更新expectedModCount,不会触发异常。JDK8 之后也可以用removeIf,底层同样走的是迭代器安全删除。

4. 扩容机制:resize() 里藏着的全部细节

4.1 负载因子 0.75 到底是怎么定下来的

负载因子loadFactor默认 0.75,含义是“当元素个数超过容量的 75% 时就扩容”。这个数字不是拍脑袋定的,它来自空间和时间的一次权衡。源码注释里提到,在理想随机哈希下,桶中节点数量近似服从泊松分布,当负载因子为 0.75 时,一个桶中出现 8 个节点的概率大约是千万分之六级别,低到几乎不可能。所以链表长度达到 8 才树化,是一个在统计上兜底的阈值,而不是经常触发的常规路径。

如果把负载因子调高,比如 0.9,空间利用率上去了,但冲突概率明显增加,链表变长,查询变慢;调低到 0.5,冲突少了查询快了,但数组要频繁扩容、内存占用翻倍。0.75 是在大量实测下表现最均衡的取值。当然它不是铁律,如果你的场景读多写少、对延迟极其敏感,可以适当调低;如果内存极度紧张、对查询延迟不敏感,可以适当调高,但要清楚代价。

4.2 resize() 的源码级流程拆解

resize()分两大块:先算新容量和新阈值,再把旧表数据迁移过去。算容量时分三种情况。第一种是oldCap > 0,说明表已经存在:如果旧容量已经达到最大容量1 << 30,就把阈值设为Integer.MAX_VALUE并且不扩容,直接返回旧表;否则新容量等于旧容量左移一位,也就是翻倍,新阈值也翻倍。

第二种是oldCap == 0但oldThr > 0,说明是构造时指定了初始容量,此时新容量直接等于旧阈值,这是初始化路径。第三种是两者都为 0,说明用的是无参构造,走默认值:容量 16,阈值 12。最后如果新阈值没算出来,就用newCap * loadFactor补算,同时把阈值限制在Integer.MAX_VALUE以内。

迁移阶段是新表逐个桶处理。如果桶里只有一个节点,直接newTab[e.hash & (newCap - 1)] = e重新定位,不需要遍历。如果是红黑树,调用split方法按高低位拆成两棵子树或两条链表。如果是普通链表,就走高低位拆分逻辑,这部分是 JDK8 扩容优化的核心。

4.3 高低位拆分:JDK8 最值钱的一次优化

JDK8 在迁移链表时不再逐个重新计算下标,而是把原链表拆成两条:一条留在原来的下标 j,一条移动到 j + oldCap。判断依据就是那一行(e.hash & oldCap) == 0。为什么这么判断?因为容量翻倍后,参与下标计算的有效位数多了一位,这多出来的一位正好是 oldCap 的二进制 1 所在的位置。如果 hash 在这一位上是 0,那么新下标和旧下标完全一样;如果是 1,新下标就等于旧下标加上 oldCap。

这样一来,扩容迁移只需要一次遍历、两次指针拼接,时间复杂度 O(n),而且不需要任何取模运算。更妙的是它天然保持了链表的相对顺序,拆分后每个节点的 next 关系不变,不会出现 JDK7 那种顺序颠倒的问题。代码结构也很有对称美:loHead/loTail维护低位链,hiHead/hiTail维护高位链,遍历结束后把低位链挂到newTab[j]、高位链挂到newTab[j + oldCap]。

4.4 JDK7 的头插法留下了什么历史包袱

JDK7 迁移链表时用的是头插:每取一个节点就插到新桶的头部,结果就是链表顺序完全反转。单线程下这没问题,但并发场景下隐患极大。假设线程 A 和线程 B 同时对同一个桶做迁移,两个线程各自持有部分节点的引用,头插过程中相互交错修改 next 指针,就可能形成环。一旦成环,后续任何落到这个桶上的 get 都会在环里无限循环,表现为 CPU 打满但线程不报错。

JDK8 改成尾插法之后,环形链表问题基本消失,因为尾插不会打乱原有顺序,并发交错修改最多导致部分节点丢失或者 size 不准,不会再出现死循环。但请注意,这不等于 JDK8 的 HashMap 就线程安全了。并发 put 依然可能覆盖数据、丢失节点,多线程环境下必须换 ConcurrentHashMap,这一点没有任何商量余地。

对比项JDK7 HashMapJDK8 HashMap
数据结构数组 + 链表数组 + 链表 + 红黑树
插入方式头插法尾插法
哈希扰动4 次移位 + 多次异或1 次右移 16 位 + 异或
扩容迁移逐个 rehash 计算下标高低位拆分,一次遍历
并发扩容风险可能形成环形链表,CPU 打满可能丢数据,不会死循环
树化无链表长度 ≥ 8 且容量 ≥ 64

4.5 怎么把扩容次数压下来

扩容是 HashMap 里最贵的操作,一次扩容要分配新数组、遍历所有节点、重新建立引用关系。如果容量预估不准,一个不断增长的 Map 可能反复扩容五六次,每次都要搬一遍数据。解决办法很简单:在创建时就把预期元素数量告诉它。

但这里有个容易踩的坑:构造方法传进去的initialCapacity并不等于最终容量。HashMap 会用tableSizeFor把它向上取整到 2 的幂,而且阈值是按capacity * loadFactor算的。所以如果你预计要放 1000 个元素,写new HashMap<>(1000)是不够的,因为阈值只有 750,放到 751 个就会触发扩容。正确写法是new HashMap<>(1000 / 0.75 + 1),也就是约 1334,向上取整到 2 的幂后实际容量是 2048,阈值 1536,足够容纳 1000 个元素且不扩容。

// 预期存放 expectedSize 个元素,且不想触发扩容 int expectedSize = 1000; int initialCapacity = (int) (expectedSize / 0.75f) + 1; Map<String, Object> map = new HashMap<>(initialCapacity); // 如果用的是 Guava,可以直接调用现成的方法 Map<String, Object> map2 = Maps.newHashMapWithExpectedSize(expectedSize);

注意:tableSizeFor的实现是先减一再做五次无符号右移或运算,最后加一,目的是把任意整数向上补齐到 2 的幂。减一是为了处理本身已经是 2 的幂的情况,比如传 16,如果不减一会被补成 32。

5. 面试高频问题清单与答题框架

5.1 基础原理题速查表

下面这张表是我这些年整理出的高频问题,从“底层结构”到“哈希函数”基本覆盖了第一轮面试会问到的内容。答题时不要只背结论,最好能说出“为什么”。

问题核心答法加分补充
HashMap 的底层结构JDK8 是数组 + 链表 + 红黑树JDK7 只有数组 + 链表,没有树化
默认容量和负载因子16 和 0.75容量必须是 2 的幂,构造时会被 tableSizeFor 补齐
为什么容量是 2 的幂索引可用位运算代替取模,扩容时元素位置可预测非 2 的幂会导致部分下标永远取不到
hash 函数为什么右移 16 位让高位参与运算,提升散列均匀度只做一次扰动,JDK7 做了多次
怎么解决哈希冲突链地址法,冲突元素挂成链表链表长度到 8 且容量到 64 时转红黑树
树化阈值为什么是 8泊松分布下概率极低,属于兜底退化阈值是 6,中间留 7 做缓冲
put 的流程定位桶、比较、覆盖或新增、判断扩容先判 table 是否为空,懒加载初始化
为什么重写 equals 必须重写 hashCode否则相等对象可能落到不同桶,查找失效反之 hashCode 相同 equals 不同是合法的

5.2 扩容与并发相关的高频追问

扩容是面试官最爱深挖的地方,因为能同时考察源码熟悉度和并发意识。第一个常见追问是“扩容后元素的新下标怎么算”。标准答案是:要么保持原下标,要么变成原下标加旧容量,判断依据是(e.hash & oldCap) == 0。如果你能顺手说出为什么这个判断成立,也就是容量翻倍后有效位数多一位,基本就能拿到这一分。

第二个追问是“JDK7 和 JDK8 扩容的区别”。要答到三点:插入方式从头插改尾插,迁移方式从逐个 rehash 改成高低位拆分,风险从可能的环形链表死循环变成可能的数据丢失。第三个追问往往落在线程安全上:HashMap 线程不安全,并发 put 可能覆盖数据、size 计数不准,JDK7 还可能死循环,所以并发场景要用 ConcurrentHashMap。

提示:如果面试官继续问 ConcurrentHashMap 怎么保证安全,可以答 JDK7 用分段锁、JDK8 用 CAS + synchronized 锁单个桶头节点,再配合 volatile 保证可见性,扩容时支持多线程协助迁移。这个问题展开能聊很久,但至少要知道版本差异。

5.3 手写一个能跑的最小 HashMap

面试里有时会让你手写一个简易版,重点不是功能完整,而是看你能不能把核心思想表达清楚。下面这个版本保留了数组加链表、容量 2 的幂、扰动函数、扩容高低位拆分的完整骨架,去掉红黑树和并发处理,方便在纸上或者编辑器里快速实现。

public class SimpleHashMap<K, V> { static class Node<K, V> { final int hash; final K key; V value; Node<K, V> next; Node(int hash, K key, V value, Node<K, V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } } private static final int DEFAULT_CAPACITY = 16; private static final float LOAD_FACTOR = 0.75f; private Node<K, V>[] table; private int size; private int threshold = DEFAULT_CAPACITY; private static int hash(Object key) { int h; return key == null ? 0 : (h = key.hashCode()) ^ (h >>> 16); } public V put(K key, V value) { if (table == null) { resize(); } int h = hash(key); int i = (table.length - 1) & h; Node<K, V> p = table[i]; if (p == null) { table[i] = new Node<>(h, key, value, null); } else { Node<K, V> e = p; while (true) { if (e.hash == h && (e.key == key || (key != null && key.equals(e.key)))) { V old = e.value; e.value = value; return old; } if (e.next == null) { e.next = new Node<>(h, key, value, null); break; } e = e.next; } } size++; if (size > threshold) { resize(); } return null; } public V get(K key) { if (table == null) { return null; } int h = hash(key); Node<K, V> e = table[(table.length - 1) & h]; while (e != null) { if (e.hash == h && (e.key == key || (key != null && key.equals(e.key)))) { return e.value; } e = e.next; } return null; } @SuppressWarnings("unchecked") private void resize() { int oldCap = table == null ? 0 : table.length; int newCap = oldCap == 0 ? DEFAULT_CAPACITY : oldCap << 1; Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap]; if (table != null) { for (int j = 0; j < oldCap; j++) { Node<K, V> e = table[j]; if (e == null) { continue; } table[j] = null; if (e.next == null) { newTab[e.hash & (newCap - 1)] = e; } else { Node<K, V> loHead = null, loTail = null, hiHead = null, hiTail = null; while (e != null) { if ((e.hash & oldCap) == 0) { if (loTail == null) { loHead = e; } else { loTail.next = e; } loTail = e; } else { if (hiTail == null) { hiHead = e; } else { hiTail.next = e; } hiTail = e; } e = e.next; } if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } table = newTab; threshold = (int) (newCap * LOAD_FACTOR); } }

写完之后,面试官大概率会问你为什么不用红黑树、为什么不在外面加锁、如果并发调用会出什么问题。这些正好是展示你理解边界的机会,比单纯把代码写完更有价值。

5.4 回答时的加分点与减分点

加分点有几个方向。一是能主动说出设计的权衡,比如为什么负载因子是 0.75 而不是 0.5 或 0.9,为什么树化阈值是 8 而不是 4。二是能把版本差异讲清楚,体现你不是只背了一个版本。三是能联系实际,比如提到容量预设的公式、提到可变 key 的坑、提到并发场景该换容器。

减分点同样明显。只背“数组加链表加红黑树”却说不出红黑树什么时候用、为什么用,会显得很浮。把“HashMap 线程不安全”说成“加个 synchronized 就好了”,说明对并发成本没概念。把hashCode和equals的关系说反,或者坚持认为 hashCode 相同就一定相等,这是基础硬伤。还有一种常见问题是被问到扩容时只说“容量翻倍”,却答不出元素迁移的具体规则,这往往是没真正读过源码的表现。

6. 实战踩坑与性能调优记录

6.1 踩坑速查表

下面这些坑我在实际项目里要么自己踩过,要么在代码评审和故障复盘里见过,整理成表格方便对照。

现象可能原因处理方式
接口偶发卡死、CPU 打满并发使用 HashMap,JDK7 可能成环换 ConcurrentHashMap,抓线程栈确认
map.get 返回 null 误判为不存在value 本身允许为 null改用 containsKey 或者用 Optional 包装
数据明明放进去了却取不到key 是可变对象,字段变化导致 hashCode 变了key 用不可变对象,或放进去后不再修改
内存占用远超预期初始容量设置过大,或长期只增不删按预期元素数设置,定期清理或用弱引用容器
大量元素时性能断崖式下跌哈希函数质量差,冲突集中检查 key 的 hashCode 实现,必要时自定义包装类
扩容频繁发生拖慢写入初始容量未预设,逐个增长用 expectedSize / 0.75 + 1 预设容量

6.2 容量初始化到底该写多少

很多人的习惯是new HashMap<>()无参构造,觉得反正会自己扩容。小数据量下没问题,但一旦这个 Map 会长期存放上千甚至上万条数据,无参构造就意味着从 16 开始一路扩容到 2048 甚至更大,中间十几次数组分配和数据迁移都是纯开销。在高频写入路径上,这些开销会直接体现在接口的 P99 上。

正确的做法是根据业务数据量预估容量。这里要注意两点:一是预期元素数量除以 0.75 再向上取整,二是 HashMap 会再把容量补齐到 2 的幂,所以实际分配可能比你算的还大一点。如果不确定数据量上限,可以给一个偏保守的估计,宁可稍微浪费一点内存,也别让它频繁扩容。如果实在不知道该填多少,可以参考历史监控里这个容器的元素数量峰值,取峰值除以 0.75 再加一。

6.3 可变对象当 key 的坑有多深

把可变对象当作 key 是 HashMap 使用中最隐蔽的错误之一。假设你定义了一个 User 对象,只重写了 equals 和 hashCode,基于 id 字段计算哈希值,然后把它作为 key 放进 Map。后来业务逻辑修改了这个 User 的 id,这时候它的 hashCode 变了,但它在数组里的位置还是按旧的哈希值算的。之后你再用这个对象去 get,计算出的新下标指向另一个桶,自然找不到,而原来那个桶里躺着的节点又永远不会被访问到,形成实质上的内存泄漏。

解决方案有三个层次。首选是用不可变对象作为 key,比如 String、Integer、枚举或者你自己定义的 final 字段类。如果一定要用可变对象,那就保证放进去之后不再修改任何参与 hashCode 计算的字段。实在控制不住,可以给 key 加一层不可变包装,或者干脆把 key 换成 id 这种稳定值。吃过一次亏之后,我现在的习惯是:只要看到 Map 的 key 是自定义对象,第一反应就是去检查它的 hashCode 是不是基于可变字段算的。

注意:如果两个不同的可变对象在放入时 hashCode 相同,之后其中一个字段变化导致 hashCode 改变,也会出现类似的查找失效问题。这种 bug 不会抛异常,只会在某个时间点开始表现成“数据丢了”,排查起来非常耗时。

6.4 什么时候该果断换掉 HashMap

HashMap 不是万能容器,以下几种情况我会毫不犹豫地换掉它。第一是多线程读写,直接上 ConcurrentHashMap,这是底线。第二是需要按插入顺序或者访问顺序遍历,用 LinkedHashMap,它在 HashMap 的基础上多维护了一条双向链表,可以指定accessOrder实现 LRU。第三是 key 需要排序,用 TreeMap,底层红黑树保证有序,也支持范围查询。第四是元素数量极少(比如三五个)且查找频繁,这时候一个数组遍历可能都比哈希计算加下标定位快,因为常数因子更小。

还有一种情况容易被忽略:如果这个 Map 的 key 是枚举类型,用 EnumMap 会比 HashMap 快很多,因为枚举的 ordinal 天然是连续整数,可以直接当数组下标用,完全不需要哈希计算。这类针对性优化在自己的工具类里用起来很舒服,只是要注意可读性,别为了微小的性能提升把代码写得没人看得懂。

在写完这一大圈之后,我个人实际操作下来的体会是:HashMap 真正难的不是记住“数组加链表加红黑树”这句话,而是搞清楚每个数字背后的权衡,以及这些权衡在什么场景下会失效。0.75、8、6、64、16 这几个数字,单看都是结论,串起来看才是设计思路。当你下次在代码里写下new HashMap<>()的时候,能顺手想一想这个容器大概会装多少条数据、会不会被多个线程碰到、key 是不是稳定的,那这篇内容的目的就达到了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询