1. 前言
HashMap绝对是JDK源码中比较精髓的存在,除此之外还有AQS,线程池等等。但是由于HashMap是我们接触最早也是接触最多的,所以面试八股文中绝对少不了它的存在。
想要学习一门技术能够大概了解它的原理其实已经差不多了,但是面试官往往会刨根问底,追问你put流程,resize过程,为什么负载因子是0.75之类的……
其实这些都还好,最让人头疼的是哈希算法,我们来看看JDK1.8中计算下标的源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
除此之外还有一句,这个hash就是上面得到的值,而n就是数组长度:
(n - 1) & hash
那么问题来了:
- 为什么要右移16位?
- 为什么要和自己再进行异或(^)操作?
- 为什么要n-1?
- 为什么是位运算而不是求模?
带着这些疑问,我们继续往下读。
2. 哈希算法的目标
我们都知道HashMap的原理,对于一个key进行哈希运算得到下标,然后把这个key对应的value存入数组中。
我们假设数组容量是16,如果这时候来了一个hashcode是9999的值怎么办?直接用9999做下标会越界,所以我们需要做进一步处理:
保证哈希运算得到的结果在容量范围内。
2.1 如何保证效率?
有人说这还不简单,直接取模不就完事?
hashcode % 16
行了打完收工,我们的哈希算法完事了。如果说这是在你的业务代码里这么写,当然没什么问题。但这可是JDK源码,你业务代码运行的基石,取模运算的本质:
- 求整数商:c = a/b;
- 计算模/余数:r = a - (c*b);
可以看到,这其中包括了除法、乘法、减法运算,直接取模效率太低了,那么有没有什么方法能够取代求模呢?
当然有:
当length为2的n次方时,其实(length-1)&hashCode计算出来的值和hashCode%length是一致的
于是我们的算法得到了改进:
(length-1)&hashCode
咦?这不就是JDK源码中的(n - 1) & hash吗?除此之外,我们还有个意外收获:HashMap的容量为什么是2的n次幂。
2.2 怎么减少哈希冲突?
效率是提上来了,新的问题又来了:
String a = "张三";
String b = "李四";
String c = "王五";
String d = "赵六";
int length = 31;
System.out.println(a.hashCode() & length);
System.out.println(b.hashCode() & length);
System.out.println(c.hashCode() & length);
System.out.println(d.hashCode() & length);
假设我们有两个hashcode的值a和b,使用我们刚才推导出来的算法,计算出来的下标是这样的:
可以看到,张三和王五的下标发生了冲突,这个算法的似乎不太聪明的亚子,怎么办?
这个也简单,我们对哈希算法做个扰动即可。
2.3 怎么进行扰动?
事实上,当length=8时,下标运算结果取决于哈希值的低三位,当length=16时,下标运算结果取决于哈希值的低四位,以此类推,我们得出结论:
当length=2的N次方, 下标运算结果取决于哈希值的低N位。
也就是说,我们在进行(n - 1) & hash时,实际上是取低N位的值,那么如何让低N位的值分布得更加均匀呢?
用一个固定的值再去运算一次?这样做太死板了,我们需要的是更加随机,更加均匀。那么我们能不能让它对自己做点手脚?
我们知道,一个hashcode是32位,如果我们对它某些位数的值进行改动,然后再让这个改动后的值与自己进行一次运算,结果是不是就更加随机,更加均匀了?
于是我们可以将高十六位无符号右移之后,与原来的值做异或运算。这样可以让高十六位的特征与低十六位的特征进行了混合,得到的新的数值中就高位与低位的信息都被保留了。
而在这里采用异或运算而不采用& 、| 运算的原因是 异或运算能更好的保留各部分的特征,如果采用&运算计算出来的值会向1靠拢,采用|运算计算出来的值会向0靠拢。
整个过程的核心突出一个点:均匀!
于是我们的算法再次得到了改进:
(hashCode ^ (hashCode >>> 16)) & (length-1)
这不就是JDK源码中的做法吗?
2.4 新的算法效果如何?
我们用刚才推导出来的算法再来运行一次:
String a = "张三";
String b = "李四";
String c = "王五";
String d = "赵六";
int length = 31;
System.out.println(a.hashCode() & length);
System.out.println(b.hashCode() & length);
System.out.println(c.hashCode() & length);
System.out.println(d.hashCode() & length);
System.out.println("--------------------");
System.out.println((a.hashCode() ^ a.hashCode() >>> 16) & length);
System.out.println((b.hashCode() ^ b.hashCode() >>> 16) & length);
System.out.println((c.hashCode() ^ c.hashCode() >>> 16) & length);
System.out.println((d.hashCode() ^ d.hashCode() >>> 16) & length);
结果很明显,计算出来的下标明显均匀了许多!
3. 结尾
看到这里前面提出的几个问题大家心里应该都有数了,实际上本文还有一些延伸的问题,比如:计算机是怎么进行乘除运算的?为什么位运算可以替代取模?一定要高十六位右移吗?高八位高十二位行不行?
这些问题留给读者去思考,如有问题,欢迎在评论区中留言指正!