引言
在计算机科学中,HashMap是一种常用的数据结构,用于存储键值对。它提供了高效的插入、查找和删除操作。然而,随着元素的增加,HashMap可能会出现性能下降的问题。为了解决这个问题,HashMap引入了扩容机制,了解扩容机制,可以提高我们对HashMap深层逻辑的理解。
========================================================
关于HashMap中数组与桶、哈希函数的内容可以点击下方链接进行跳转
HashMap详解-内部实现原理(1)-数组和桶
HashMap详解-内部实现原理(2)-哈希函数
HashMap为什么要扩容
主要有以下几点:
-
负载因子:
HashMap使用负载因子(load factor)来衡量哈希表的满载程度。当哈希表中存储的元素数量超过负载因子与初始容量的乘积时,就会触发扩容操作。
如果负载因子设置得过小,哈希表会很快满载,导致频繁的冲突和查找时间的增加。而设置得过大,则会浪费大量的空间。通过扩容,可以在保持合理负载因子的前提下提供更多的存储空间。 -
冲突增加:
当HashMap中存储的元素增多时,桶中的元素可能发生冲突,即多个键映射到同一个桶上。冲突的增加会降低HashMap的性能,导致插入、查找和删除操作所需的时间复杂度增加。通过扩容,可以减少冲突的概率,提高哈希表的性能。 -
提高空间利用率:
扩容后的新哈希表通常具有更大的容量,这可以减少桶的填充因子,提高空间利用率。较低的填充因子可以减少冲突,使得元素分布更均匀,减少链表长度,从而提高数据访问的效率。 -
动态适应需求:
=当元素数量增加时,通过扩容可以及时提供足够的存储空间,避免发生哈希表溢出或性能下降的问题。
使用一句话概括就是——为了提高效率
扩容(resize)的触发条件
以下是HashMap中resize方法的部分引用:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict)
{
...
while (s > threshold && table.length < MAXIMUM_CAPACITY)
resize();
...
}
```java
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict)
{
....
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
....
if (++size > threshold)
resize();
....
}
public V computeIfAbsent(K key,Function<? super K, ? extends V> mappingFunction)
{
....
if (size > threshold || (tab = table) == null ||
(n = tab.length) == 0)
n = (tab = resize()).length;
....
}
final void treeifyBin(Node<K,V>[] tab, int hash)
{
....
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
....
}
public V merge(K key, V value,BiFunction<? super V, ? super V, ? extends V> remappingFunction)
{
.....
if (size > threshold || (tab = table) == null ||
(n = tab.length) == 0)
n = (tab = resize()).length;
....
}
通过这些对resize的使用我们可以看出来resize通常在什么时候被使用:
- HashMap实行了懒加载, 新建HashMap时不会对table进行赋值, 而是到第一次插入时, 进行resize时构建table;
- 当HashMap所存大于threshold(阈值)时, 会进行resize;
PS:阈值(threshold)的简易计算公式为:threshold = table.length * loadFactor;
以下是HashMap中该计算逻辑的部分代码,其中的newCap指的是新的容量
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
扩容过程
HashMap扩容的步骤:
-
当HashMap中的元素数量达到或超过负载因子(loadFactor)与当前容量(capacity)的乘积时,就会触发扩容操作。
-
扩容操作会将HashMap的容量增加一倍,并重新计算新的容量(newCap)。
-
创建一个新的数组(newTable)来存储扩容后的元素。
-
遍历原始数组(oldTable)中的每个存储桶,将桶中的元素重新分配到新的数组中。重新分配的过程是根据元素的哈希值和新的容量进行重新计算位置。
-
如果原始数组的某个存储桶中只有一个元素,则直接将该元素放入新数组的相应位置。
-
如果原始数组的某个存储桶中存在多个元素,则会根据元素的哈希值重新计算元素在新数组中的位置。如果新位置上已经有元素存在,则以链表或树形结构的形式处理冲突。
-
所有元素都重新分配到新数组后,新的数组将成为HashMap的存储结构,并替代原始数组。
-
更新HashMap的容量(capacity)和阈值(threshold),容量更新为新的容量(newCap),阈值更新为新容量乘以负载因子。
通过扩容操作,可以保证HashMap在元素数量增加时仍能保持较低的查找时间复杂度。同时,通过调整负载因子和容量的大小,可以在性能和空间利用率之间达到平衡。
由于每次扩容都是以2的倍数进行计算,因此HashMap的容量一定是2的n次方
扩容的性能影响
HashMap的扩容操作也会对性能产生一定影响。
-
时间和空间开销:扩容需要重新计算元素的哈希值,并将它们重新分配到新的数组中。这个过程涉及遍历旧数组、计算哈希值、复制元素等操作,可能会消耗较多的时间和空间。
-
扩容触发频率:扩容操作是在HashMap的负载因子(loadFactor)和元素数量之间比较来判断是否需要进行扩容。如果负载因子设置得过高或元素数量增长较快,就会频繁触发扩容操作,从而对性能产生不利影响。
-
散列冲突减少:扩容后桶的数量增加,可以使每个桶中存放的元素数量变少,减少散列冲突的发生。这会提高元素的查找、插入和删除效率,从而改善性能。
-
内存占用增加:扩容后,由于桶的数量增加,HashMap需要占用更多的内存空间来存储元素和相关数据结构。这会增加总体的内存占用,对于内存有限的情况可能会导致性能下降。
-
迭代性能降低:在进行扩容操作期间,如果有其他线程在对HashMap进行迭代操作,可能会导致遍历过程出现不确定性和错误。这是因为扩容操作涉及元素的重新分配和复制,可能会导致某些元素在不同的阶段被访问或丢失。
为了平衡性能影响,需要合理设置负载因子和初始化容量,以及定期评估和调整HashMap的初始容量。通常情况下,较低的负载因子和适当的初始容量可以减少扩容操作的频率和开销,从而提高HashMap的性能。
扩容过程中的数据迁移
HashMap的扩容过程中涉及数据的迁移,具体步骤如下:
-
创建一个新的数组(newTable),其大小是原始数组的两倍(newCap = oldCap * 2)。
-
遍历原始数组(oldTable)中的每个存储桶。
-
对于每个非空的存储桶,将其中的元素重新计算哈希值,并重新分配到新的数组中的对应位置。
-
如果新的数组中的该位置为空,则直接放置元素到该位置。
-
如果新的数组中的该位置已经有其他元素存在,可能发生了哈希冲突。这时,根据HashMap的实现方式,可以采用链表或树的形式处理冲突。
-
如果链表的长度小于等于8(默认阈值),则将新元素追加到链表的末尾。
-
如果链表的长度大于8,则将链表转换为红黑树结构,以提高查找、插入和删除的性能。
-
继续遍历原始数组的下一个存储桶,重复步骤3~5,直到遍历完所有的存储桶。
-
所有元素都重新分配到新的数组中后,新的数组(newTable)将替代原始数组(oldTable)成为HashMap的存储结构。
需要注意的是,在数据迁移期间,HashMap可能会处于不稳定的状态。因为在扩容过程中,既有的元素可能仍然位于原始数组中,而新的元素已经被分配到新数组中。这可能导致在迭代HashMap时出现未知的行为。
为了避免在迭代期间的并发修改问题,建议在进行数据迁移期间使用一些同步机制或使用线程安全的HashMap实现。或者可以在迁移完成后再进行迭代操作。
补充:HashMap无法保证线程安全, 所以最好使用ConcurrentHashMap
结语
总结HashMap的扩容机制,强调了扩容的重要性和影响。在实际使用HashMap时,合理设置初始容量和负载因子,能够减少扩容的次数,提高HashMap的性能。