哈希函数基本工作中每天都在使用,独特的数据结构以及复杂的设计原则成为了面试中的重点考点,所以这个系列致力于梳理java中关于hash表的大部分知识点,如有疏漏,欢迎交流,会进行补充。
为了不耽误读者时间,每一篇文章都会列出文章核心知识点。
面试题,收录面试题有时候会将公司列出来,都是收集整理的大厂原题 :
1.Hash是什么?java中你了解哪些Hash表,谈谈(朋友最近去游族面试真题)
2.java中常见类型以及自定义对象的hash函数如何实现?
3.除了链地址法,你还了解哪些处理hash冲突的方法?(网易)
4.为什么HashTable的初始容量设计为11,扩容为何采用原容量*2+1,保持奇数呢?
5.为什么HashMap的容量转变为 2 次幂?
6.为什么要HashMap的hash函数要增加扰动函数,并且位数为16?
7.负载因子为什么是0.75?
8.hashcode和equals方法的作用以及如何重写?
一、哈希函数相关概念
1.hash定义
把任意长度的输入通过散列算法变换成固定长度的输出,该输出就是散列值。
2.hash函数设计原则
良好的hash函数应该是哈希值更加均匀,能够减少哈希冲突次数,提升哈希表的性能。
3.回顾位运算
(1)位运算为什么快?
位运算是汇编级的代码,位运算汇编级执行速度是很快的。
如果只是数值交换,正常情况不用位运算,这点速度提高没意义,而且代码不直观。
口说无凭,写了一段测算代码:
一、测试代码public class BitAndModulusTest { @Test public void bit() { //分别取值10万、100万、1000万 int number = 10000 * 10; int a = 1; long start = System.currentTimeMillis(); for(int i = number; i > 0 ; i++) { a &= i; } long end = System.currentTimeMillis(); System.out.println("位运算耗时:" + (end - start)); } @Test public void modulus() { //分别取值10万、100万、1000万 int number = 10000 * 1000; int a = 1; long start = System.currentTimeMillis(); for(int i = number; i > 0; i++) { a %= i; } long end = System.currentTimeMillis(); System.out.println("取模运算耗时:" + (end - start)); }}二、测试结果:(时间单位:毫秒) 计算次数 位运算 取模运算 倍数(位运算:取模运算) 10万: 1378 14609 10 100万: 1605 14716 9 1000万: 1637 17669 10三、结论 位运算确实比取模运算快得多,大约快了10倍,当然不同配置机器会有出入。
(2)常见位运算
符号 | 描述 | 运算规则 |
& | 与 | 同1为1,反之为0 |
| | 或 | 有1为1,反之为0 |
^ | 异或 | 相同为0,反之为1 |
~ | 取反 | 0变1,1变0 |
<< | 左移 | 各二进位全部左移若干位,高位丢弃,低位补0 |
>> | 右移 | 各二进位全部右移若干位,对无符号数,高位补0,有符号数,各编译器处理方法不一样,有的补符号位(算术右移),有的补0(逻辑右移) |
二、java中常见类型以及对象的hash值
java中的hash值都是是32位的int类型
1.Integer和Short类型hash值就是当前值
//Integer 直接返回当前值public static int hashCode(int value) { return value;}//Shortpublic static int hashCode(short value) { return (int)value;}
2.Float类型hash值:将存储的二级制格式转为整数值
浮点数在计算机存储的格式为二级制,hash值是将其该二级制转换为32位整数
(1)Java中如何获得浮点数的hash值以及对应的二进制呢?
//Float.floatToIntBits:将浮点数在计算机中存储的2进制转换为10进制;int hash = Float.floatToIntBits(1.4f);//Integer.toBinaryString :将10进制转换为2进制String binaryStr = Integer.toBinaryString(hash);System.out.println("hash值:"+hash);System.out.println("二级制字符串:"+binaryStr);输出:hash值:1068708659二级制字符串:111111101100110011001100110011//采用JDK中float的hashcode方法:Float f = new Float(1.4f);System.out.println(f.hashCode());输出:1068708659
3.Long及Double类型hash值:高32位与低32为进行异或运算
由于Long本身就是整数,但它是64位,固只能取其中32位。如果只取前32 或后32 位,并不能保证其高低位的特征,并且容易导致hash值高位或低位相同的不同key发生hash冲突。
问题在于如何最大化的保证其特征,降低冲突。JDK中的做法是将高低位进行异或运算 (HashMap的扰动函数做法相同)
Float类型public static int hashCode(long value) { //无符号右移再与原值进行异或运算 return (int)(value ^ (value >>> 32));}
Double类型public static int hashCode(double value) { long bits = doubleToLongBits(value); return (int)(bits ^ (bits >>> 32));}
计算:
为什么采用异或而不是其他的位运算?
如果用与运算:如果高32位全是1,那么结果就是低32位,无法保证高低位的特征。
同理如果用或运算:若高32位全是1,结果就是高32位,同样无法保证高低位的特征。
但采用异或运算便可以最大化的保证让高低32位都参与运算,保证特征,降低hash冲突。
4.String类型hash值,每一位char都与31相乘并循环累加。
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h;}
素数的魔力
为什么采用31,31是一个奇素数?
参考这篇文章:
https://www.cnblogs.com/nullllun/p/8350178.html#autoid-3-1-0
大致意思:选择数字31是因为它是一个奇质数,如果选择一个偶数会在乘法运算中产生溢出,导致数值信息丢失,因为乘二相当于移位运算。选择质数的优势并不是特别的明显,但这是一个传统。同时,数字31有一个很好的特性,即乘法运算可以被移位和减法运算取代,来获取更好的性能:31 * i == (i << 5) - i,现代的 Java 虚拟机可以自动的完成这个优化。
5.Boolean: true为1231,false为1237,并没有什么好说的。
public static int hashCode(boolean value) { return value ? 1231 : 1237;}
6.Object:与内存地址有关
public native int hashCode();
7.自定义对象hash值
如果没有覆盖hashcode方法,则使用内存地址进行运算获得hash值;
public class Student { private int age; private String name; private float height; public Student(String name, int age, float height) { this.age = age; this.name = name; this.height = height; } @Override public int hashCode() { int ageHash = Integer.hashCode(age); int heightHash = Float.hashCode(height); int nameHash = (name == null || name == "") ? 0 : name.hashCode(); //利用String的hash值计算的特性; int result = ageHash * 31 + heightHash; result = result*31+nameHash; return result; }}
三、Hash冲突常见的解决方案
Hash冲突的常见处理方式:
1.链地址法 HashMap,ConcurrentHashMap
比如通过链表将同一个桶位置的元素连接起来
2.开放地址法
按照一定规则向其他地址探测,直到遇到空桶。
3.再Hash法 ThreadLocalMap中采用
设计多个hash函数
四、hashcode和equals
hash值一样,一定位于同一个bucket中,用链表;
hash值不一样,有可能与运算得到的索引一样,位于同一个bucket;
hashcode使用:定位索引
equals:索引相同,比较对象是否相同
不实现equals方法,对象默认比较内存地址
不实现hashcode,对象默认比较内存地址
Person p1 = new Person(13,170,"老大");Person p2 = new Person(13,170,"老大");
(1)只实现equals,不实现hashcode
p1和p2的hashcode基于内存计算一定不一样,但他们的hash值对数值与运算后的所有可能一样,可能不一样;
如果索引一样,equals比较后发现是同一个对象,则覆盖;size 为1;
如果索引不一样,则放在不同的bucket;size为2;
(2)只实现hashcode,不实现equals
p1和p2的hash值一样;对数值长度进行与运算后获得索引一样;放在同一个bucket;size=1;
默认equals方法比较内存地址,发现地址不同,p2放在p1后面。size =1;
(3)同时实现hashcode和equals的场景
如果认定对象中成员变量相同就为同一个key,则必须同时实现hashcode和equals方法;
五、Hash表容量的设计原则
Hash表中影响效率的最主要两个参数就是容量以及负载因子,重点探讨这两个参数的设计原则。
Hash表用来保存数据,首先想到的肯定是数据应该被放在哪个桶位,如果定位这个桶。
常规思路:
1.先生成key的hash值(注意java里面hash值都是int类型)
2.让这个hash值与数组长度进行取模,生成索引值
public int getIndex(Object key){ return hashcode(key)%table.length;}
1.HashTable采用取模运算的数组长度设计
HashTable的特点:
初始容量为11,扩容算法为 原容量*2+1, 保证数组容量始终都是一个素数或奇数。
为什么要这样设计呢?
先说结论:素数或奇数作为hash表的长度,做取模运算能够使得hash分布更加均匀。
2.HashMap容量设计为何转变为2次幂,以及扰动函数的作用?
(1)容量为2的幂次方原因
权威解答参考连接,HashMap的作者Josh Bloch的回答,总结来看即位运算性能较取模运算更高。
如果采用位运算的话数组长度应该如何设计?为什么要设计为2的幂,如何不设置为2的幂会怎样?
下面结合资料以及自身的思考谈谈我的理解:
假设数组长度是15,对应的二级制为:0000 0000 0000 0000 0000 0001 1110key1的hash值假设为1111 1111 1111 1111 1111 1111 1110与15做与运算结果:0000 0000 0000 0000 0000 0000 1110key2的hash值假设为:1111 1111 1111 1111 1111 1111 1111与15做与运算结果:0000 0000 0000 0000 0000 0000 1110结果相同,造成冲突;究其原因无论key的hash是多少,最后一位始终是0,数组的一半空间被浪费。最佳应是每一位都是1,如下形式1111 1111 1111 1111 1111 1111 1111 思考下,这种形式就是 2的幂次方 -10 2^0-1(这种不用考虑,数组容量设置为0干啥)1 2^1-111 2^2-1111 2^3-11111 2^4-111111 2^5-111...11 2^n-1所以将数组的长度设置为2的幂次方后数组的利用率达到最大。
采用位运算的来计算索引,与(2^n-1)做与运算,结果就是自身,且一定小于 2^n-1:
public int getIndex(Object key){ return hashcode(key)&(table.length-1);}
另外HashMap的容量一般都不会特别大,比如一般不会超过2的16次方,及65536,
假设数组长度为2的10次方,对应的二级制为:0000 0000 0000 0000 0011 1111 1111 1111假设key1的hash值为:1010 0011 0001 1010 1110 1110 0101 0011计算结果:0000 0000 0000 0000 0000 1110 0101 0011可以看到只有最后10位是有效位,参与了运算。这种情况下很多key的hash值后10为可能相同,但是前面22位却可能不同,但他们的索引是相同的,这就造成了冲突。所以需要将key的hash中所有位都能参与运算,最大的保证key的特性。
结论:
1.位运算比求余算法更快。
2.hashcode(key)%table.length 等价于 hashcode(key)&(table.length-1)(数组长度为2次幂)
(2)扰动函数的作用
HashMap使用2的幂作为按位,并且比使用模数更快。但仅仅采用简单hash函数很容易造成hash冲突,原因如下:
key1 = "德玛" 假设其hash值为:0101 1111 0000 1011 0111 0011 1011 0011key2 = "提莫" 假设其hash值为:1100 1011 1010 0000 0100 11111 1011 0011假定数组长度为2的10次方,即1024二级制为:0000 0000 0000 0000 0000 0011 1111 1111计算key1的index:0101 1111 0000 1011 0111 0011 1011 0011 & 0000 0000 0000 0000 0000 0011 1111 1111结果:0000 0000 0000 0000 0000 0011 1011 0011可以看到结果仅仅是保留低10位; 计算key2的index:1100 1011 1010 0000 0100 11111 1011 0011 & 0000 0000 0000 0000 0000 0011 1111 1111结果:0000 0000 0000 0000 0000 0011 1011 0011同样看到结果仅仅是保留低10位;key1和key2的结果是一样的,就造成了hash冲突,原因就在于两个key的高位并没有参与运算,仅仅是保留了低位,所以说模数为2的幂对低位非常敏感。
HashMap增加了扰动函数,让高低16位进行异或运算,最大化的保留了高低位的特征。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
为什么要采用异或运算^,而不是与运算&或者或运算| 呢?上面第二节已说明,不再赘述。
六、负载因子的设计原则
该元素表示数组实际存储元素占据数组容量比例,若该比例达到负载因子,则进行扩容。
负载因子多少是合适的呢?
看HashMap和HashTable源码的doc解释,面试的时候回答这些应该够用了,
通常,默认负载因子(.75)在时间和空间成本之间提供了一个很好的权衡。较高的值会减少空间开销,但会增加查找成本(在HashMap类的大多数操作中都得到体现,包括get和put)。设置其初始容量时,应考虑映射中的预期条目数及其负载因子,以最大程度地减少重新哈希操作的次数。如果初始容量大于最大条目数除以负载因子,则将不会进行任何哈希操作。
其实这个数字的选择在其他语言中并不是同一的,比如 Java 是 0.75,Go 中是 0.65,Dart 中是0.8,python 中是0.762,不同场景也会不同,但java设计的思路肯定是保证通用性。
实际上,根据我的计算,“完美”的负载系数更接近对数2(〜0.7)。尽管任何小于此的负载因子都会产生更好的性能。我认为.75可能已被取消。
证明:
通过预测存储桶是否为空,可以避免链接并利用分支预测。如果存储桶为空的可能性超过0.5,则该存储桶可能为空。
让s代表大小,n代表增加的键数。使用二项式定理,存储桶为空的概率为:
P(0) = C(n, 0) * (1/s)^0 * (1 - 1/s)^(n - 0)
因此,如果少于
log(2)/log(s/(s - 1)) keys
当s达到无穷大并且如果添加的键数使得P(0)= .5时,则n / s迅速接近log(2):
lim (log(2)/log(s/(s - 1)))/s as s -> infinity = log(2) ~ 0.693...
七、谈一谈你了解的java中Map实现及异同
JDK中有哪些Map?
HashMap
LinkedHashMap(经常会问到手写LRU)
TreeMap (一致性hash的实现)
WeakHashMap
IdentityHashMap
ThreadLocalMap(ThreadLocal的实现)
ConcurrentHashMap
后面会对这些Map逐一进行比较分析。
引用资料:
1、HashMap为什么需要更好的hashcode算法?
https://www.javaspecialists.eu/archive/Issue054-HashMap-Requires-a-Better-hashCode---JDK-1.4-Part-II.html
2、为什么hash函数采用素数作为容量更好?
https://stackoverflow.com/questions/1145217/why-should-hash-functions-use-a-prime-number-modulus
3、负载因子的取值原因?
https://stackoverflow.com/questions/10901752/what-is-the-significance-of-load-factor-in-hashmap
https://www.javacodegeeks.com/2015/09/an-introduction-to-optimising-a-hashing-strategy.html