Java 中 HashMap 的扩容机制是怎样的?

八股文一网打尽,更多面试题请看程序员面试刷题神器 - 面试鸭open in new window

回答重点

HashMap 中的扩容是基于负载因子(load factor) 来决定的。默认情况下,HashMap 的负载因子为 0.75,这意味着当 HashMap 的已存储元素数量超过当前容量的 75% 时,就会触发扩容操作。

例如,初始容量为 16,负载因子为 0.75,则扩容阈值为 16 × 0.75 = 12。当存入第 13 个元素时,HashMap 就会触发扩容。

当触发扩容时,HashMap 的容量会扩大为当前容量的两倍。例如,容量从 16 增加到 32,从 32 增加到 64 等。

扩容时,HashMap 需要重新计算所有元素的哈希值,并将它们重新分配到新的哈希桶中,这个过程称为rehashing。每个元素的存储位置会根据新容量的大小重新计算哈希值,并移动到新的数组中。

扩展知识

rehashing 细节

按照我们的思维,每一个元素应该是重新 hash 一个一个搬迁过去。

在 1.7 的时候就是这样实现的,然而 1.8 在这里做了优化,关键点就在于数组的长度是 2 的次方,且扩容为 2 倍。

因为数组的长度是 2 的 n 次方,所以假设以前的数组长度(16)二进制表示是 010000,那么新数组的长度(32)二进制表示是 100000,这个应该很好理解吧?

它们之间的差别就在于高位多了一个 1,而我们通过 key 的 hash 值定位其在数组位置所采用的方法是 (数组长度-1) & hash。我们还是拿 16 和 32 长度来举例:

16-1=15,二进制为 001111

32-1=31,二进制为 011111

所以重点就在 key 的 hash 值的从右往左数第五位是否是 1,如果是 1 说明需要搬迁到新位置,且新位置的下标就是原下标+16(原数组大小),如果是 0 说明吃不到新数组长度的高位,那就还是在原位置,不需要迁移。

所以,我们刚好拿老数组的长度(010000)来判断高位是否是 1,这里只有两种情况,要么是 1 要么是 0 。

从上面的源码可以看到,链表的数据是一次性计算完,然后一堆搬运的,因为扩容时候,节点的下标变化只会是原位置,或者原位置+老数组长度,不会有第三种选择。

上面的位操作,包括为什么是原下标+老数组长度等,如果你不理解的话,可以举几个数带进去算一算,就能理解了。

总结一下

  • 当扩容发生时,HashMap 并不是简单地将元素复制到新数组中。每个元素的哈希值会根据新的数组容量重新计算,因此元素的存储位置会发生变化
  • Java 8 之后的扩容不需要每个节点重新 hash 算下标,因为元素的新位置只与高位有关,通过和老数组长度的 & 计算是否为 0 就能判断新下标的位置,因此链表中的元素可能只需要部分移动。这一优化减少了扩容时的计算开销。

扩容的考虑

每次扩容都需要遍历当前所有的元素,重新计算哈希值并移动它们到新的位置,因此扩容是一个比较耗时的操作。如果频繁扩容,整体性能会明显下降。

优化策略:如果能预估到 HashMap 中大概会存储多少数据,可以在创建 HashMap 时就指定一个较大的初始容量,以减少扩容次数。例如,对于存储 100 万个元素的 HashMap,可以直接设置一个 1024 × 1024 的初始容量,避免多次扩容。

场景题

八股文一网打尽,更多面试题请看程序员面试刷题神器 - 面试鸭open in new window

最近更新:
Contributors: weave