代码改变世界

JDK中HashMap中hash方法的原理

2019-09-30 14:52  老九君  阅读(548)  评论(0编辑  收藏  举报

HashMap中hash方法的如下:

//jdk1.7版本

static int hash(int h) {

    h ^= (h >>> 20) ^ (h >>> 12);

    return h ^ (h >>> 7) ^ (h >>> 4);

}

//Java 8中的散列值优化方法

static final int hash(Object key) {

    int h;

    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

上面这段代码叫做“扰动函数”,在Java8中进行了简化,只做一次16位位移或混合,而不是四次,原理不变。下面以Java8的源码为例解释:大家都知道上面代码里的key.hashCode()方法调用的是key键类型自带的哈希方法,返回整型散列值。理论上散列值是一个int类型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的整型范围从-2147483648到2147483648,前后加起来大概40亿的映射空间。只要哈希方法映射地比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。各位想,HashMap的初始容量大小才16!所以这个散列值并不能直接拿来用,用之前还要先做对数组的长度取模运算,得到的余数再拿来访问数组下标。jdk源码中的模运算是在indexFor()方法中完成的,indexFor()方法的代码很简单,就是把散列值和数组长度做一个“与”操作:

static int indexFor(int h, int length){

    return h & (length - 1);

}

....

bucketIndex = indexFor(hash, table.length);

这里顺便讲一下为什么HashMap的数组长度要取2的整次幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与操作”的结果就是散列值的高位全部归零,只保留低位值,用来做数组的下标。以初始长度16为例:16-1=15,2进制表示是 00000000 00000000 00001111。和某散列值做“与”操作的结果如下:

 

 

我们发现,最终的结果就是截取了最低的四位值。这时问题就来了,就算我们的散列值分布再松散,要是只取最后几位的话,碰撞会非常严重。更要命的是,如果散列本身做的不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,这会无比蛋疼。这时候“扰动方法”的价值就体现出来了,请看下图:

 

 右移16位,刚好是32位的一半,自己的高半区和低半区做异或(如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0),就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留了下来。最后我们来看一下Peter Lawley的一篇专栏文章<An introducion to optimising a hashing stretegy>https://www.javacodegeeks.com/2015/09/an-introduction-to-optimising-a-hashing-strategy.html )里的一个实验:他选取了352个字符串,在散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标:

 

 

结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动方法的情况下,发生了103次碰撞,接近30%。而在使用了扰动方法后,只有92次碰撞,碰撞减少了近10%。扰动方法的确发挥了不错的功效。Java7中的扰动做了四次,而到了Java8,觉得做一次就够了,多了边际效用也不大,这就是所谓的为了效率考虑就改成了1次扰动,相比较而言减少了过多的位运算,是一种折中的设计。

更多干货笔记关注微信公众号 : 老九学堂