HashMap何时扩容以及它的扩容机制?

Posted Java蚂蚁

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap何时扩容以及它的扩容机制?相关的知识,希望对你有一定的参考价值。

面试官Qusetion:请问HashMap的扩容机制是什么?

应聘者Answer: 

  • 在对HashMap进行扩容的时候,HashMap的容量会变为原来的两倍; 

  • 扩容是一个特别耗性能的操作,所以在使用HashMap的时候,如果能估算出map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容;


引言

什么时候扩容?

当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值(即当前数组的长度乘以加载因子的值的时候),就要自动扩容了。


扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。


结合源码分析

我们看一下addEntry()方法,为了便于理解我们使用JDK1.7的代码

 1void addEntry(int hash, K key, V valueint bucketIndex{  
2     // 获取指定 bucketIndex 索引处的 Entry   
3     Entry<K,V> e = table[bucketIndex];  
4     // 将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry  
5     table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
6     // 如果 Map 中的 key-value 对的数量超过了极限  
7     if (size++ >= threshold)  
8     // 把 table 对象的长度扩充到原来的2倍。  
9         resize(2 * table.length);  
10}   

看源码上说的条件是if (size++ >= threshold),当HashMap数组的长度大于阈值的时候,触发resize的动作。

举个栗子:假设现在阈值是4,在添加下一个假设是第5个元素的时候,这个时候的size还是原来的,还没加1,size=4,那么阈值也是4的时候, 当执行put方法,添加第5个的时候,这个时候 4 >= 4,元素个数等于阈值。就要resize()了;添加第4的时候,还是3>=4不成立,不需要resize();


接着我们再分析下resize的源码

 1pvoid resize(int newCapacity) {//传入新的容量  
2    Entry[] oldTable = table;//引用扩容前的Entry数组  
3    int oldCapacity = oldTable.length;  
4    if (oldCapacity == MAXIMUM_CAPACITY) {//扩容前的数组大小如果已经达到最大(2^30)了  
5        threshold = Integer.MAX_VALUE;//修改阈值为int的最大值(2^31-1),这样以后就不会扩容了  
6        return;  
7    }  
8
9    Entry[] newTable = new Entry[newCapacity];//初始化一个新的Entry数组  
10    transfer(newTable);//!!将数据转移到新的Entry数组里  
11    table = newTable;//HashMap的table属性引用新的Entry数组  
12    threshold = (int) (newCapacity * loadFactor);//修改阈值  
13}  

transfer()方法将原有Entry数组的元素拷贝到新的Entry数组,实现了元素的转移,源码如下:

 1void transfer(Entry[] newTable{  
2    Entry[] src = table;//src引用了旧的Entry数组  
3    int newCapacity = newTable.length;  
4    for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组  
5        Entry<K, V> e = src[j];//取得旧Entry数组的每个元素  
6        if (e != null) {  
7            src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)  
8            do {  
9                Entry<K, V> next = e.next;  
10                int i = indexFor(e.hash, newCapacity);//!!重新计算每个元素在数组中的位置  
11                e.next = newTable[i]; //标记[1]  
12                newTable[i] = e;//将元素放在数组上  
13                e = next;//访问下一个Entry链上的元素  
14            } while (e != null);  
15        }  
16    }  
17}  

我们来捋一下上面 transfer() 代码的过程:src保存旧的数组引用,遍历src,取得第一个Entry元素e=src[j],释放src[j]位置的引用,然后遍历链表,用next保存e.next的引用,对于链表的每个元素,都通过indexFor重新找到桶的位置i,将新索引第一个位置的引用给e.next,然后再把e的引用放到桶第一个元素位置,即实现了从头插入的效果。但是这段代码会导致一个问题,如果一个链表上所有元素,重hash之后的位置和原先位置相同,那么,这个链表的顺序就会颠倒。所以,先放在一个索引上的元素终会被放到Entry链的尾部。


JDK中有一段这样的源码如下:

1static int indexFor(int h, int length) {  
2    return h & (length - 1);  
3}  

这段代码使用 & 运算,效率很高,为了很好的理解上面的这段代码,我们改用取模运算的方式

1static int indexFor(int key, int length) {  
2    return key % length; 
3}  

假如哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在对2取余以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组resize成4,然后所有的Node重新rehash的过程。


大段大段的文字,看得有点心慌,来点图缓解一下不适感:

从上图,我们得知:

  • 在resize之前,数据都冲突在旧数组下标为1的位置,可以看到有一个链表结构,且排序为3、5、7

  • 第一次,转移数据,transfer key=3的数据到新数组,这个key3的next指向为null,因为新数组是对象数组,原来的值就是null

  • 第二次,当key=3移动完了,就轮到key=7了,7对4取余数,所以移动到新数组之后,在这个新数组的下标和刚刚移动的key=3的下标是一样的,按照上面的代码key=7的元素的next指向table[3],当前table[3]=刚刚的key=3的元素,然后又把table[3]=key7了,这样就形成了如上的链表结构,也就是头插法链表,这个时候key=3先来的,就排到了链表尾部了;

  • 第三次,我们转移key=5的元素,5对4取余数,最后这个元素落到了下标为1的位置;


通过上面整个过程的梳理,我们发现扩容的成本并不低,因为需要遍历一个时间复杂度为O(n)的数组,并且为其中的每个enrty进行hash计算。加入到新数组中,所以最好的情况是能够合理的使用HashMap的构造方法创建合适大小的HashMap,使得在不浪费内存的情况下,尽量减少扩容,这个就要根据业务来决定了。



推荐阅读


▼如果你喜欢我 ,就长按二维码关注我哟


以上是关于HashMap何时扩容以及它的扩容机制?的主要内容,如果未能解决你的问题,请参考以下文章

Java常见集合的默认大小及扩容机制

HashMap原理 扩容机制及存取原理

HashSet保证元素唯一原理以及HashMap扩容机制

HashMap的扩容机制

HashMap原理 — 扩容机制及存取原理

你知道的Go切片扩容机制可能是错的