HashCode & HashMap扰动函数、初始化容量、负载因子、扩容元素拆分

192 阅读7分钟

HashCode & HashMap扰动函数、初始化容量、负载因子、扩容元素拆分

1.HashCode为什么用31作为乘数?

String.class的hashCode方法如下:

public int hashCode() {
    int h = hash;
    if (h == 0 && value.lengt > 0) {
        char[] val = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i]
        }
        hash = h
    }
    return h;
}

上面方法中有一个写死固定值31,想必大家在看String的hashCode方法源码时都会有这个疑问,为什么是31?

  • hash函数必须选用质数,这是被科学家论证过的hash函数减少冲突的理论。
  • 如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为使用偶数相当于位运算(低位补0)。
  • 使用31有个很好的性能,即用移位和减法来代替乘法,可以得到更好的性能:31*i = (i << 5) - i,目前使用的JVM自动完成此类优化。
  • 31是个不大不小的质数,hash碰撞概率很低,而且生成的hash值很均匀的散列。(可以通过实验得出)
2. HashMap
2.1 HashMap扰动函数

Java8中HashMap获取hash值的方法为:

static final int hash() {
   int h;
   return (key == null) ? 0 : (h = key.hashCode() ^ (h >>> 16));
}

get方法中获取key的下标值方法为:n是map的大小

(n - 1) & hash

看到HashMap的获取hash方法的源码时,都会思考为什么使用扰动函数计算,为什么不能直接用key的hashCode的值?

  • 首先,hashCode的取值范围是[-2^32, 2^31],也就是[[-2147483648, 2147483647],有将近40亿的长度,不可能把数组初始化得这么大,内存也放不下。
  • 所以,我们要将hashCode的值进行扰动计算(h = key.hashCode() ^ (h >>> 16)),把hash值往右移16位,右移高位补0,相当于把高位移到了低位,然后再与原先的hash值做异或运算(相同为0,不相同为1),这就相当于混合了原hash值中的高位和低位,得到一个更加散列的低 16 位的 Hash 值,增大了随机性。最后与map数组的大小-1做与运算,高位全部归0,只保留末四位 计算方式如下图:比如原hash值为:00000000 11111111 00000000 00001010
hashCode()  		              00000000 11111111 00000000 00001010		hash值
hashCode() >>> 16 		      00000000 00000000 00000000 11111111		右移16位
hashCode() ^ (hashCode() >>> 16)      00000000 11111111 00000000 11110101  		异或高低位(相同为0,不相同为1)
hashCode() ^ (hashCode() >>> 16) & 15 00000000 00000000 00000000 00001111 		与运算下标(都为1则为1,否则为0)
				      00000000 00000000 00000000 00000101
                                                                   = 0101 
                                      				   = 5
  • 一句话,使用扰动函数就是为了增加随机性,让数据元素更加均匀的散列,减少碰撞。
2.2 HashMap初始化容量

HashMap的初始化含量是16,最大容量是2的30次方,而且容量大小必须是2的倍数,因为只有2的倍数在减1的时候,二进制都是1,从而在与hash值做与运算的时候随机性更大。

/**
 * The default initial capacity - MUST be a power of two.
 */
static final int DEFAULT_INITAL_CAPCITY = 1 << 4

如果我们传的值不是2的倍数,比如我们传17,这个时候HashMap会怎么处理呢?

HashMap构造方法里面,会调用一个tableSizeFor方法进行计算,得到大于我们传的值最小的2倍数的数。

public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadlFactor;
    this.threshold = tablSizeFor(initialCapacity);
}

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 >= MAXIMUN_CAPACITY ? MAXIMUM_CAPACITY : n + 1);
}
  • MAXIMUN_CAPACITY = 1 << 30,这个是临界范围,也就是最大的Map集合。

  • tableSizeFor都是在向右移1、2、4、8、16位,然后做或运算(两个位都为0则为0,否则为1),因为这样子可把二进制的各个位置都填上1,加上1之后,就是一个标准的2的倍数的数了。

  • 可以把传入17初始化计算阙值的过程用图展示出来,方便理解,最后得到的数就是32

    17 - 1 			10000
    n >>> 1  		01000 
    n | n >>> 1		11000
    n >>> 2			00110
    n | n >>> 2		11110
    n >>> 4 		00001
    n | n >>> 4		11111	
        n >>> 8		00000
    n | n >>> 8             11111
    n >>> 16		00000
    n | n >>> 16            11111 = 31
    
2.3 HashMap负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

负载因子用于当容量超过某个阙值时,要进行扩容操作。在HashMap中,负载因子决定了数据量多少了以后可以进行扩容。比如HashMap的容量大小为16,当容量超过 16*0.75=12个元素时,就要进行扩容操作了,这样子做的原因是因为可能出现即使你的元素数量比容量大时也不一定能填满容量,因为某些位置会出现碰撞,使用链表存放了,如果存在大量的链表,这样子就失去了Map的数组的性能了。所以要选择一个合理的大小进行扩容,HashMap默认值是0.75,当阙值容量占了3/4时进行扩容操作,减少Hash碰撞。同时0.75是一个默认构造值,在创建HashMap也可以做调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一点,减少碰撞。

2.4 HashMap扩容元素拆分

HashMap在扩容的时候要把原先的元素拆分到新的数组中,拆分过程中,原jdk1.7中会需要重新计算哈希值,但在jdk1.8中进行了优化,不再重新计算,提升了拆分的性能。

String key = "zuio";
int hash = key.hashCode() ^ (key.hashCode() >>> 16);
System.out.println("zuio的扰动hash值:" + Integer.toBinaryString(hash));
System.out.println("容量为16的下标二进制值:" + Integer.toBinaryString(hash & (16 - 1) ));
System.out.println("容量为16的下标十进制值:" + ((16 - 1) & hash));
System.out.println("zuio hash值原容量16与运算结果为:" + (hash & 16));
System.out.println("容量为32的下标二进制值:" + Integer.toBinaryString(hash & (32 - 1) ));
System.out.println("容量为32的下标十进制值:" + ((32 - 1) & hash));

String key2 = "plop";
int hash2 = key2.hashCode() ^ (key2.hashCode() >>> 16);
System.out.println("zuio的扰动hash值:" + Integer.toBinaryString(hash2));
System.out.println("容量为16的下标二进制值:" + Integer.toBinaryString(hash2 & (16 - 1) ));
System.out.println("容量为16的下标十进制值:" + ((16 - 1) & hash2));
System.out.println("plop hash值与原容量16与运算结果为:" + (hash2 & 16));
System.out.println("容量为32的下标二进制值:" + Integer.toBinaryString(hash2 & (32 - 1) ));
System.out.println("容量为32的下标十进制值:" + ((32 - 1) & hash2));

// 上面输出结果为
// zuio的扰动hash值:1110010011100110011000
// 容量为16的下标二进制值:1000
// 容量为16的下标十进制值:8
// zuio hash值与原容量16与运算结果为:16
// 容量为32的下标二进制值:11000
// 容量为32的下标十进制值:24
    
// plop的扰动hash值:1101001000110011101001
// 容量为16的下标二进制值:1001
// 容量为16的下标十进制值:9
// plop hash值与原容量16与运算结果为:0
// 容量为32的下标二进制值:1001
// 容量为32的下标十进制值:9

通过上面两个例子可以得出以下结论:原hash值与原容量进行&运算,如果结果为0,则下标位置不变,如果不为0,则新的下标在原先的位置上加上原先的容量(我只举了两个例,可以多举些例子,最后都可以得到上面结论)。在HashMap扩容方法resize核心代码如下:

final Node<K, V>[] resize() {
	...
	// hash值与原容量&运算
	if ((e.hash & oldCap) == 0) {
		// 原索引
    	if (loTail == null)
    		loHead = e;
   		 else
    		loTail.next = e;
    	loTail = e;
    }
    else {
    	// 原索引 + 原容量
    	if (hiTail == null)
    		hiHead = e;
    	else
    		hiTail.next = e;
    	hiTail = e;
    }
}

HashMap每一次执行扩容后,数组长度都变成原来的2倍,所以就是数组长度转为二进制后比原来多了一位,比如原先16-1,二进制为1111,扩容之后为32-1,二进制为11111,二进制都多了一位。多出来的这一位与hash值的同一位做&运算,结果为0则索引不变,结果为1则索引为原索引+原数组长度,栗子如下:

多出来的二进制一位1与hash做与运算为1, 为索引为原索引 + 原数组长度
原数组上次16-1的二进制: 	  0000 0000 0000 0000 0000 0000 0000 1111
新数组长度32-1的二进制:     0000 0000 0000 0000 0000 0000 0001 1111
字符串zuio的扰动hash值:    0000 0000 0011 1001 0011 1001 1001 1000
				多出来的这一位与运算结果     1		 	
 
多出来的二进制一位1与hash做与运算为0, 为索引为原索引    
原数组上次16-1的二进制: 	  0000 0000 0000 0000 0000 0000 0000 1111
新数组长度32-1的二进制:    0000 0000 0000 0000 0000 0000 0001 1111
字符串plop的扰动hash值:    0000 0000 0011 0100 1000 1100 1110 1001
        			多出来的这一位与运算结果     0