哈希表
对于HashMap,我们首先来分析一下它的底层结构到底是怎么样的,众所周知,HashMap是用来存放键值对的,所谓键值对,也就是一个键对应一个值,HashMap中将一个键值对作为一个对象存放在容器中。而且HashMap中允许key值为null,也允许value值为null,表面上允许key值重复,但是当key值的hash值,本身值以及内存地址hash值一样的话,后一个键值对就会覆盖前一个键值对,以确保每一次查找时每一个key都对应着唯一的value。这些特点在之后的源码中再细说,那么存放这些键值对的究竟是什么数据结构呢?
答案是数组,但又不完全是数组,是经过改造的数组,我们知道,当数据存放到数组中后,再想查找数组中某一个数的话,倘若知道其索引,那么时间复杂度为O(1),只需要一步就够了,但是不知道索引,只知道要的内容,那么时间复杂度基本在O(log2n)到O(n^2)之间了。那么有没有什么方法让时间复杂度固定在O(1)左右呢?有,答案是哈希表(hash table 又称散列表)。这也是HashMap的核心数据结构之一,不然为什么叫做HashMap,OK,我们现在开始来研究一下哈希表到底是个什么东西。
哈希表是根据关键码值(key)而直接进行访问的数据结构,也就是说,它通过把关键码值映射到表中的一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
简单来说散列函数就是一个处理方法,而面对一个传进来的键值对,我们先将键(key)通过散列函数这个处理方法获得一个数组索引,当然这个索引肯定要在【0,array.length -1】这个区间内的,然后通过索引将键值对(key-value)放进数组中。这样的话,你插入一个键值对只需要通过将键通过散列函数进行处理获得索引,再将键值对插进相应的数组内就够了。而你想查找一个键值对的话,先将键通过散列函数进行处理获得索引,再通过索引直接就可以找到数组中的键值对。
假设我们需要将下图的键值对存储起来:
那么先创建一个数组:
先设置散列函数为index = key % (array.length - 1)【为什么是数组长度减一自己想想】
ok,现在先将第一个键值对放进去,key为34,则index = 34 % (15) = 4,所以索引为4,将第一个键值对放入索引为4的数组槽中。
按照这样的方法依次将其他的键值对放进去:
现在开始来查找,倘若我要查key为41的键值对,那么只需要将key通过散列函数获得index = 41 % (15) = 11,那么寻找索引为11的数组槽,获得键值对41-'i'。时间复杂度为O(1),同理,在哈希表中增加与删除的时间复杂度也是O(1)。
那么我们来看一看HashMap中的哈希表是如何的,它的散列函数是怎么定义的:
public V put(K key, V value){
//向HashMap加入键值对
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict){
if((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash,key,value,null);
}
static final int hash(Object key){
//设定哈希值,判断键是否为空,如果为空,哈希值为0
//否则哈希值为键的哈希值与键的哈希值无符号右移16位的值
//异或得到的结果
int h;
return (key == null) ? 0 : (h == key.hashCode()) ^ (h >>> 16);
}
public final int hashCode(){
//设定结点的哈希值
return Objects.hashCode(key) ^ Objects.hashCode(value);
//返回键的哈希值和值的哈希值异或等到的结果
}
上面代码是从HashMap中截取的一部分增加键值对的代码,我们来分析一下,首先put调用putVal方法,putVal方法中有一段
tab[i = (n - 1 & hash)]与tab[i] = newNode(hash,key,value,null)代码,这两句的意思即index = (n - 1 & hash)获得索引,再将新结点放入数组槽tab[index]中。与我们上面讲的方法不一样,其中hash是什么呢,继续深入代码,发现这个hash值其实是通过调用hash方法对key值进行处理的,而hash方法又怎么处理呢,它调用hashCode方法对键值对中的key和value分别获得哈希地址,再将两个哈希地址进行异或获得结果。
有些绕,上图:
所以最底层其实是调用Objects.hashCode()方法获得key和value的哈希值,那么这个哈希值又是什么呢?哈希值又称作散列值,是hashCode方法根据一定的规则将与对象相关的信息(比如对象的存储地址,对象的字段等)映射成的一个数值。因此这个值是和对象有关系的。因此根据上图由下往上分析,首先获得key和value的哈希值,再将两者进行异或(即二进制每一位分别相加,但是不进位,详情请移步位运算浅析),我的理解是将两者的哈希值杂揉在一起,尽可能避免重复(当然不可能完全规避)。
而获得的key.hashCode()值与自己的无符号右移16位获得的值再进行异或。众所周知,java中的哈希值是int类型的值,而int是用32位二进制表示的,所以无符号向右移动16位相当于把前面一般的二进制移动到后面来了,前面那一半用0补充。再将其与原值进行异或运算,这相当于把高位与低位杂揉在一起,增加随机性,尽可能规避重复。
经过上面两轮处理,所获得的就是该键值对的hash值了,那么这个(n - 1) & hash又是什么意思呢?和我们上面采取的取余方法有什么不同呢?
我们来举几个例子,首先15的二进制表示方式为:
0000 0000 0000 0000 0000 0000 0000 1111
我们先用34与15进行与运算:
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0010 0010
0000 0000 0000 0000 0000 0000 0000 0010
结果为2.
再用35与15进行与运算:
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0010 0011
0000 0000 0000 0000 0000 0000 0000 0011
结果为3.
再用41与15进行与运算:
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0010 1001
0000 0000 0000 0000 0000 0000 0000 1001
结果为9.
经过上面几次运算应该发现了一些规律,无论用什么数与15进行与运算,结果只会取后四位数,前面都归0了,而后四位最大值也就是15,所以说与运算有时候也类似于我们开始采用的取余运算。但是为什么要说有时候呢?我们来试一下用14和传入值进行与运算会如何。
用34与14进行与运算:
0000 0000 0000 0000 0000 0000 0000 1110
0000 0000 0000 0000 0000 0000 0010 0010
0000 0000 0000 0000 0000 0000 0000 0010
结果为2.
用35与14进行与运算:
0000 0000 0000 0000 0000 0000 0000 1110
0000 0000 0000 0000 0000 0000 0010 0011
0000 0000 0000 0000 0000 0000 0000 0010
结果还是2.
这就表示将34和35放入哈希表中,他们获得的索引都会指向2,也就是所谓的哈希碰撞,究其原因,是因为14的最后一位2进制是0,无论与谁相与都会是0,那这一位就没有什么用了,也模仿不了取余运算。那究竟什么样的数和其他数进行与运算最能模仿取余运算呢?观察14和15两个数字的二进制表示就能略知一二了,就是要让低位都是1,比如1,3,7,15,31这样的数,你可以分别将他们的二进制表示写出来观察观察。就是这个原因,HashMap中定义数组初始容量正是16,每一次默认扩容都是扩为原来的两倍,而哈希定位时都是用数组长度-1和其他键值对的哈希值进行与运算,这样的与运算和取模运算类似,而且比取模运算效率高。
接下来我们再进行一个实验,将hash值为50的键值对放入哈希表中:
用50与15进行与运算:
0000 0000 0000 0000 0000 0000 0000 1111
0000 0000 0000 0000 0000 0000 0011 0010
0000 0000 0000 0000 0000 0000 0000 0010
结果为2.
现在我们用的是15对hash值进行与运算的,那么获得的值还是2,但是哈希表内索引为2的位置已经放了哈希值为34的键值对,还是发生了哈希碰撞了,怎么办呢?
主流的解决方案有三种:
1.开放地址法。开放地址法的基本思想是当发生地址冲突时,按照另外一种方法继续探测哈希表中的其他存储单元直到找到空位置为止;
2.rehash法。基本思想是使用第二个或第三个...计算地址,直到无冲突,比如首字母进行hash冲突了,则按照首字母第二位进行hash寻址。
此处就不再详细介绍这两种方法了,有兴趣的可以百度或google。而java 1.7及以前HashMap中采取的方法是链地址法(拉链法)。java 1.8采用的是链地址法+红黑树。我们首先来介绍一下链地址法究竟是什么。
3.链地址法。所谓链地址法就是建立一个链表,如果发生哈希碰撞了,则将该结点(键值对)和槽内原来的结点组成一个双向链表,槽内放置链表的首结点,首结点的后置指针指向下一个结点,依次往外延伸。如果想要寻找某个键值对,那先根据它的key获得相应的hash值,再通过hash值与(n - 1)相与,n就是当前哈希表的长度,相与会获得一个索引,根据索引找到对应的表槽。但是现在就不是直接取出槽内键值对了,因为你不能保证槽内是否建立了链表,放在槽内的是否只是单独键值对还是双向链表中的首结点。
那么如何处理呢,先比较hash值和槽内键值对的hash值是否一样(50与34对应的索引都是2),如果一样再比较key的值是否一样,如果都一样则说明槽内键值对就是你要找的那个键值对,不一样则先判断槽内的键值对所在的结点是否有下一个结点,也就是说是否组成链表,如果有下一个结点,则依次遍历链表,寻找hash值和key值都一样的结点。
此时哈希表的结构如下图所示:
为方便起见,假设key值和hash值相等,但实际上是不一样的,key是一个对象,而hash值是一个int类型的整型数
这就是java 1.7以前的HashMap的底层实现,采用哈希表加链地址法对键值对进行存储,但是我们知道链表虽然插入与删除速度很快,但是这是牺牲查找速度才达到的,如果一个哈希表槽内放置的一个双向链表表头所在的链表很长很长,那么需要查找该链表上的键值对的时间复杂度会呈线性增加。那么如何避免这种情况的发生呢,自从java 1.8HashMap就引进了红黑树这个数据结构,当链表长度达到一定阈值就会在链表上建树,从而大大提高查询速度,至于红黑树是什么,又是如何建树的,我们下一篇再继续讲解。
参考材料:
https://blog.youkuaiyun.com/yyyljw/article/details/80903391
https://blog.youkuaiyun.com/dazhu233/article/details/79678232
https://blog.youkuaiyun.com/zlts000/article/details/51942352
https://blog.youkuaiyun.com/tectrol/article/details/80646966