HashMap扩容机制是Java开发中绕不开的一道坎。从校招面试到生产环境性能排查,它都是高频出现的东西。有人背过源码,知道有个resize()方法;也有人知道负载因子默认0.75,却说不清为什么是这个值;更有老项目在并发场景下出现CPU 100%或者数据错乱,定位到最后就是扩容这颗雷。这篇内容我打算从扩容的根源讲起,把触发条件、源码实现、并发隐患、调优思路一次性拆透,让不同基础的读者都能真正弄明白扩容到底在做什么,以及怎么避免踩坑。
1. 扩容机制到底解决了什么问题:从哈希冲突到容量不足
1.1 哈希表的“水桶效应”与冲突成本
要理解HashMap为什么需要扩容,得先理解哈希表的基本工作方式。HashMap底层是一个数组,数组的每个位置(通常叫桶或槽位)理论上存放一个元素。通过key.hashCode()得到一个整数,再经过扰动和取模运算,落到数组的某个下标上。理想情况下,每个桶只有一个元素,查找、插入、删除都是O(1)。
但哈希函数不可能完美。当两个不同的key映射到同一个桶时,就会发生哈希冲突。冲突的解决办法有开放地址法、再哈希法,而HashMap用的是链地址法——在冲突的桶位置上拉一条链表(JDK 8 及以后还会升级成红黑树)。冲突越多,链表越长,查询效率就从O(1)退化为O(n)。
这就出现了一个矛盾:数组容量越小,冲突概率越高,链表越长,性能越差。容量越大,内存浪费越多。所以需要在“容量”和“冲突率”之间找一个平衡点,而扩容就是当数据量接近容量上限时,动态把数组变大,重新分布元素,从而降低冲突率,让性能恢复。
1.2 为什么不是“满了才扩容”,而是提前扩容
很多人会误以为HashMap是“装满了才扩容”。如果真是这样,等数组完全填满再去扩容,链表的长度已经非常可观,性能早就崩了。而且数组下标是按容量取模计算的,元素一旦增多,局部就会挤在一起,出现“一桶多元素,多桶空置”的现象。
HashMap采用的是“提前扩容”策略:当size(键值对数量)达到一个阈值(threshold)时,就触发扩容,而不是等数组真正满。这个阈值跟容量和负载因子有关,默认情况下,容量16,负载因子0.75,阈值就是12。也就是说,HashMap里保存到12个键值对时,就会触发第一次扩容,尽管底层数组还有4个空位。
这样做的好处是:数组平均占用率被控制在75%以内,链表的长度通常不会太长,查询性能能维持在一个可接受的水平。这是一种典型的“空间换时间”思想,用一定的内存浪费换取稳定性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 扩容的“扳机”:阈值计算、负载因子与树化的连带影响
2.1 为什么负载因子默认是0.75
负载因子loadFactor决定了HashMap“多满才扩容”。默认0.75是一个在时间和空间成本之间做了权衡后的经验值。很多资料会说这是“泊松分布”推导出来的,更准确地说,是在大量实验下,当负载因子为0.75时,桶中出现链表长度超过8的概率极低(约千万分之一),既能保证较好的查询效率,又不会浪费太多内存。
如果你把负载因子调成1,意味着数组填满了才扩容,内存利用率高了,但冲突率会急剧上升,链表变长,增删查性能下降。调到0.5,冲突少了,性能好了,但空间浪费一半。所以0.75是大多数业务场景下的“黄金分割点”。
2.2 threshold的取值逻辑:容量 * 负载因子
threshold就是扩容触发阈值,计算方式很简单:threshold = capacity * loadFactor。
初始化时,如果使用无参构造,HashMap的容量为16,loadFactor为0.75,threshold就是12。随着扩容,容量翻倍,阈值也翻倍。比如第一次扩容后容量变成32,threshold变成24。
如果你使用指定容量的构造方法,比如new HashMap<>(100),HashMap会先将容量调整为“大于等于100的2的幂”,即128,然后threshold = 128 * 0.75 = 96。这里有个容易忽略的点:容量永远是2的幂次方,而threshold是容量乘以负载因子后可能不是整数,源码里会经过无符号右移和乘法运算计算,最终可能是小数向下取整后的值。
java复制// JDK 8 中 threshold 计算的部分源码(构造方法中)
this.threshold = tableSizeFor(initialCapacity);
注意,在JDK 8的构造方法里,threshold先被临时存为“2的幂次方容量”,等到第一次put时,在resize()里才会真正计算newThr = (int)(newCap * loadFactor)。这个细节很容易被忽视,但理解了它,你就明白为什么new HashMap<>(100)之后第一次put就会分配一个128容量的数组。
2.3 树化阈值与扩容阈值的“联动”
JDK 1.8引入了红黑树来优化长链表,但当链表长度达到8且数组容量达到64时,链表会树化。这里你需要区分两个动作:树化和扩容,它们会相互影响。
如果一个桶的链表长度达到8,但当前数组容量小于64时,HashMap不会立刻树化,而是优先扩容。扩容后,原来的长链表可能会被拆分到两个新桶中,链表长度自然缩短,冲突也就不再严重。所以树化是“迫不得已”的手段,扩容才是首选策略。理解这一点,对你分析源码中treeifyBin方法特别重要。
java复制final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize(); // 容量不足64时,直接扩容
...
}
扩容和树化是一套组合拳:容量小时用扩容解决冲突,容量足够大但仍出现长链表时,才用红黑树兜底。所以你在看resize()的源码时,还会看到它顺手处理了反向树化——把红黑树退化为链表的情况。
3. 深度拆解resize():从源码层面看扩容的完整流程
3.1 resize()的分支:首次初始化与常规扩容
resize()是HashMap扩容的核心方法,它的职责有两块:一是创建初始哈希表,二是对现有哈希表进行2倍扩容。这两件事在JDK 8的代码里写在一起,理解起来有点绕,但拆开看就不难。
先看oldTab。如果老表为null,说明这是首次put触发的初始化。此时容量取threshold的值(还记得吗,构造时它存的是2的幂次方的容量)。如果threshold > 0,说明用了指定容量构造;否则用默认容量16,默认阈值12。
java复制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;
...
}
注意,JDK 8在扩容翻倍时,阈值用oldThr << 1复制,直接用位运算,比乘法更高效。这是JDK团队性能优化到牙齿的体现。
3.2 数据迁移:每个元素的“高下判断”与链表拆分
新表创建好后,就要把老表里的元素全部迁移过去。JDK 1.7的做法是逐个元素重新计算索引,然后头插法插入新链表;JDK 1.8则利用“容量是2的幂”这个特性,做了一次非常巧妙的优化。
因为新容量是旧容量的2倍,元素的索引位置只有两种可能:原索引位置,或者原索引位置 + 旧容量。判断依据是:(e.hash & oldCap) == 0。这个位运算为什么能决定索引?
假设旧容量oldCap = 16,二进制是10000。取模运算等价于hash & (oldCap - 1),也就是hash & 1111,取的是哈希值的低4位。扩容后容量32,取模变成hash & 11111,取低5位。新增的这1位,恰好就是哈希值在旧容量那一位上的值。如果这一位是0,索引不变;如果是1,索引变成原索引 + oldCap。
举个例子,两个元素的哈希值分别是5和21,二进制低5位分别是00101和10101。在容量16时,它们都落在索引5的位置(因为低4位都是0101)。扩容到32后,5 & 31 = 5,21 & 31 = 21,所以第二个元素移动到索引5+16=21的位置。这个判断用(hash & oldCap)就能区分:5 & 16 = 0,位置不变;21 & 16 = 16,位置后移16。
java复制if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表优化逻辑
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);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
这段代码里维护了lo(低位)和hi(高位)两个链表,遍历一次就把原链表拆分成两部分,然后直接放到新数组的对应位置。这个做法相比JDK 1.7的逐个头插,不仅避免了链表倒序,也把时间复杂度从O(n)的重复哈希降低为O(n)的位运算判断,且不会产生环。
3.3 红黑树在扩容时的split逻辑
当桶里是红黑树时,扩容不能简单地把根节点挪过去。红黑树节点TreeNode之间也有next指针,底层其实是双向链表。扩容时,先遍历这个链表,按(e.hash & oldCap)分为lo和hi两条链表,然后分别判断两条链表的长度是否小于等于UNTREEIFY_THRESHOLD(默认6),如果小于等于6,就退化成普通链表;否则重新构建红黑树。
java复制final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
...
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null)
loHead.treeify(tab);
}
}
...
}
这里有个小细节:如果hiHead为null,说明所有节点都留在原桶,那么loHead本身就是一棵树,不需要重新树化。只有当高低位两个链表都非空,说明原来的红黑树被打散了,才需要重新执行treeify。JDK团队在这里抠得非常细。
4. 扩容的并发阴影:JDK 1.7死循环与JDK 1.8的妥协
4.1 JDK 1.7为什么会产生环形链表
JDK 1.7的transfer()方法在迁移元素时使用头插法,将旧链表节点依次插入新链表的头部。在多线程并发扩容时,两个线程同时resize,可能造成链表节点相互引用,形成环。一旦形成环,后续在get一个不存在的key时,就会死循环遍历链表,导致CPU飙高。
具体过程大致是:线程A和线程B都读取了同一个链表,线程B先执行迁移,把链表头节点变了,线程A再执行时,基于自己之前保存的节点引用继续迁移,导致next指针重新指向了已经迁移过的节点,形成环。这个经典问题在Java 8中不会发生了,因为Java 8改用了尾插法,也就是上面提到的loHead和hiHead链表,不会反转链表的指向,也就不会出现环形结构。
4.2 JDK 1.8“并发安全了”是错觉
虽然有说法是JDK 1.8修复了死循环问题,但HashMap依然不是线程安全的。并发put时可能造成数据覆盖:两个线程同时调用putVal,都检查到数组同一个位置为null,然后一个线程写入节点,另一个线程也写入节点,后者直接覆盖前者的数据,导致丢失更新。
还有一个问题是并发扩容时,如果多个线程同时进入resize(),它们可能操作同一个数组引用,导致某些桶的链表丢失,或出现节点重复到新表但旧表引用还在的情况。虽然不会死循环,但数据错乱、丢数据依然存在。所以如果你在多线程环境使用HashMap,请务必用ConcurrentHashMap。
4.3 一个“扩容导致CPU 100%”的真实排查思路
我曾经遇到一个老项目在高峰期CPU突然飙到100%,线程dump后发现大量线程卡在HashMap的get()方法上。进一步排查,发现是因为JDK 1.7的HashMap在并发put时触发了扩容,形成了环形链表。当时的第一反应是换ConcurrentHashMap,但因为历史包袱太重,不可能一次性替换,所以临时措施是给Map加锁,或者初始化时给一个足够大的容量,让它不触发扩容,熬过版本升级窗口。
这里也提醒大家:分析这种问题,光看代码是看不出来的,一定要抓线程dump,看线程栈停在哪里。如果大量线程都卡在HashMap.get()的e.next循环里,那基本可以断定是环形链表。
5. 扩容的代价与调优手段:如何让HashMap少“搬家”
5.1 扩容不止是“复制数组”,还有哈希重算和链表重建
每次扩容,所有元素都要重新经历一次“判断位置、放到新数组”的过程,时间复杂度是O(n)。这个过程会有一个明显的性能尖刺:在数据量大的时候,一次扩容可能阻塞当前线程几十毫秒甚至更久。在延迟敏感的服务里,这种尖刺不可接受。
要降低扩容的影响,思路不外乎两个方向:
- 减少扩容次数:设置一个合理的初始容量,让Map在一开始就足够大,不够的时候才扩容。
- 避免扩容时的不确定性:预知数据规模,按规模设置容量,甚至直接指定负载因子。
5.2 初始容量到底该怎么设
JDK内置的new HashMap<>(initialCapacity)并不会直接使用你传进去的数字,而是会转为2的幂。如果你知道大概要存多少条数据,建议设置初始容量为 (预估数据量 / 0.75) + 1,保证实际容量下不会过早触发扩容。
举个例子,你预估要存1000条数据,用默认负载因子0.75,那么容量至少是1000 / 0.75 = 1334,向上取2的幂是2048。如果你直接new HashMap<>(1000),JDK会把它转成1024,而1024 * 0.75 = 768,意味着存到769个键值对就会扩容,白白多了一次搬移。所以正确姿势是new HashMap<>(1334),或者在JDK 8中直接传1000 * 2(但这样会浪费空间,最好还是按公式算)。
这里再介绍一个常用技巧:使用Guava的Maps.newHashMapWithExpectedSize方法,它会帮你计算这个值,避免你手算错。
5.3 负载因子能不能随意调整
负载因子是可以调的,但不要轻易调。如果你的Map经常被读,几乎不写,可以适当调大负载因子,比如1.0,这样节省内存,但查询性能会略降。如果你对查询性能要求极高,可以调小到0.5,但内存消耗会增加一倍。一般业务场景,0.75是默认最优。
另外,负载因子还影响扩容阈值,所以调了负载因子等于同时调整了“何时扩容”和“哈希分布”两个维度。强烈建议只在明确需求下调整,不要全局改。
5.4 JDK 9后的改进:预留容量与更科学的初始化
JDK 9加入了LinkedHashMap相关的改进,但对HashMap扩容本身没有太大变化。真正值得关注的是JDK 10之后的HashMap在某些场景下使用了数组拷贝优化吗?其实没有。HashMap的扩容逻辑从JDK 8到现在的JDK 17基本保持稳定,只是在细节上比如keySet迭代器、红黑树拆分等做了微调。所以JDK 8的扩容机制,放到JDK 17同样适用,只是源码位置和部分变量名有所变化(比如JDK 14之后的Node数组元素从Node<K,V>[]改成了Node<K,V>[],但本质一致)。
对于JDK 8及以上的使用者,你只需要记住扩容的触发条件和数据迁移逻辑即可。
6. 围绕扩容机制的几个高频疑问与实战验证
6.1 容量为什么必须是2的幂
这是HashMap中最容易考倒人的问题。为了让(n - 1) & hash能等价于hash % n,并且让扩容后的索引计算变得简单,HashMap要求容量必须是2的幂。因为n - 1的二进制都是低位全1,这样hash & (n - 1)结果均匀分布,不会出现某些索引永远为空的情况。如果容量不是2的幂,n - 1的低位有0,有些位置永远映射不到。
6.2 扩容后元素的位置分布
前面已经分析了,扩容后元素要么在原地,要么在原位置加oldCap。这个规律可以直接用来判断并发环境下的数据分布,也可以用来做性能测试:当大量key的哈希值具有相同的低位特征时,扩容后它们可能仍然集中在一个桶,导致这个桶依然很长,此时扩容并不能有效缓解冲突。这种情况下调整hash算法或使用ConcurrentHashMap可能更有效。
6.3 用一个小例子验证扩容过程
写段测试代码,用debug模式观察扩容前后的桶分布,是非常直观的学习方式。
java复制public class HashMapResizeDemo {
public static void main(String[] args) throws Exception {
HashMap<Integer, String> map = new HashMap<>(2);
map.put(0, "zero");
map.put(2, "two");
map.put(4, "four"); // 触发扩容
// 反射打印底层 table 长度和 key 的索引
Class<?> mapType = HashMap.class;
Field tableField = mapType.getDeclaredField("table");
tableField.setAccessible(true);
Object[] table = (Object[]) tableField.get(map);
System.out.println("table length = " + table.length);
for (int i = 0; i < table.length; i++) {
if (table[i] != null) {
System.out.println("index " + i + " contains " + table[i]);
}
}
}
}
运行后会看到,当初始容量为2时,put(0)和put(2)都在索引0的位置,形成链表。当插入4时,size=3 > threshold=1,触发扩容,容量变成4,2和4的索引都发生变化,链表被拆开。这个实验把扩容逻辑直接摆在眼前。
6.4 扩容时key为null的特殊处理
HashMap允许null键。null键的哈希值为0,永远存放在数组的0号桶。扩容时,null键也参与迁移,由于hash为0,(0 & oldCap) == 0,它会始终留在索引0的位置。所以null键在扩容时不会变化,这可能也是为什么很多开发者愿意用HashMap来存null值的原因之一。
7. 最后说一说我在实际项目里的经验
踩过HashMap扩容的坑之后,我现在的习惯是:一旦知道Map的规模,就直接用带初始容量的构造方法,并按“预估size / 0.75 + 1”来设置,绝不裸用new HashMap<>()。如果Map还可能有并发写入,就老老实实用ConcurrentHashMap,别拿HashMap赌命。在排查线上性能问题时,我习惯先看一眼线程栈,如果卡在java.util.HashMap$Node.hashCode()或getNode()这类方法上,我会果断检查是不是扩容带来的连锁反应。
另外,如果某个Map会频繁扩容,且无法预估容量,可以尝试在业务低峰期提前预热,也就是先put足够多的数据触发一次扩容,让后续操作不再经历resize。这个技巧在一些缓存类系统中非常实用,能有效消除扩容尖刺。
HashMap扩容机制本身并不复杂,复杂的是它和哈希分布、并发、性能三者之间的耦合关系。理解了扩容的触发条件、迁移流程和性能病灶,你就能在实际开发中做出更合理的设计选择。希望这篇偏实战的分析对你有用,如果有疑问,欢迎在评论区交流。
