ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HashMap扩容机制深度解析:从JDK 1.7死循环到JDK 1.8优化

HashMap扩容机制深度解析:从JDK 1.7死循环到JDK 1.8优化 先问一个老生常谈但依然能挂住不少人的问题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 在插入、查找、扩容时都要反复计算索引这个微小的性能差异会被放大很多倍。第二个原因更关键位运算能保证哈希值真正均匀落在数组下标上。我给你算一笔账。如果数组长度是 8length - 1的二进制是0111那么索引只取决于hash值的低三位任何一个位的不同组合都能反映到索引上。但如果数组长度是 7length - 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.00.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方法最终调用的addEntryvoid 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) { EntryK,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 (EntryK,V e : table) { while(null ! e) { EntryK,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扩容时它们哈希到新数组的同一个桶迁移过程大致是取出 AA.next 指向 null新桶放 A。取出 BB.next 指向 A新桶放 B。取出 CC.next 指向 B新桶放 C。最终新桶里是 C - B - A链表顺序从原来的 A-B-C 变成了反转后的 C-B-A。单线程环境下这个反转虽然改变了链表的顺序但不会丢节点、也不会死循环。真正的问题出在多线程并发扩容时。2.3 并发死循环的完整推演从 e.next 挂起开始现在演示一下两个线程并发扩容时如何形成环形链表。这个案例在面试和博客里被讲过很多次但我还是想用最直白的方式把推演过程写清楚。假设旧表容量为 2某个桶里有一条链表 A - B两个节点的哈希值计算后仍会落入扩容后新表的同一个桶。线程 1 和线程 2 同时执行resize。线程 1 先进入transfer执行到关键位置EntryK,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。这一轮看起来正常。问题出在下一轮继续处理 BEntryK,V next e.next; // e BB.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 AnewTable[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) { NodeK,V[] tab; NodeK,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 { NodeK,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 ((TreeNodeK,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(NodeK,V[] tab, int hash) { int n, index; NodeK,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 NodeK,V[] resize() { NodeK,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}) NodeK,V[] newTab (NodeK,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位置NodeK,V loHead null, loTail null; NodeK,V hiHead null, hiTail null; NodeK,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) ((TreeNodeK,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.7JDK 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。容量 1024threshold 就是 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 集合相关的代码时会多一层底气。
返回列表