HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;
这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1),
hash%length==hash&(length-1)的前提是length是2的n次方;
为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1;
例如长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了;
例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞;
其实就是按位“与”的时候,每一位都能 &1 ,也就是和1111……1111111进行与运算
0000 0011 3
& 0000 1000 8
= 0000 0000 0
0000 0010 2
& 0000 1000 8
= 0000 0000 0
-------------------------------------------------------------
0000 0011 3
& 0000 0111 7
= 0000 0011 3
0000 0010 2
& 0000 0111 7
= 0000 0010 2
当然如果不考虑效率直接求余即可(就不需要要求长度必须是2的n次方了)
JDK1.8 中 hash 函数的实现
JDK1.8中再次优化了这个哈希函数,把 key 的 hashCode 方法返回值右移16位,即丢弃低16位,高16位全为0 ,然后在于 hashCode 返回值做异或运算,即高 16 位与低 16 位进行异或运算,这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低Bit都参与到 hash 的计算中,同时不会有太大的开销,扰动处理次数也从 4次位运算 + 5次异或运算 降低到 1次位运算 + 1次异或运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码
进过上述的扰动函数只是得到了合适的 hash 值,但是还没有确定在 Node[] 数组中的角标,在 JDK1.7中存在一个函数,JDK1.8中虽然没有但是只是把这步运算放到了 put 函数中。我们就看下这个函数实现:
static int indexFor(int h, int length) {
return h & (length-1); // 取模运算
}
复制代码
为了让 hash 值能够对应到现有数组中的位置,我们上篇文章讲到一个方法为 取模运算,即 hash % length,得到结果作为角标位置。但是 HashMap 就厉害了,连这一步取模运算的都优化了。我们需要知道一个计算机对于2进制的运算是要快于10进制的,取模算是10进制的运算了,而位与运算就要更高效一些了。
我们知道 HashMap 底层数组的长度总是 2^n ,转为二进制总是 1000 即1后边多个0的情况。此时一个数与 2^n 取模,等价于 一个数与 2^n - 1做位与运算。而 JDK 中就使用h & (length-1) 运算替代了对 length取模。我们根据图片来看一个具体的例子:
图片来自:https://tech.meituan.com/java-hashmap.html 侵删。
小结
通过上边的分析我们可以到如下结论:
在存储元素之前,HashMap 会对 key 的 hashCode 返回值做进一步扰动函数处理,1.7 中扰动函数使用了 4次位运算 + 5次异或运算,1.8 中降低到 1次位运算 + 1次异或运算
扰动处理后的 hash 与 哈希表数组length -1 做位与运算得到最终元素储存的哈希桶角标位置。