HashMap的扩容——resize()方法

一, 先来看几个变量、常量、静态变量、静态常量:

详情参见:HashMap的结构与构造函数_Morning sunshine的博客-CSDN博客

1) Map的默认初始化容量以及容量极限:

    //HashMap默认的初始容量大小--16,容量必须是2的幂
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    //HashMap的容量极限,为2的30次幂;
    static final int MAXIMUM_CAPACITY = 1 << 30;

2) 默认负载因子、实际元素数量:

    //负载因子的默认大小,
    //元素的数量/容量得到的实际负载数值与负载因子进行对比,来决定容量的大小以及是否扩容;
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //HashMap的实际元素数量
    transient int size;

·

3)table——Entry数组;


    //Node是Map.Entry接口的实现类,可以将这个table理解为是一个entry数组;
    //每一个Node即entry,本质都是一个单向链表
    transient Node<K,V>[] table;

· 

4)Map已经修改的次数、扩容阀值、存储负载因子的常量:

    //HashMap已在结构上修改的次数 结构修改是指更改 HashMap 中的映射数量或以其他方式修改其内部结构(例如,重新散列)的那些
    transient int modCount;

    //下一次HashMap扩容的阀值大小,如果尚未扩容,则该字段保存初始entry数组的容量,或用零表示
    int threshold;

    //存储负载因子的常量,初始化的时候将默认的负载因子赋值给它;
    final float loadFactor;

        扩容阀值threshold = 负载因子loadFactor * 容量capacity

·


二、Hashmap源码-负载因子

·        

1)什么是负载因子:

static final float DEFAULT_LOAD_FACTOR = 0.75f;

final float loadFactor;

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

        负载因子在初始化Map的时候被赋值,要么被用户传的参数赋值,要么被默认的0.75f给赋值;

        默认的负载因子我们之前说过:0.75f,当然也可以自己设置,但 0.75 是最均衡的设置,没有特殊要求不要修改该值,

        负载因子过小,说明Node元素比较少,理论上能减少 hash 冲突,

        负载因子过大,可以节约空间。

·

2)负载因子loadFactor,在哪里被赋值:

        在Map的构造函数中:

 public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
 }

从代码中可以看到,

        如果我们设置的初始化容量小于0,将会抛出异常

        如果加载因子小于0也会抛出异常

        同时,如果初始容量大于最大容量,则重新设置为最大容量

·

3)tableSizeFor()方法是做什么的:

        我们看最后两行代码,首先,对负载因子进行赋值,这个没什么可说的。

        牛逼的是下面一行代码:this.threshold = tableSizeFor(initialCapacity);

我们进入到该tableSizeFor方法查看: 

 static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
 }

        可以看出,其实代码很简单,就是对容量cap进行了几次无符号右移的操作,最后返回。

那么这个方法的目的和作用是什么呢? 

        这个方法的作用是:用于查找距离容量initialCapacity最近的2次幂值,比如:

  •         cap是5~7,那么该方法的返回结果就是8;
  •         cap是9~15,那么该方法的返回结果就是16;
  •         cap是17~31,那么该方法的返回结果就是32;
  •         以此类推...

        我们仔细观察其算法的过程,可以说,任何一个int 数字,都能找到离他最近的 2 的幂次方数字(且比他大)。

·


三,resize()方法:

1)怎么计算的扩容阀值:

      扩容阀值threshold = 负载因子loadFactor * 容量capacity

·

2)什么时候触发扩容:

         当entry数组的长度到达了扩容的阀值threshold之后,就会调用resize()方法进行扩容

·

3) resize方法的两个作用:

        resize方法的两个作用是:①用来初始化一个Map,②或者对Map容量进行扩容

        扩容时,因为我们使用二次幂展开,每个 bin 中的元素必须保持相同的索引,或者在新表中以二次幂的偏移量移动。

· 


四, resize()方法源码分析:

·

1)resize()方法第一部分:初始化newThr、newCap、newTab;

· 

1.1、分析 resize()方法的源码之前,先认识几个变量是什么意思:

oldTab——扩容前,原Node数组

oldThr——扩容前,原扩容阀值

oldCap——扩容前,原Node数组的长度

newTab——扩容后,新Node数组

newThr——扩容后,新扩容阀值

newCap——扩容后,新Node数组的长度

·

 1.2,开始分析:

​final Node<K,V>[] resize() {
	//将Node数组赋值给oldTab,oldCap为Node数组的长度
	Node<K,V>[] oldTab = table; 
	int oldCap = (oldTab == null) ? 0 : oldTab.length; 
    //在初始化的时候,Node数组--oldTab为空,Node数组长度--oldCap==0
	
	//如果用户在初始化的时候传入了容量参数,则threshold>0,如果没有传入容量参数,则threshold==0;
	int oldThr = threshold;
	int newCap, newThr = 0;

    //如果原Node数组的长度>0:
	if (oldCap > 0) {

        //判定Node数组是否已达到极限大小,若判定成功将不再扩容,直接将原Node数组返回
		if (oldCap >= MAXIMUM_CAPACITY) {
			threshold = Integer.MAX_VALUE;
			return oldTab;
		}

        //如果新Node数组的大小(oldCap*2)小于数组极限大小,并且原Node数组的长度大于等于数组初始化大小
		else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
				 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //则将原扩容阀值,乘以2,当作新的扩容阀值
			newThr = oldThr << 1; // double threshold
	}

	 //如果初始化的时候用户传入了容量参数,则此时oldThr(原扩容阀值)大于0,那么将(原扩容阀值)oldThr 赋值给 (新Node数组的长度)newCap
	else if (oldThr > 0)
		newCap = oldThr;  
	
    //如果初始化的时候用户什么都没传,那么此时oldCap和oldThr都==0:则		
	else { 
		//将默认容量大小DEFAULT_INITIAL_CAPACITY(16)赋给newCap(新Node数组的长度);
		newCap = DEFAULT_INITIAL_CAPACITY;
		//将(默认负载因子)0.75f * (默认的容量大小)DEFAULT_INITIAL_CAPACITY的计算结果,赋值给newThr(新的扩容阀值);
		newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
	}

	//如果初始化的时候用户传入了容量参数和负载因子或者只传入了容量参数,
	//那么意味着oldCap==0、oldThr>0,那么上边的else里边是不会进入的,那么此时newThr(新的扩容阀值)仍然==0:
	if (newThr == 0) {  
		//所以,将用户传入的容量参数 * 用户传入的负载因子loadFactor(也可能是默认的负载因子),
		//将计算结果赋值给newThr(新的扩容阀值);
		float ft = (float)newCap * loadFactor;   
		newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
				  (int)ft : Integer.MAX_VALUE);
	}

	//最终,将newThr(新的扩容阀值)赋给threshold;
	//即,初始化好了扩容阀值;
	threshold = newThr; 
	
	//所以新建一个entry数组,容量为newCap,即创建出扩容后新的Node数组;
	Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
	table = newTab;
	
	//...其他的扩容逻辑
	
	//返回新的Node数组;
	return newTab;
}

· 

逻辑步骤概括:

1、如果初始化的时候用户传入了容量参数和负载因子,或者只传入了容量参数,

        1.1、那么将 用户传入的容量参数 * 用户传入的负载因子loadFactor(也可能是默认的负载因子),的计算结果,赋值给扩容阀值threshold

        1.2、将容量参数赋值给newCap(新的Node数组的长度),根据newCap(新的Node数组的长度)创建出一个新的Node数组newTab;

2、如果初始化的时候用户什么都没传,

        2.1、那么将 默认负载因子0.75f * 默认的容量大小DEFAULT_INITIAL_CAPACITY,的计算结果,赋值给扩容阀值threshold

        2.2、将默认容量大小DEFAULT_INITIAL_CAPACITY(16)赋给newCap(新的Node数组的长度);根据newCap(新的Node数组的长度)创建出一个新的Node数组newTab;

·

总结:

在这一部分,resize()方法主要做了三件事情:

  •        初始化了(新的扩容阀值)newThr,(随后将newThr赋值并覆盖了threshold);
  •        初始化了(新的Node数组的长度)newCap;
  •        根据初始化好的(新的Node数组的长度)newCap,创建出(新的Node数组)newTab,(随后赋值并覆盖了table)

·

2) resize()方法第二部分:

        将原Node数组中的Node元素,迁移到扩容后的新的Node数组中:

final Node<K,V>[] resize() {
    //...
	//如果原Node数组不为空
	if (oldTab != null) {
		//遍历原Node数组:
		for (int j = 0; j < oldCap; ++j) {
			Node<K,V> e;
			if ((e = oldTab[j]) != null) {
				oldTab[j] = null;
				
				//如果该Node元素的next==null,则说明该Node元素后边既没有链表又没有红黑树;
				if (e.next == null)
					//则将该Node元素直接存于新Node数组的指定位置
					newTab[e.hash & (newCap - 1)] = e;
				
				//如果该Node元素后边跟着的是一个红黑树结构:
				else if (e instanceof TreeNode)
					//在新的Node数组中,将该红黑树进行拆分,
					//(如果拆分后的子树过小(子树的节点小于等于6个),则取消树化,即将其转为链表结构);	
					((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
				
				//如果是链表的情况下,则进行下面的链表数据转移的操作
				else { // preserve order
					Node<K,V> loHead = null, loTail = null;
					Node<K,V> hiHead = null, hiTail = null;
					Node<K,V> next;
					do {
						//对链表进行遍历,把链表中的节点分成两个类别,一是需要更换数组下标的,一是不需要的
						next = e.next;
						//如果e.hash&oldCap进行与运算,算出的结果是为0,即说明该Node节点所对应的数组下标不需要改变
						if ((e.hash & oldCap) == 0) {
							//如果loTail为null,说明该链表没有头节点
							if (loTail == null)
								//所以把头节点指向该节点
								loHead = e;
							//如果该链表有头结点,则把遍历出来的节点放在该链表的尾部
							else
								loTail.next = e;
							loTail = e;
						}
						//如果e.hash&oldCap进行与运算,算出的结果不为0,则更新该Node节点所对应的数组下标
						else {
							//逻辑跟上面一样
							if (hiTail == null)
								hiHead = e;
							else
								hiTail.next = e;
							hiTail = e;
						}
					} while ((e = next) != null);
					
					//该Node节点所对应的数组下标不需要改变,直接把数组下标对应的节点指向新Node数组下标位置链表的头节点
					if (loTail != null) {
						loTail.next = null;
						newTab[j] = loHead;
					}
					//该Node节点所对应的数组下标需要改变,重新计算出所对应的数组下标值,然后指向新Node数组下标位置链表的头节点
					if (hiTail != null) {
						hiTail.next = null;
						newTab[j + oldCap] = hiHead;
					}
				}
				//总结:将链表分成两个不同的部分,可以使得数据更加的分散,使得链表的长度变短
			}
		}
	}
	return newTab;
}

·

逻辑步骤概括: 

        1、如果原Node数组不为空,遍历原Node数组;

        2、如果遍历的该Node元素的next==null,则说明该Node元素后边既没有链表又没有红黑树,则将该Node元素直接存于新Node数组的指定位置;

        3、如果遍历的该Node元素后边跟着的是一个红黑树结构,则在新的Node数组中,将该红黑树进行拆分,(如果拆分后的子树过小(子树的节点小于等于6个),则取消树化,即将其转为链表结构);    

        4、如果遍历的该Node元素是链表的情况下,对链表进行遍历,将链表中的Node元素迁移到新的Node数组中;(对链表遍历的时候、把链表中的节点分成两个类别,一是需要更换数组下标的,一是不需要的);

·

如果遍历的该Node元素是链表的情况下的元素迁移:

        在Java8中,该部分代码不是简单的将旧链表中的数据拷贝到新数组中的链表就完了,而是会对旧的链表进行重新 hash,

        如果 hash 得到的值和之前不同,则会从旧的链表中拆出,放到另一个下标中去,提高性能,刚刚的红黑树也是这么做的。

if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

这段代码的理解: 

        oldCap 假如是16,那么二进制为 10000,扩容变成 100000,也就是32.

        当旧的hash值 与运算10000,结果是0的话,那么hash值的右起第五位肯定也是0,那么该于元素的下标位置也就不变。

        但如果不是0是1的话,说明该hash值变化了,那么就需要对这个元素重新散列放置。

        那么应该放哪里呢?如果是16,那么最左边是1的话,说明hash值变大了16,那么只需要在原有的基础上加上16便好了。

·

建议:

        在遍历的该Node元素是链表的情况下的元素迁移的这段代码,还有一个需要注意的地方:在JDK 7 中,这里的的代码是不同的,在并发情况下会链表会变成环状,形成死锁

        而JDK 8 已经修复了该问题,但是仍然不建议使用 HashMap 并发编程

        HashMap 在 JDK 7 中并发扩容的时候是非常危险的,非常容易导致链表成环状。但 JDK 8 中已经修改了此bug。但还是不建议使用。

        在并发情况下,推荐并发容器 ConcurrentHashMap

·

总结:

        1、我们知道了,无论我们如何设置初始容量,HashMap 都会将我们改成2的幂次方,也就是说,HashMap 的容量百分之百是 2的幂次方,因为HashMap 太依赖他了。

        2、HashMap 的默认加载因子是 0.75,虽然可以修改,但是出于安全考虑,除非你经过大量测试,请不要修改此值,HashMap 使用此值基本是平衡了性能和空间的取舍。

        3、HashMap 扩容的时机是,Node数组中的元素数量 > 负载因子 * 容量

              如果负载因子是0.75,容量是16,那么当容器中数量达到13 的时候就会扩容。

              还有,如果某个链表长度达到了8,并且容量小于64,则会用扩容代替红黑树。

·        

        4、HashMap 扩容的时候,不管是链表还是红黑树,都会对这些数据进行重新的散列计算,然后缩短他们的长度,优化性能。

             在进行散列计算的时候,会进一步优化性能,减少减一的操作,直接使用& 运算。可谓神来之笔。


              总之,HashMap 中的优秀的设计思想值得我们去学习,最让楼主震惊的就是那个将任意一个数变成了2的幂次方的数,并且该数字很合理,说实话,如果让楼主写,楼主是写不出来的。

        所以,请努力吧,现在做不到,不代表以后做不到,通过学习优秀的源码,一定能够提高我们的编码能力。