为什么数组长度必须是2的n次方?
/**
* Returns a power of two size for the given target capacity.
*/
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;
}
复制代码
在初始化一个HashMap时会调用上面这个方法,方法注释说明了它的作用是返回一个2的次方数作为数组容量。
但是为什么要规定必须是2的n次方呢?
原因和计算数组下标有关,下面这些代码是put方法中用来判断数组下标位置是否已经有Node的部分。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
复制代码
可以发现数组下标 i = hash & (n – 1) 的。HashMap是通过这种与运算的方式来快速获得数组下标的。
hash & (len – 1)
我们先来看len=16的情况,len-1=15=01111
hash | 表达式 | 结果 |
---|---|---|
1 | 01111&00001 | 1 |
2 | 01111&00002 | 2 |
3 | 01111&00003 | 3 |
4 | 01111&00004 | 4 |
5 | 01111&00005 | 5 |
…. | ||
13 | 01111&01101 | 13 |
15 | 01111&01111 | 15 |
16 | 01111&10000 | 0 |
观察表上结果可以发现两个规律:
- 所有的下标都在0~15的范围内
- 下标分布十分的均匀,以16个数为周期循环
再来看len=17的情况:
hash | 表达式 | 结果 |
---|---|---|
1 | 10000&00001 | 0 |
2 | 10000&00010 | 0 |
3 | 10000&00011 | 0 |
4 | 10000&00100 | 0 |
5 | 10000&00101 | 0 |
… | ||
15 | 10000&01111 | 0 |
16 | 10000&10000 | 1 |
17 | 10000&10001 | 1 |
可以发现len=17时,计算出来的数组下标全部都是 0 ,这就会使所有的Node全部落在一个桶中,导致十分严重的冲突。
总结
通过上面两张表我们可以发现,之所以把数组容量规定为2的幂次,作用有两个:
- 保证len-1的最高位为0,使下标值不可能越界
- 使len-1除最高位全是1,这样可以不改变hash值,使得下标更加分散,减少冲突的发生
HashMap是如何计算hash的
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码
上面的代码是jdk1.8的hash方法,jdk1.7的思路类似但是更复杂,这里不介绍。
首先,如果key是null,hash方法会返回0,这也就是为什么null key一定是存在数组下标0位置的。
key不为null时调用了Object类的hashCode()方法,然后进行了位运算。这里我们来分析为什么调用了hashCode还要位运算。
异或运算和右移运算
异或是二进制运算的一种,java中用 ^ 表示。异或运算会将每一位二进制位相比,相同的得0,不同的得1。
操作数1 | 操作数2 | 异或 |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
右移是将二进制数去除右侧低位,在左侧高位补零的运算。
比如:
0101011 >>> 2 = 0001010
复制代码
key.hashCode()
java中规定了,当我们重写了Object类的equals方法后必须重写hashCode方法。这是因为两个对象相等hashCode也必须相等。
既然我们可以改写hashCode方法,那么hashMap直接使用这个hashCode就不合理了。这也就是为什么还要计算hash的原因之一。
回到 hash & (len – 1)
之前提到HashMap使用这个表达式计算数组下标的,计算时需要考虑到key的分布来减少冲突的发生。
表达式中 hash 是 32位,len-1的高位全是0,低位是1。也就是说我们只会用到hash 32位二进制位的一部分。
比如len是16,那么我们只会用到hash值32位中的4位。
这下再回到hash()方法就清晰了。
- 这里将h右移了16位,16这个数正好将hash值分为两半
- 如果要用到16位的hash做与运算,那么len = 2 ^ 17。这个容量明显是很难达到的,也就是说hashcode至少有一半的长度没有利用起来。
结论
- 右移后再与原值异或,最大的作用就是使hashcode的每一位都得到充分的利用。充分利用的32位值也可以减小冲突发生的可能。
- 用户可能重写了hashCode方法,hash()方法可以打乱重写逻辑,避免重写可能导致的冲突几率提高
为什么负载因子是0.75
/*Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
复制代码
HashMap源码中有以上的一段注释。大概意思是说,在随机的hashcode条件下,Node在哪个桶中符合泊松分布。并给出了在负载因子是0.75时,同一个桶中链表不同长度的概率。
其中链表长度为8的概率已经是 千万分之六,几乎是不可能出现的。至于是怎么算出来的… …(我也没算过)
结论1:负载因子在0.75时,链表长度很难达到8。
抛开数学的东西,我们来考虑负载因子的大小对HashMap的影响。
- 负载因子大于1明显不可能,这样HashMap永远都不会扩容,将导致严重的冲突
- 负载因子很接近1,这会使数组要达到满载时才会触发扩容,也会让冲突频繁
- 负载因子很小,数组会频繁触发扩容,这样的好处是key会变得分散,冲突会减少。但是扩容会占用大量时间,而且数组元素会很散,使空间利用率变低。
结论2:负载因子应该是一个0.5附近的值,以此来平衡过大和过小的情况。