哈希函数的作用与本质
HashMap用来存储存在映射关系的数据对{key, value}
,在内部通过构造复合数据结构来封装数据对,即
//伪代码,非源码
class Pair<K, V> {
public K key;
public V value;
}
假设用来存储数据对的哈希数表为table,数据对Pair的存储位置通过计算key
得到,key => index
,则Pair
将存储在table[index]
处。
哈希函数就是用来计算哈希索引的工具。
fun(key,table) = index, 0 <= index < table.length
入参key为整数,若key不为整数,则需要先将key转化为整数再参与计算。在Java中,每个对象都有一个public int hashCode();
方法,起的就是这个转化作用。
哈希函数设计
构造哈希函数最常见的策略是取余法,即index = int_key % table.length
。
HashMap也是采用取余法构造哈希函数,并强制table.length为2的整数次幂,即
2
n
{2}^{n}
2n,这样便可采用位运算计算余数以提高运算效率:(table.length - 1) & key.hashCode()
。
因为当table.length = 2^n时,其二进制形式只有第n位为1,其余皆为0。任何一个整数对table.length求余,实际上就是取这个整数的低n位的值。
例如table.length = 16 = 2^4,其二进制形式为:
0000 0000 0000 0000 0000 1000
那么18 % 16,
而(table.length -1)的二进制形式,除了低(n - 1)位为1,其余皆为0。恰好是对任意一个整数低(n - 1)位的遮罩, 当用这个遮罩与任意一个整数进行位或操作时,恰好得到这个整数对table.length求余的余数。
以table.length = 16 = 2 ^ 4为例,table.length -1 = 15,即
0000 0000 0000 0000 0000 0000 0000 0111
那么18 & (16 -1) = 18 % 16 = 2,
哈希表初始容量的校正
创建HashMap时,允许指定哈希表的初始容量,即table.length。HashMap没有强制要求使用者传入的初始容量为2的整数次幂,而是在内部自动转化。若使用者传入的初始容量为c,那么转化后的哈希表容量为大于等于c的最小的一个2的整数次幂。如,9~15都会转为为16 = 2^4,
17~31都会转成2^5 = 32,以此类推。
从二进制的角度看,使用者传入的初始容量为c(先不考虑c恰好是2的整数次幂的情况),如果它比特值为1的最高位是第k位,那么转化后的值就是2^(k+1)。以129 = 2 ^7 + 1为例,比特值
为1的最高位是7,那么转化后的值便是2^8 = 256。
HashMap采用的策略是,如果c的比特值为1的最高位是第k位,即c=2^(k-1),那么将低k位全部置为1,再把结果值 + 1,恰好得到转换后的目标容量。以129 = 2 ^7 + 1为例,比特值为1的最高位是第8位,那么将低7位全部置为1得到255,255再加1等于256。
具体计算方法上,将c不断进行无符号右移再与原值进行位或操作,我们还是通过c = 129,k = 8来一步步说明。
第一步,将129无符号右移一位,得到结果64,结果的第k-1位必定为1。
然后将129与64做位或操作得到结果193,由于位或操作中,两个比特为只要有一个为1,那么结果必为1,如此,确保了最终结果第k-1位变为1。
第二步,经过第一步,第k位,k-1位都为1,所以我们可以把193无符号右移2位,得到结果48,结果的第k-2位及第k-3位必定为1。
然后将193与48做位或操作,得到结果241,最终结果第k位到第(k - 3)位,总共四位都变为1。
第三步,经过前面的操作,得到的结果241的第k位到(k-3)位,总共四位均变为1,所以将241无符号右移4位,得到结果15,其低4位均为1。
再将241与48进行位或操作,得到结果255,至此目标结果的低8位全部置为1。
因为用户传入的初始容量c,其比特值为1的最高位可能是第31位,所以为了囊括所有的可能,会继续无符号右移,直至移够31位,每次移动的位数恰好是上一次的2倍。
对于上面的例子,接下来要无符号右移8位。将255 无符号右移8位得到0。
将255与0进行位或,结果不变还是255。
接下来,位移16位,结果为0,再与255位或,结果还是255。至此已经总共无符号右移了(1 + 2 + 4 + 8 + 16) = 31位,位移结束。
最后,255 + 1 = 256。
按照上面的算法,哈希表初始容量的校正代码如下:
int tableSizeFor(int cap) {
int n = cap;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return n
}
我们还有遗留的一个问题没有讨论,假如使用者传入的初始容量c本来就是2的整数次幂,如c = 128 = 2^(k - 1),k = 8,那么将低(k - 1) = 7位全部置为1,将得到255。
那么,如果想把这样的c也统一到我们的计算公式里,我们只需要在开始位移前,将初始容量减1,然后再开始位移。比如c = 128,c - 1 = 127,127的二进制表示法中,比特值为1的最高位是第7位,将其低6位全部置为1,结果还是127,然后加1得到目标结果128。
对于初始容量c不是2的整数次幂的情况,将c - 1再通过tableSizeFor计算,结果跟直接通过c计算的结果一致。
最后再考虑下边界值问题。
当c = 0,直接取1 = 2^0,
在java中,一个int值能表示的最大的2的整数次幂是2^(k -1),k = 31,即2^30,所以当c的二进制形式中,比特值为1最
高位为31时,那么位移结束后目标结果将是(2^32) -1,
即除了符号位全是1,此时再加1将产生溢出,所以直接取2^30。
最终的adjustCapacity如下:
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;
}