☰
HashMap底层原理深度拆解:从哈希碰撞到扩容并发问题
2026/10/14 5:24:08 网站建设 项目流程

面试官问出这句话的时候,他其实并不想听你背一遍“数组加链表、链表长度超过8转红黑树”。这个知识点网上随便一搜就有几十篇,他真正想知道的是:你有没有认真读过源码、能不能讲清楚每个设计背后的权衡,以及你有没有踩过多线程、扩容、哈希碰撞这些真实的坑。

我见过太多候选人,背到“加载因子0.75”就卡住了,说不清为什么是0.75而不是0.5或1.0;也知道有人在面试时只讲“JDK 1.8用尾插法替代了头插法”,却讲不明白为什么头插法会在并发扩容时造成死循环。这篇文章我想把 HashMap 底层原理完整地拆开聊一遍,从哈希算法、数组定位、put 流程、扩容分链到并发问题,把源码级的细节和设计动机都串起来。适合正在准备Java面试的人,也适合工作中想真正搞懂 HashMap 的同学。

1. 为什么面试总爱问HashMap:从“背答案”到“讲设计”

1.1 数据结构演进:JDK 1.7到1.8到底变了什么

HashMap 在 JDK 1.7 时代的结构很简单:一个数组,数组每个元素是一条链表。到了 JDK 1.8,结构变成了“数组 + 链表 + 红黑树”。这个变化不是拍脑袋决定的,而是要解决同一个桶里哈希碰撞过多时,链表查询成本从 O(n) 恶化的问题。

我最早接触 HashMap 时也以为“链表只是兜底方案”,但后来读源码才发现,整个 HashMap 的设计重心其实都围绕一件事:让 key 尽量均匀地散落在数组各个位置,同时把碰撞后的处理成本控制在一个可接受的范围内。

对比项JDK 1.7JDK 1.8
数据结构数组 + 链表数组 + 链表 + 红黑树
节点插入方式头插法尾插法
哈希扰动4次扰动1次高16位异或低16位
扩容后重哈希全量rehash按hash & oldCap分两条链
树化不支持链表长度 ≥ 8 且数组长度 ≥ 64

1.8 的选择其实是“空间换时间”的典型。红黑树的插入、删除、查找都是 O(log n),但每个树节点比普通链表节点多占用一部分空间。如果任意碰撞就树化,空间代价太高;如果一直不树化,最坏情况下的查询又会退化。于是源码选择了折中:链表长度超过8才考虑树化,并且数组长度必须达到64。

面试中最容易踩的坑就在这儿:很多人只记住了“链表长度大于8就转红黑树”,却把“数组长度大于64”这个前置条件漏掉了。实际上当数组长度不足64时,HashMap 会优先触发扩容,而不是直接树化,因为此时更合理的解决碰撞方式是增大数组规模,让元素重新散开。

1.2 数组 + 链表 + 红黑树为什么能解决哈希碰撞

拿现实场景类比一下:数组就像酒店的一排房间,你根据房号直接跑到对应房间,时间复杂度 O(1);但两个客人算出的房号相同,只能在前台排队登记,这就是链表。如果某个房号下的客人特别多,就要把排队方式从“挨个问”升级成“按楼层索引找”,这就是红黑树。

哈希碰撞的本质是:不同 key 通过哈希算法算出的索引落在同一个数组下标上。HashMap 不追求杜绝碰撞,那不可能,它追求的是碰撞足够少,且碰撞发生后有可控的兜底结构。

源码中为此做了两层设计:第一层是扰动函数,把 key 的 hashCode 高16位和低16位异或,让低位的信息更丰富,减少索引的规律性;第二层就是链表和红黑树的自动切换机制。这两层配合,才让 HashMap 在大多数场景下都能保持接近 O(1) 的操作效率。

我建议面试时不要只回答结构,而是补一句“HashMap 的时间复杂度平均是 O(1),最坏是 O(log n)”,并且解释一下为什么最坏情况是 O(log n) 而不是 O(n)。这样一下就和只会背概念的候选人拉开差距。

2. 哈希算法与数组定位:hash()函数背后的细节

2.1 为什么容量必须是2的整数次幂

这是 HashMap 里被问得最多、也最容易被一笔带过的点。默认初始容量是16,扩容时翻倍变成32、64、128,始终是2的整数次幂。不是巧合,是为了让(n - 1) & hash能够等价于hash % n。

按位与运算比取模运算快得多。当 n 是2的幂时,n - 1的二进制形态是低位全1,比如 n=16,n-1 = 15,二进制是1111。此时hash & 1111相当于只保留 hash 的低4位,结果范围是0到15,正好能落到数组下标上。

如果容量不是2的幂,比如 n=15,n-1 = 14,二进制是1110。最低位被掩掉了,那么索引永远不可能是奇数,哈希的空间利用率直接少了一半,碰撞概率也会显著上升。所以容量取2的幂,不只是性能优化,更是保证索引分布均匀性的基础。

构造 HashMap 时如果你指定了初始容量,比如new HashMap(5),源码并不会直接用5,而是会通过tableSizeFor方法把它转换成大于等于5的最小2次幂,也就是8。这一点可以从构造代码里看到:

public HashMap(int initialCapacity, float loadFactor) { // 省略参数校验 this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }

tableSizeFor做的事情就是把传进来的容量不断右移再按位或,最终把所有低位都变成1,然后加1,得到一个2的幂。这个细节我很喜欢在面试时主动讲出来,因为它证明了你不是只看了博客,而是看过源码里这层处理逻辑。

2.2(n - 1) & hash的计算过程

数组定位的核心代码在putVal里:

if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);

这里的(n - 1) & hash就是决定 key 放到哪个桶的最终公式。因为n是2的幂,这个式子等同于取模。我用一个具体的例子演算一下。

假设 table 长度 n=16,某个 key 的 hash 值是1111 1111 1111 1111 0101 0101 0101 0101(二进制)。n - 1 = 15,二进制是0000 0000 0000 0000 0000 0000 0000 1111。

1111 1111 1111 1111 0101 0101 0101 0101 & 0000 0000 0000 0000 0000 0000 0000 1111 ------------------------------------------ 0000 0000 0000 0000 0000 0000 0000 0101

结果是5,这个 key 会落在数组下标为5的位置。如果 key 的 hash 值只是某一个高位不同,而低4位相同,那么它们在长度为16的数组上仍然会碰撞。这就是需要扰动函数的原因。

2.3 扰动函数的作用

JDK 1.8 的hash()方法写得非常精简:

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

第一行先取得 key 的 hashCode,第二行把 hashCode 的高16位与低16位做异或。为什么这么做?因为(n - 1) & hash在数组长度不大时,只用到了 hash 的低位,比如 n=16 时只用低4位。如果两个 key 的 hashCode 低位相同而高位不同,它们就会碰撞,而扰动函数让高位的差异也能影响到低位,从而让索引分布更均匀。

JDK 1.7 的扰动函数做了4次异或操作,包括右移20位、12位、7位、4位。1.8 简化为一次h ^ (h >>> 16),不是因为效果变差了,而是因为现代 CPU 的计算能力和哈希分布质量都比 1.7 时代更好,一次扰动在绝大多数场景下已经足够。同时这也减少了计算开销。

我在实际写代码时几乎不会手动覆写hashCode()来针对 HashMap 优化,但如果你自定义类作为 key,强烈建议让hashCode()的分布足够均匀。否则就算 HashMap 内部做了扰动,也救不回来。典型反例是拿所有属性拼接字符串再hashCode(),如果对象之间只有少量字段不同,哈希值分布往往会集中在某些区间,碰撞率居高不下。

3. put流程:从插入到树化的完整链路

3.1 putVal的逐步拆解

好多人都知道put是 HashMap 的核心操作,但具体流程到底是什么,能讲清楚的人真不多。putVal的主干逻辑大致可以拆成六步:

  1. 如果 table 为空或长度为0,先触发resize()初始化数组。
  2. 根据(n - 1) & hash找到目标桶。
  3. 如果桶为空,直接创建新节点放入。
  4. 如果桶不为空,判断首节点是否与待插入 key 相同(equals比较),相同则记录旧值等待覆盖。
  5. 如果首节点是红黑树节点,走红黑树的插入逻辑。
  6. 如果首节点是普通链表节点,遍历链表查找相同 key;没找到就在链表尾部插入新节点。插入后如果链表长度达到树化阈值,调用treeifyBin。

这六步对应源码里的分支结构非常清晰。我用简化的伪代码描述一下,对照源码看会更直观:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }

注意一个细节:如果 key 已经存在,HashMap 会用新值覆盖旧值,并返回旧值;如果 key 不存在,返回 null。所以put的返回值可以用来判断是否发生了覆盖。这个点在一些老项目的坑里很有用,比如我遇到过一次事故,就是因为用map.put(key, value)的返回值判断幂等性,结果多次提交相同请求时把前面成功的结果覆盖掉了,但返回值不为 null 的逻辑恰好又能掩盖问题。

3.2 树化与反树化的触发条件

树化条件大家背得都很熟:链表长度大于等于8,数组长度大于等于64。但源码里真正触发树化的地方是treeifyBin,它内部还有一个数组长度判断:

final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { // 真正的链表转红黑树逻辑 } }

MIN_TREEIFY_CAPACITY是64。也就是说,链表长度到了8,但数组长度还不到64时,HashMap 不会树化,而是直接扩容。因为数组扩容后,同样的哈希值在新的长度下索引会重新分布,很多碰撞会自然消除,链表长度会降下来,没必要引入红黑树。

反过来,红黑树什么时候退化成链表?在扩容拆分时,如果某个树桶里的节点数小于等于6,就把树退化成链表。这背后也有一个数字:UNTREEIFY_THRESHOLD = 6。为什么不直接用8?因为如果阈值设成和8一样,那么链表刚转成树又马上退化成链表,会在8和8之间反复横跳,导致频繁转换。设成6相当于留了一个滞回区间,避免抖动的设计。这个概念在很多系统设计里都能看到,面试时能主动提出来会加分。

3.3 扩容时的链拆分

这块内容我认为是 HashMap 原理中最有意思的地方之一。Java 8 的扩容不再像1.7那样全量 rehash,而是把每个桶里的链表拆成两条,分别放到原位和“原位+旧容量”的位置。

判断依据是(e.hash & oldCap)是否等于0。原理还是2的幂:旧容量 oldCap 是2的幂,那么它在二进制里只有某一位是1。扩容后 n 翻倍,n - 1比原来的oldCap - 1多了一个高位1。一个元素的索引是否会变化,就取决于它的 hash 值在和这个高位对应的那一位是0还是1。

举个例子。旧容量是16,16的二进制是0001 0000。扩容后容量是32,32 - 1 = 31,二进制是0001 1111。一个索引为5的元素(hash 的低位相关值0101),如果它的 hash 在二进制第4位(对应16那一位)是0,那么新索引还是5;如果是1,新索引就是5 + 16 = 21。

源码中resize()里的关键片段:

Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; 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 = next; } while (e != null);

loHead和loTail组成留在原索引的链表,hiHead和hiTail组成移动到“原索引+oldCap”位置的链表。这样一条原链表被拆成两条,节点顺序保持不变,并且整体迁移效率很高。这个设计不仅解决了1.7并发扩容时的死循环问题,也避免了全量 rehash 对 CPU 的浪费。

面试官如果追问“扩容后一条链表如何拆成两条”,你能画出这样两条链的走向,基本已经高出大多数人一大截了。

4. 为什么加载因子是0.75:性能与空间的博弈

4.1 泊松分布与源码注释中的数字

默认加载因子是0.75,这背后有一个概率学解释。JDK 源码的注释里提到,在理想随机哈希码下,某个桶中节点数量的分布近似泊松分布,并给出了概率表:

桶中节点数概率
0约 0.6065
1约 0.3033
2约 0.0758
3约 0.0126
4约 0.0016
5约 0.0002
6约 0.000014
7约 0.0000009
8约 0.000000006

当链表中节点数达到8时,概率已经降到千万分之几。所以树化阈值定成8,是因为在正常情况下,链表长度很难超过8;一旦超过,说明你的哈希函数分布出了问题,或者遭遇了恶意构造的哈希碰撞攻击,此时用红黑树来兜底会显著缓解查询性能恶化。

加载因子0.75本质上是一个时间和空间的折中。调成0.5的话,数组会更早扩容,碰撞更少,查询更快,但内存浪费严重;调成1.0的话,数组利用率更高,但碰撞增多,尤其在链表树化之前,查询和插入的成本都会上升。0.75是实测下来综合表现比较好的一档。

4.2 从负载因子角度理解扩容阈值

负载因子和容量的乘积称为扩容阈值threshold。当size > threshold时,HashMap 会扩容。默认容量16 × 0.75 = 12,也就是元素个数超过12个就触发扩容到32。这个“12”是怎么来的,其实就是这么简单。

但是这里有一个容易忽略的细节:threshold在构造阶段可能是tableSizeFor的结果,而不是容量 × 负载因子。原因很好理解,table 还没初始化,容量还是0,没法直接算。真正初始化时,resize()会把threshold重新赋值为newCap * loadFactor。我在刚开始读源码时被这个逻辑绕了好一会儿,这里提出来,省得大家踩同样的坑。

4.3 初始容量选择的实战经验

如果你确定 Map 里大约要放 N 个元素,直接用new HashMap<>(N)其实并不够好,更稳妥的是在构造时指定N / 0.75 + 1左右的容量。举个例子,你要放1000个元素,如果直接指定容量1000,HashMap 会转换成1024,阈值是1024 * 0.75 = 768。你刚放满768个元素,它就开始扩容,白白多了一次扩容的损耗。

正确做法是new HashMap<>( (int) (1000 / 0.75f + 1) ),也就是大约1334,转换成2的幂是2048,阈值变成1536,1000个元素放进去不会触发扩容。这种细节在写高吞吐服务时确实能省下一点点性能开销,面试时如果顺手提出来,也会让面试官觉得你有真实的容量预估经验。

很多人还会问:既然讲了扩容,为什么 HashMap 不在 put 之前提前扩容?源码的顺序是:先把新节点插入到桶里,再判断++size > threshold。也就是说,本次插入触发了扩容时,是带上了刚插入的这个节点一起扩容的。这一点和 RedHat 一些老文章讲的“先扩容再插入”并不一样,实际要看源码。

5. 线程安全问题与并发场景下的真相

5.1 JDK 1.7 头插法为什么会导致死循环

JDK 1.7 的扩容方式是用头插法把旧链表迁移到新数组。所谓头插,就是新节点插到链表的头部,旧的头部节点会被挤压到后面。问题出在并发场景下,两个线程同时执行扩容时,可能把链表节点的 next 指针改乱,形成循环链表。

我把这个小场景简化演示一下:假设旧数组某个桶里有两个节点 A、B,A.next 指向 B。线程1扩容时先处理 A,线程2也在同时处理这个链表。因为头插法会修改 A 和 B 的 next 指针,在某个交叉时刻,A 的 next 变成 B,B 的 next 又变成 A,两条记录互相引用,循环链表形成。后续任何查找操作在这个桶里都会死循环,CPU 直接飙满。

JDK 1.8 改用尾插法,新节点加在链表尾部,扩容时用上面讲的 lo/hi 两条链拆分法,节点的相对顺序保持不变,因此从根本上避免了循环链表的产生条件。这也是为什么你常常听到“1.8 修复了死循环问题”的原因。

5.2 JDK 1.8 并发下不是安全的

别误以为1.8的 HashMap 可以在多线程下随意用了。尾插法只是消除了死循环的典型场景,但并发写入时依然有两个严重问题。

第一个是数据覆盖:两个线程同时 put,命中了同一个空桶,线程A判断桶为空,线程B也判断桶为空,A先写入,B后写入直接覆盖了 A 的结果。这不是概率极低的事件,在高并发写入时经常出现。

第二个是 size 计数不准确,++size不是原子操作,并发时会出现计数丢失。此外,迭代时如果其他线程修改了 Map,会抛出ConcurrentModificationException。这是modCount快速失败机制的作用:每次结构性修改都会让modCount加1,迭代器在遍历时检测到modCount变化,立即抛异常。

我见过不少线上事故,用 HashMap 做本地缓存,多个线程往里写,结果偶尔出现 Key 对应的 Value “神秘消失”。排查到最后就是 HashMap 并发覆盖问题。这里必须强调:单线程场景用 HashMap,并发场景请用 ConcurrentHashMap。无论面试还是实际开发,这条红线不能碰。

5.3 安全的替代方案和 HashMap 的适用边界

并发环境下,你可以选ConcurrentHashMap。它的设计思路和 HashMap 有本质区别:锁粒度从整个表细化为单个桶,也就是细粒度锁。写入时只锁住当前桶,读操作不加锁,并借助 volatile 和 CAS 保证可见性,因此在大多数场景下并发性能远好于用Collections.synchronizedMap包装 HashMap。

Collections.synchronizedMap的实现在每个方法上加了synchronized锁,简单粗暴,适合低并发场景,但不适合高并发。另一个思路是直接用HashTable,不过这已经是上古时代的方案,性能表现远不如 ConcurrentHashMap,现在基本可以淘汰。

也有人问,为什么不用 ConcurrentHashMap 替代所有 Map?因为单线程下 HashMap 没有任何锁开销,性能仍然是同类中最高的,hashCode分布良好时平均 O(1)。所以正确的策略是:单线程读写用 HashMap,需要并发安全用 ConcurrentHashMap,需要排序时用 TreeMap,需要维持插入顺序时用 LinkedHashMap。

5.4 关于 null key 和 null value

最后补一个小众但经常被追问的点:HashMap 允许 key 为 null,也允许 value 为 null。链表和红黑树的结构都可以容纳 null value,唯一的特殊处理是 null key 的 hash 值固定为0,会落到数组的第0个桶。

TreeMap 不允许 null key,ConcurrentHashMap 不允许 null key 和 null value。这个差异不是随便设计的,而是和并发安全有关。ConcurrentHashMap 如果允许 null value,在get返回 null 时,你无法区分是“key 不存在”还是“value 本身就是 null”,这会给并发场景下的原子语义带来歧义,所以干脆禁止。

很多人在面试时会忽略这个细节,但面试官问 HashMap 和 ConcurrentHashMap 有什么区别时,null 约束是一个高频考点。主动提出来,能体现你的理解深度。

6. 源码阅读的几个关键手法与我的实战体会

6.1 调试时如何观察扩容与树化过程

我自己的习惯是,在读 HashMap 源码时不会只盯着代码看,而是会写一个很小的调试 Demo,然后打上条件断点去观察内部状态。比如验证树化条件,可以自定义一个hashCode固定返回同一个值的类,让所有 key 都落到同一个桶,然后不断 put,观察treeifyBin什么时候被调用,以及数组长度什么时候从16扩容到64。

调试时我还喜欢看table数组本身的类型变化。当节点类型从Node变成TreeNode时,你就知道红黑树生效了。打印出table.length、size、threshold三个值,再对照扩容条件,整个机制会清晰很多。

6.2 一句话总结 HashMap 的设计哲学

HashMap 所有复杂设计的背后,其实就是两个词:均匀散列和成本可控。让元素尽量均匀地分布在数组上,控制查询插入的时间成本;当碰撞不可避免时,用链表兜底;当链表退化时,用红黑树兜底;当空间不足时,用扩容兜底。理解了这个哲学,再去看源码里的每个阈值和每个分支,都会觉得顺理成章。

另外我建议所有准备面试的人,花一个下午时间把 HashMap 源码完整读一遍,重点看hash()、putVal()、resize()、getNode()这四个方法就够了。读完之后再尝试不看源码把 put 流程画出来,画不出来的地方就是你的知识盲区。我在带团队时也经常用这个方法考察新人,能在半小时内把 put 流程和扩容拆分讲清楚的人,代码功底基本不会差。

HashMap 的底层原理覆盖面很广,但核心永远绕不开那几个设计决策。我最后再分享一个实用的经验:看源码时不要边看边背,而是带着“为什么这么写”的疑问去读。比如看到(n - 1) & hash,就想想为什么不用%;看到TREEIFY_THRESHOLD = 8,就去搜那句泊松分布的注释。这样读一遍,比你刷十篇面试题都管用。

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

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

立即咨询