先问一个老生常谈但依然能挂住不少人的问题:HashMap 到底在什么时候扩容?为什么 JDK 1.7 的 HashMap 在并发场景下会出现 CPU 100% 甚至死循环,而 JDK 1.8 重写了扩容逻辑之后,同样的问题却几乎听不到了?
我在前几年排查一个线上接口偶发卡死的问题时,就撞上过这个经典事故。当时服务没有明显的慢 SQL,也没有外部调用超时,但某个线程的 CPU 占用异常高,jstack 一抓,线程栈正好卡在 HashMap 的扩容transfer方法里。后来翻代码才发现,那是 JDK 1.7 的 HashMap,多个线程同时触发 resize,链表节点互相指向,形成了环形链表。从那之后,我再没把 HashMap 当"随便用用就行的集合",而是把它的扩容机制、哈希分布、并发风险这些底层原理彻底过了一遍。
这篇文章就围绕 HashMap 的扩容机制展开,重点对比 JDK 1.7 和 JDK 1.8 的实现差异。适合正在准备面试的人,也适合那些写业务代码时被扩容卡顿、并发异常坑过,想真正搞懂 HashMap 底层逻辑的后端工程师。我会从扩容的触发条件讲起,把两版 JDK 的源码拆开,分析它们的设计取舍,最后再聊聊实际工程里怎么规避扩容带来的性能问题。
1. 扩容的底层逻辑:容量为什么必须是 2 的幂、0.75 这个数字怎么来的
要理解两版 JDK 的扩容差异,不能一上来就盯着resize方法看。扩容只是结果,真正决定扩容怎么做的是 HashMap 整体采用的哈希存储模型。先把这个基础打牢,后面看 1.7 和 1.8 的区别才不会一头雾水。
1.1 索引计算的两个硬性要求:取模等价与低位均匀
HashMap 底层是数组加链表(1.8 里再引入红黑树),每次插入一个 key-value,都需要先确定这个键值对应该放到数组的哪个下标位置。计算索引的方式在 1.7 和 1.8 里是一样的:
// JDK 1.7 static int indexFor(int h, int length) { return h & (length - 1); } // JDK 1.8 里没有单独抽方法,直接是 e.hash & (newCap - 1)核心就一句:hash & (length - 1)。这是一个位运算,但它等价于hash % length,只有在length是 2 的幂时才成立。为什么 HashMap 非要这么干?
第一个原因是性能。位运算比取模运算快一个数量级,而 HashMap 在插入、查找、扩容时都要反复计算索引,这个微小的性能差异会被放大很多倍。
第二个原因更关键:位运算能保证哈希值真正均匀落在数组下标上。我给你算一笔账。如果数组长度是 8,length - 1的二进制是0111,那么索引只取决于hash值的低三位,任何一个位的不同组合都能反映到索引上。但如果数组长度是 7,length - 1的二进制是0110,最低位永远是 0,所有哈希值算出来只能是偶数,数组下标为奇数的位置永远空着,这等于白白浪费了一半空间,还让所有哈希值都挤在偶数的桶里,碰撞概率翻倍。
所以 HashMap 对容量的要求是:初始容量和扩容后的容量都必须保证是 2 的 n 次幂。这也是 JDK 1.8 里tableSizeFor方法存在的意义——你构造 HashMap 时传一个容量,底层会强行给你算出一个大于等于这个值且最接近的 2 的幂。
1.2 加载因子 0.75:一个来自泊松分布的工程折中
扩容不是等数组塞满了才触发,HashMap 维持一个加载因子(load factor),默认是0.75。当元素个数超过capacity * loadFactor时,就触发扩容。也就是说,默认容量 16 的 HashMap,存到第 13 个元素时就要扩容到 32。
为什么偏偏是 0.75,而不是 0.5 或者 1.0?
0.75 是一个在时间和空间之间取平衡的经典数值。加载因子越小,数组越稀疏,哈希碰撞越少,put/get 的摊还成本越低,但内存浪费明显;加载因子越大,数组越紧凑,空间利用率高,但每个桶里的链表会越来越长,查询会慢慢退化成遍历链表。
JDK 源码里有这么一段注释:当加载因子为 0.75 时,桶中元素个数服从参数约为 0.5 的泊松分布。链表长度达到 8 的概率大概是千万分之六,也就是基本不可能发生。这个数据是经过统计模型推算的,不是拍脑袋定的。用一句话总结就是:0.75 之下,链表长度很难超过 8,绝大多数桶里都只有 0 到 2 个元素,查询效率接近理想的 O(1)。
这里顺带提一个常见误区:加载因子不是"数组使用率超过 75% 才扩容",而是"元素个数超过 容量 × 加载因子 才扩容"。元素个数包括数组上挂的所有链表节点和红黑树节点,不只算非空桶的数量。
1.3 扩容的触发时机:size 超过 threshold 而不是 bucket 用满
HashMap 里有两个字段:size表示当前键值对数量,threshold表示扩容阈值。默认情况下:
threshold = capacity * loadFactor扩容触发的条件很简单:size > threshold。但在两版 JDK 里,这个判断的时机不一样,这也是 1.7 和 1.8 的一个显著区别。1.7 是先判断是否需要扩容,再插入新元素;1.8 是先把元素插进去,再判断要不要扩容。这个区别在下文拆源码时细说。
另外注意一个细节:扩容的倍数不是 1.5、也不是 2 的任意倍数,而是严格两倍,oldCap << 1。原因还是那个——只有保持 2 的幂,hash & (length - 1)的等价取模关系才会一直成立,同时 1.8 里那个"根据哈希高位判断去留"的优化才能成立。它不是随便翻倍,是为了让你省掉重算所有哈希的功夫。
2. jdk1.7 扩容链路复盘:头插法、transfer 与并发死循环事故
JDK 1.7 的扩容代码是整个 HashMap 历史上被讨论最多的一段,也是面试里最经典的问题来源。这段代码在并发环境下极容易出问题,但即便在单线程环境下,它的设计思路也跟 1.8 完全不同。
2.1 addEntry 里那句 if 条件:为什么"先扩容再插入"
先看 JDK 1.7 的插入入口,也就是put方法最终调用的addEntry:
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }注意这里的判断带了一个额外的条件:null != table[bucketIndex]。也就是说,1.7 里扩容不是"元素个数超过阈值就扩容",而是在"元素个数超过阈值,并且新元素要落到的桶不是空的"时才扩容。
这个设计的逻辑是:如果新元素要落到一个空桶里,那么即使当前 size 已经达到 threshold,插进去之后整体链表平均长度也不会明显恶化,可以先不扩容。但它也带来一个副作用——同一个 HashMap 里,可能出现某个桶链表已经很长,而 size 还没到 threshold 的情况,查询性能在局部位置受拖累。这个"先检查、再插入"的顺序,和 1.8 的"先插入、再检查"正好相反。
另外注意createEntry用的是头插法:新节点永远插在链表最前面。为什么?因为作者认为刚插入的数据大概率会被立刻访问,头插可以减少一次遍历,属于一种"局部性"优化。这个优化是 1.7 并发死循环的根源之一。
void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }2.2 transfer 的核心机制:单链表从头拆到新表
真正执行扩容迁移的是transfer方法,这也是 1.7 扩容机制里最核心、也最危险的一段代码:
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }逐行拆解一下。外层循环遍历旧数组的每个桶,内层循环遍历这一个桶上的整条链表。每次拿到当前节点e,先暂存它的下一个节点next,然后重新计算e在新数组里的下标i,接着把e.next指向newTable[i]现在的链表头,再把e放到newTable[i]的位置上。
这个过程的形状是:从旧链表的头节点开始,一个一个摘下来,以"反转"的方式插到新链表前面。举例说明,假设旧数组里某个桶有一条链表 A -> B -> C,扩容时它们哈希到新数组的同一个桶;迁移过程大致是:
- 取出 A,A.next 指向 null,新桶放 A。
- 取出 B,B.next 指向 A,新桶放 B。
- 取出 C,C.next 指向 B,新桶放 C。
最终新桶里是 C -> B -> A,链表顺序从原来的 A->B->C 变成了反转后的 C->B->A。
单线程环境下,这个"反转"虽然改变了链表的顺序,但不会丢节点、也不会死循环。真正的问题出在多线程并发扩容时。
2.3 并发死循环的完整推演:从 e.next 挂起开始
现在演示一下两个线程并发扩容时如何形成环形链表。这个案例在面试和博客里被讲过很多次,但我还是想用最直白的方式把推演过程写清楚。
假设旧表容量为 2,某个桶里有一条链表 A -> B,两个节点的哈希值计算后仍会落入扩容后新表的同一个桶。
线程 1 和线程 2 同时执行resize。线程 1 先进入transfer,执行到关键位置:
Entry<K,V> next = e.next; // e = A, next = B刚取出next = B,线程 1 被操作系统挂起,不再往下执行。
线程 2 继续执行完整的扩容流程。因为它是单线程推进,最终新表里这个桶的链表变成了 B -> A(头插法反转了 A -> B 的顺序)。
此时线程 1 恢复执行,它的局部变量状态还停留在e = A, next = B。它继续执行:
int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; // newTable[i] 现在指向 B,所以 A.next = B newTable[i] = e; // newTable[i] 指向 A e = next; // e = B注意,因为线程 2 已经迁移完成,线程 1 用的新表数组newTable并不是线程 2 正在用的那个新数组,而是它自己新建的另一个数组,初始状态下newTable[i]是 null。所以第一轮 A.next 被设成 null,然后 newTable[i] = A。这一轮看起来正常。
问题出在下一轮,继续处理 B:
Entry<K,V> next = e.next; // e = B,B.next 在旧链表里是 null,所以 next = null int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; // newTable[i] 当前是 A,所以 B.next = A newTable[i] = e; // newTable[i] 指向 B e = next; // e = null从这里看,B.next = A,newTable[i] = B,然后循环结束。最终这条链表是 B -> A。注意,线程 1 并没有读到线程 2 迁移后的状态,它只是在自己新建的数组上把 A 和 B 按反转后的顺序放好,看起来也没有问题。
但经典死循环场景还有一种更常见的推演路径:如果线程 1 在迁移 A 之后、迁移 B 之前,线程 2 已经完成了整个扩容,那么线程 1 的e = A在后续处理时,可能读到已经被线程 2 修改过next指向的节点。更具体的场景取决于线程切换的时机。比较经典的循环链形成路径是:
假设线程 2 迁移完后,B 的next指向 A(因为线程 2 头插法反转了链表)。线程 1 此时拿到e = A, next = B,执行A.next = newTable[i]。如果此时线程 1 的newTable[i]已经被另一个并发流程放入了 B,那么 A.next = B。而 B.next 已经被线程 2 置成 A,于是 A -> B -> A,环形链表形成。
一旦形成环,后续任何线程去get这个桶里的 key,就会在链表中循环遍历,永远走不到链表末尾,CPU 被拉满,服务表现为假死。
这个问题的根因有三个:头插法改变了链表顺序;并发时两个线程操作的同一个旧链表状态互相干扰;扩容过程没有加锁。三个条件缺一个,死循环都没那么容易形成。
3. jdk1.8 扩容机制重构:先插后扩、尾插法和高低位拆分
JDK 1.8 对 HashMap 几乎做了重写,扩容这块变化尤其大。修复并发死循环不是唯一目的,它还顺带解决了扩容时全量重算哈希、链表过长时查询劣化、链表顺序反转导致局部性变差等一堆问题。
3.1 putVal 的主流程:尾插、阈值判断与树化入口
JDK 1.8 的put走的是putVal方法,主流程比 1.7 复杂不少,但关键差异点很清晰:
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) // -1 for 1st treeifyBin(tab, hash); break; } ... } } } ... if (++size > threshold) resize(); ... }三个关键变化:
第一,新节点挂在链表尾部,即尾插法。代码里p.next = newNode(...),遍历到链表最后一个节点才插入。尾插法的好处是链表顺序不会被反转,扩容后头的顺序保持,环形链表问题从根源上被消除。
第二,扩容判断放到了插入完成之后:if (++size > threshold) resize();先插后扩,跟 1.7 的"先扩后插"正好相反。
第三,当链表长度达到TREEIFY_THRESHOLD - 1,也就是桶内节点数达到 8 时,调用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) { // 真正执行链表 -> 红黑树 } }当数组长度小于 64 时,即使链表长度已经达到 8,也不会立即树化,而是先扩容数组。因为扩容可以把链表拆散,很多碰撞会自然消失。这也是"链表长度 8 才树化"这个规则的完整表述:在数组容量达到 64 的前提下,链表长度达到 8 才树化。
3.2 resize 的双重身份:初始化容器与两倍扩容
JDK 1.8 的resize方法承担了两个职责:数组初始化,以及后续的两倍扩容。它比 1.7 的transfer方法长得多,但逻辑其实更清晰。关键分三段。
第一段。计算新容量和新阈值:
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; } else if (oldThr > 0) newCap = oldThr; else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; ... }第一次初始化时,oldCap等于 0,如果构造函数里指定了初始容量,oldThr会是tableSizeFor算出来的 2 的幂;否则走默认逻辑:容量 16,阈值 12。
扩容时,新容量直接是oldCap << 1,新阈值如果旧容量大于等于 16,也直接翻倍oldThr << 1,省去了一次乘法。
第二段是分配新数组:
@SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab;第三段是节点迁移,这是整个 1.8 扩容的重头戏,也是最有意思的设计。
3.3 (e.hash & oldCap) == 0:一个位运算同时完成拆分与保序
JDK 1.8 的节点迁移代码,彻底抛弃了 1.7 那种"全部重新计算哈希索引"的做法。因为新容量是旧容量的两倍,也就是二进制里最高位向左移了一位,那么一个节点在新数组里的索引只有两种可能:跟原来一样,或者原来索引加上旧容量。这个判断只要一个位运算就能完成:
if ((e.hash & oldCap) == 0) { // 留在原地 } else { // 移动到 j + oldCap }为什么hash & oldCap能决定去留?我来推一下数学原理。
假设旧容量oldCap是 16,二进制是10000。旧索引由hash & (16 - 1)也就是hash & 01111决定,只看低 4 位。扩容后新容量是 32,新索引由hash & 11111决定,看低 5 位。
hash & oldCap也就是hash & 10000,检查的是这个哈希值从低到高的第 5 位(原容量的最高位)是 0 还是 1。
- 如果第 5 位是 0,那么
hash & 11111的低 4 位不变,等于hash & 01111,所以新索引等于旧索引。 - 如果第 5 位是 1,那么
hash & 11111的结果等于hash & 01111再加上10000,也就是旧索引加上 16,即j + oldCap。
这样一来,扩容时不需要对整条链表上的每个节点重新计算哈希、重新求索引,只需要跟oldCap做一次与运算。这个优化在 1.7 里完全不存在,它就是"容量必须是 2 的幂"带来的红利。
对应的迁移代码是经典的双链表拆分,把一条链表原封不动地分成两条,分别挂到新数组的j和j + oldCap位置:
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; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }注意这里用的是loTail和hiTail两个尾指针,而不是 1.7 那种头插法。它相当于把原来的一条链表按哈希第 n 位拆成两条子链表,然后保持原有的相对顺序,直接整链挂到新数组的两个桶里。
整个过程时间复杂度是 O(oldCap),只遍历每个桶的链表一次,而且完全不重算哈希。跟 1.7 的transfer相比,少了很多无意义的重复计算。
3.4 红黑树的拆分与退化:扩容时 TreeNode 链怎么处理
JDK 1.8 里桶内节点可能是红黑树节点。扩容时,如果某个桶是一个TreeNode,走的是另一条迁移路径:
else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);split方法的逻辑跟普通链表的拆分思路一模一样,也是通过(e.hash & oldCap) == 0把红黑树节点分成两拨,分别放到j和j + oldCap两个位置。只不过拆分前,TreeNode内部本身还维护着一条双向链表,拆分时就是在这条链表上做切分。
拆分完之后,如果某一边的节点数小于等于 6,就不再维持红黑树,直接调用untreeify降级成普通链表。因为节点数量少了,红黑树在调整和增删时的开销已经超过它带来的查询收益,继续维持树结构是浪费。这个阈值是UNTREEIFY_THRESHOLD = 6。
还有一个容易忽略的点:链表在扩容时可能被拆成两半,原本长度达到 8 的链表拆完可能两边都不到 8,这样就自然退出树化路径了。这也是前面提到的"数组容量不足 64 时先扩容而不是树化"的原因——扩容本身就能消除很多哈希碰撞,让链表长度降下来。
4. 两版扩容代码的纵深对比:迁移策略、索引计算与并发表现
前两节分别拆了两版代码,这一节把它们摆到一起,从几个关键维度做一次彻底对比。理解了这些差异,才真正知道 1.8 的重构解决了哪些问题,又带来了哪些新的边界情况。
4.1 元素迁移:从"全部重算并反转"到"原序原链复制"
JDK 1.7 的迁移是:遍历旧表每个桶的链表,对每个节点重新计算indexFor(e.hash, newCapacity),然后用头插法把节点挨个插到新桶的最前面。结果是链表顺序完全反转,而且每个节点都做了一次新的取模运算。
JDK 1.8 的迁移是:遍历旧表每个桶的链表,用e.hash & oldCap判断节点应该留在原索引还是移动到原索引 + oldCap,用两条临时链表(lo 链表、hi 链表)分别拼接,最后整链挂到新数组的两个桶里。链表顺序不变,索引计算从"每个节点一次取模"变成了"每个节点一次与运算"。
单看性能,1.8 的优化立竿见影。尤其在元素量大、哈希分布均匀的情况下,1.7 的扩容几乎要把所有节点重新洗牌一遍,1.8 则是把原来落在同一个桶里的节点按"高位是否为 1"直接分成两组。因为扩容是严格两倍,这两个新桶下标之间的关系是固定的,不需要再求索引。
我把两版扩容的关键差异汇总成一张表,方便后面回顾:
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 插入位置 | 头插法,链表顺序反转 | 尾插法,链表顺序保持 |
| 扩容时机 | 元素数超阈值且目标桶非空,先扩容再插入 | 元素插入完成后,若超阈值则扩容 |
| 索引计算 | 每个节点重新执行indexFor,等价取模 | 只需hash & oldCap判断去留,原索引或加 oldCap |
| 迁移数据结构 | 单链表逐个反转插入 | 双指针拆分 lo 链表和 hi 链表,整链挂载 |
| 树化支持 | 无 | 链表转红黑树,扩容时拆分红黑树并可能退化 |
| 并发风险 | 头插法容易形成环形链表,导致死循环 | 不会形成环,但并发 put 仍可能丢数据 |
| 扩容倍数 | 两倍 | 两倍,配合 2 的幂取模和高低位拆分 |
4.2 并发下的表现:死循环与数据丢失的差别
JDK 1.7 的死循环问题,在前文已经推演过。它的本质是多线程并发resize时,头插法把链表顺序反转,多个线程操作同一个旧链表的状态互相覆盖,导致某个节点的next最终指向了它自己或已经迁移过的节点,形成环。一旦形成环,get和put遍历链表时永不停歇,CPU 占用直接拉满。
JDK 1.8 里,尾插法保证了迁移时不会反转链表,同时整个迁移过程用loTail和hiTail把节点拼到新链表的末尾,不会出现"新节点的 next 指向一个已经被其他线程改过的节点"这种状态。所以在 1.8 里,严格的"扩容死循环"问题被消除了。
但我要强调一点,这不代表 1.8 的 HashMap 可以在多线程下放心用。并发 put 时,两个线程可能同时往同一个桶里插入节点,后写入的节点会覆盖先写入的节点,导致数据丢失;同时++size不是原子操作,多个线程同时自增,size 计数可能偏小,也就可能漏判扩容时机。所以在并发场景下,该用ConcurrentHashMap还是得用,HashMap 从来不是线程安全的容器。
4.3 那一行 hash 算法的微调:高低位异或如何配合扩容
讲到扩容,有一个容易被忽略的配角:hash方法本身。索引计算虽然只看哈希值的低位,但 HashMap 在拿到key.hashCode()之后,还做了一次扰动处理。
JDK 1.7 的 hash 方法做了多次位运算扰动:
h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4);JDK 1.8 简化成一次异或:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }这行代码的作用是:把哈希值的高 16 位与低 16 位进行异或,让高位的特征也参与低位的索引计算。
为什么需要这么做?因为capacity - 1通常只有低位有 1,索引结果只取决于哈希值的低位。如果两个 key 的哈希值高位差异很大、低位完全相同,那么它们会落到同一个桶里。通过h ^ (h >>> 16),高位信息被搅拌进低位,可以在不增加额外计算成本的前提下,有效降低碰撞概率。
这个技巧在 1.8 里尤其重要,因为 1.8 的迁移逻辑用到了hash & oldCap,这个判断正好依赖哈希值的第 n 位。如果哈希值没有经过扰动,某些特定模式下,同一条链表上的节点在第 n 位上可能高度集中,导致扩容拆分后一个桶挤了一堆节点、另一个桶空着。有了高低位异或,第 n 位的分布会均匀一些,拆分效果也更好。
5. 扩容机制在实战中的调优思路与高频面试问题
源码层面的拆解到此结束。这一节聊聊这些底层机制对实际编码的影响,以及面试时如何把这套东西组织成有深度的回答。
5.1 指定初始容量的两个坑:tableSizeFor 与 threshold 的换算
很多开发者知道要指定 HashMap 初始容量,但不知道这里藏着两个很容易踩的坑。
第一个坑是容量被"向上取整到 2 的幂"。你写new HashMap<>(1000),底层tableSizeFor算出来的数组容量不是 1000,而是 1024。容量 1024,threshold 就是 1024 × 0.75 = 768。也就是说,你以为 1000 个元素放进去没问题,实际上放第 769 个元素时就已经触发扩容了。
第二个坑是初始容量和 threshold 的关系在构造时是反的。看源码:
public HashMap(int initialCapacity, float loadFactor) { ... this.threshold = tableSizeFor(initialCapacity); }构造函数里把tableSizeFor的结果存到threshold上,但此时table还是 null,这个 threshold 相当于"下次真正的 threshold 的临时值"。第一次resize时,oldThr会被当成新容量用,然后再算出真正的 threshold。这个细节在代码审查时很容易看晕,面试也偶尔会问。
那到底怎么预估容量才不会频繁扩容?业界通用的公式是:
initialCapacity = (int)(expectedSize / loadFactor) + 1如果要存 1000 个元素,加载因子默认 0.75,那么初始容量应该是1000 / 0.75 + 1 ≈ 1334,向上取整到 2 的幂就是 2048。这样 threshold = 2048 × 0.75 = 1536,插入 1000 个元素时不会触发扩容。Guava 的Maps.newHashMapWithExpectedSize用的就是这套逻辑,核心代码和我上面写的公式一致。
5.2 线上规避扩容陷阱的几条建议
根据我自己的实操经验,以下几条建议可以大幅减少 HashMap 扩容带来的线上问题。
第一,能预估规模就一定要预估。一次性put大量数据时,如果容量不够,会连续触发多次扩容,每次都涉及全量节点迁移。比如你要一次性灌入 500 万条数据,默认容量 16 的 HashMap 要扩容接近 20 次,每次迁移的都是前面所有数据,性能损耗非常大。正确做法是提前算出初始容量,尽量让扩容次数归零。
第二,警惕"大 HashMap 后只读"的场景。一个 HashMap 被填满后如果只读不写,它的性能是稳定的。但如果你在持有的过程中继续 put 数据,扩容发生的瞬时延迟可能达到几十毫秒甚至更高。对于低延迟接口,这种毛刺不可接受。我曾经在网关项目里用 HashMap 做本地规则缓存,后来统一改成"构建完成后包装成不可变 Map",就是怕运行期扩容抖动。
第三,并发场景别信"1.8 没死循环了"就乱用。1.8 虽然解决了循环链表,但并发 put 仍然会丢数据。如果既想要哈希表的性能又需要线程安全,直接上ConcurrentHashMap,不要自己用Collections.synchronizedMap去包,也不要给 HashMap 加一把粗粒度锁,那会在高并发下变成性能瓶颈。
第四,自定义对象作为 key 时,务必正确实现hashCode和equals。如果 hashCode 分布不均匀,比如大量对象的 hashCode 落在同一个低位区间,就算扩容再多、加载因子再小,链表照样长得飞快,树化也救不了你。这个属于扩容机制之外的隐性因素,但实际影响往往比扩容本身更大。
5.3 面试回答这条问题的口径:从源码到工程经验
HashMap 扩容机制几乎是后端面试必考题。很多人能背出"1.7 头插法有死循环,1.8 尾插法解决了",但面试官追问几个为什么就答不上来。我建议按下面这个层次组织回答。
第一层,扩容触发与容量设计。先回答,当size > threshold时触发扩容,threshold = 容量 × 加载因子,默认加载因子 0.75,容量必须是 2 的幂,这是为了用hash & (length - 1)替代取模运算,同时让哈希值低位均匀分布。
第二层,1.7 的扩容细节。说清楚transfer方法:遍历旧表每个桶,对每个节点重新计算下标,用头插法把节点插入新桶,链表顺序会反转。死循环的根源是并发 resize 时,两个线程交替迁移同一个链表,头插法导致一个节点的 next 被改成指向已经迁移过的节点,最终形成环。
第三层,1.8 的改进。先说 putVal 流程变了,先插入再判断扩容;再说尾插法保持链表顺序,从根本上杜绝环形链表;然后重点说(e.hash & oldCap) == 0这个判断,利用两倍扩容的数学特性,把一条链表拆成 lo 和 hi 两条,整链挂到新数组,节点索引只可能是原位置或原位置加 oldCap;最后补充链表长度达到 8 且数组长度达到 64 时树化,扩容后节点少于等于 6 时红黑树退化为链表。
如果能按这个逻辑讲下来,面试官基本可以确认你不是背答案,而是真的读过源码。如果再能补一句"1.8 虽然解决了死循环,但并发下仍有数据丢失问题,所以并发场景还是得用 ConcurrentHashMap",那就把问题带到了更实际的工程层面。
我在实际项目里见过太多因 HashMap 容量不匹配导致的性能问题。有一次数据同步任务批量写入时,HashMap 频繁扩容,GC 压力明显升高,整个任务的执行时间比预期多了三倍。后来把初始容量按公式计算好,扩容次数降为零,任务耗时直接缩短到原来的三分之一。这种问题不深入源码很难定位,因为表面上它只是"变慢了",没有报错,也没有堆栈。
回到最初的问题:HashMap 的扩容机制到底重不重要?我的答案是,它不只是面试题,更是一个理解哈希表设计权衡的窗口。从 1.7 到 1.8 的演变,背后是"空间换时间""位运算替代取模""数据结构随负载自适应"这些通用思想在真实工程里的落地。把这些搞明白了,你写 JDK 集合相关的代码时,会多一层底气。