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 value, int 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何时扩容以及它的扩容机制?的主要内容,如果未能解决你的问题,请参考以下文章