前言
上回只讲了Hash函数的几种算法。然鹅,再好的hash算法,在实际使用中也只能是尽可能地减少hash碰撞。那么如果发生了hash碰撞,该怎么办呢?这就是今天要讨论的问题。
hash冲突的解决方案
- 链地址法
- 开放定址法
- 再hash法
- 公共溢出区
链地址法
使用链表结构,将发生hash冲突的key,通过链表存储起来。
JDK在HashMap中,就使用了这种处理。只不过出于对查询性能的考虑,当hash碰撞达到8的时候,就转为红黑树(对于可比较的key有很好的效果)。
开放定址法
当发生hash冲突时,试探性的探测下一个地址是否依然存在冲突,如果没有,则确定该地址即为该key的存储位置。如果依然冲突,则在探测下一个地址,知道不存在冲突为止。
H(i) = (hash(key) + Di) % (m - 1)
hash(key) % (m - 1) 即为 当前冲突的位置
Di 即为 下一个要探测偏移量
举个栗子:ThreadLocalMap就使用了这种方法,每次发生冲突就直接将当前地址偏移1进行探测。而且还是用最简单的Di,即:1,2,3…n。依次探测整个hash表中的位置(当然,超过数组长度就回到首个元素了,下标0)
而Di序列的选取,也是有多种办法的。
- 线性开放定址法,例如上面说的栗子。
- 平方开放定址法。按照 2^1, 2^2, 2 3…依次跳跃定址,又或者21, (-1)2^1, 2^2, (-1)2^2, 2 ^3,(-1)2 ^3…左右跳跃寻址
- 伪随机数定址法,这种有点像再hash了。
线性开放定址法,容易产生二次聚集。在key存在连续的情况下,本来不冲突的key,“被冲突”了,又要占据下一个位置。在此连续的位置下,可能产生聚集状况。
而另外两种定址法,虽然可以避免二次聚集,可以hash冲突不剧烈的情况下,更加均匀的散列。在hash装载比较满的情况下,但是不能100%确保能够找得到存放的位置,因为是跳跃式的,不能保证每个位置都能被探测到。而线性探测就能做到,
一般hash表通常都会有个装载因子,是不会允许全部满载的情况的。HashMap是0.75,而ThreadLocalMap 2/3,装载的更少一些,也是因为这个线性定址法的缘故,为了减少聚集现象,避免全表探测。
再hash法
在发生hash冲突后,将当前冲突的位置hash(key) % (m-1)作为参数,通过另外一个hash函数再次hash。无疑,它增加了运算消耗。
公共溢出区
构建另外一张表,用于存放发生hash冲突的key。这个感觉也太不友好了,要是需要寻找的key在公共溢出区中,这寻找的时间复杂度也太高了点吧。
ThreadLocal中的Hash
分析HashMap的文章挺多的,这里不过多介绍。咱们来看看ThreadLocalMap。呼应上回说的那个魔法数字:HASH_INCREMENT = 0x61c88647=1640531527。他是个啥子嘞?别着急,秘密在于他的hash算法中。
在说到hash算法的随机乘数法时,有这么个公式:
hash(K) = m *[( K * A) mod 1]取整(0 < A < 1)。当p=0.618…黄金比例数时,散列效果最好。
如果m=2^i (这里是幂的形式,不是异或)
则有
hash(K)=m * [( K * A) mod 1]
为了简化计算机的运算,假定 m = 2^i,如果key是32位的int类型,则一定有某个值使得 A=s/(232)成立,即s=A*232。那么则原hash(K)可以表示为
m * [( K * A) mod 1]
=2^i * [( KA mod 1)]
= (2^32 KA mod 2^32 ) /2^(32-i) 即,分子分母同时 2^(32-i)
= (2^32 KA mod 2^32 ) * 2^(i-32)
而2^32 A=s,代入黄金比例0.618… = 2,654,435,769…
等式又变成
(2,654,435,769… * K mod 2^32) * 2^(i-32)
= 2,654,435,769… * K * 2^(i-32) mod 2^i
而因为key使用32位的int表示的,所以2(i-32)表示的是小数部分,而2i则是整数部分
= 2,654,435,769 * K * mod 2^i
而2,654,435,769用无符号的int表示的话,就是-1640531527。
采用黄金比例数的随机乘数法也叫斐波那契散列法,是它的一种特殊形式。其实也可以理解,因为黄金比例是一个无限不循环的,所以产生出来的hash值更不容易冲突。
这里反映的也是斐波那契数列与黄金比例的密切关系。
而hash冲突解决,ThreadLocalMap采用的是开放地址法解决的。