HashMap原理(一)

目录

  • HashMap的底层数据结构
  • 为什么默认初始大小为16以及为什么默认加载因子为0.75
  • 为什么MashMap的容量是2的N次幂
  • HashMap的hash值是怎么算出来的,为什么这么算
  • HashMap的put()过程

 

一、HashMap的底层数据结构

HashMap的底层数据结构是数组+单向链表+红黑树(jdk1.8及以后版本才有红黑树结构)。

二、为什么默认初始大小为16以及为什么默认加载因子为0.75

默认初始大小为16以及默认加载因子是0.75,它俩都是一个经验值,是在时间和空间上权衡的一种最佳实现。首先默认初始大小为16而不是2、4、8、32等等,是因为如果太小那么会频繁地扩容,扩容涉及开辟新的内存空间以及新旧数组、链表和红黑树的数据迁移,时间上比较耗时;如果初始大小太大势必会造成空间的浪费(如果实际装不了这么多元素的话),所以基于时间和空间的考量认为16是一个比较适中的值。其次,默认加载因子是0.75也是这个道理。先说一下默认加载因子是干什么用的,它是用来度量HashMap的容量在装到多满时需要进行扩容,那为什么不是0.1、0.2……0.9、1.0呢?HashMap判断什么时候需要扩容有两个关键参数:一个是size,一个是threshold。分别解释一下这个两个概念:size是HashMap实际存储元素的个数,是数组元素个数+链表元素个数+红黑树元素个数的总和,而capacity是数组的长度或者说桶的个数,这是两个概念;threshold是一个阈值,他的计算公式是threshold = capacity * load_factor,当size>threshold的时候就会触发一次扩容操作。如果load_factor太小,就会导致计算后的threshold太小。那么threshold太小会怎样?假如说load_factor为0.1,也就是说capacity使用了10%的时候就要扩容,那么本次扩容后到达新capacity的10%的时候又会进扩容,所以每次都有90%的空间是浪费的,但是这样的好处就是碰撞的概率减小了。相反,load_factor如果太大,比如说1.0,也就是100%,那么当数组空间全部被用满时才扩容(这里的前提是hash的分布是均匀的,因为实际比较的是size与threshold的大小关系,当load_factor的大小为1.0时,那么threshold就等于capacity,当分布绝对均匀时,那么size=capacity就认为数组的所有的桶都已经用完了),这样虽然空间利用率提高了,将空间的浪费降到了最低,但是hash碰撞的概率提高了。这里说一点有关hash的题外话:

(1)什么是hash函数?在非哈希表中,元素本身和该元素在表里面的位置是没有关系的;而在哈希表中,元素本身以及它在哈希表中的位置是有关系的,这种关系是用hash函数来描述的:y=f(x)=H(x),x为存放在hash表中某一个桶里的key,H是hash函数,最终结果y描述的是key本身和key在hash表中位置的一种对应关系。

(2)什么是hash值?hash值是将任意长度的输入值转换为指定长度的int数字,相当于将任意大小的容量压缩成指定容量大小,这样势必会导致同一个hash值会对应多个不同的原输入值。

(3)什么是hash碰撞?hash碰撞说白了就是不同的原输入值经过hash函数的计算最终得到的是同一个hash值。

(4)hash碰撞取决于什么因素?hash碰撞取决于两个因素:

a)hash表实际存放的元素的个数比上hash表的长度,即a=n/m,n是hash表实际存放的元素的个数,m是hash表的长度。当a比较大时,也就是n比较大(这里当m是个定值),说明hash表的空间利用率比较大,但是hash碰撞的概率也比较大;当a比较小时,也就是n比较小,说明hash表的空间利用率比较小,但是hash碰撞的概率也随之减小。在“hash表的空间利用率”和“hash碰撞的概率”之间做一个权衡,最佳实践是范围最好落在0.6-0.9之间,那么Java在这里给出的值是0.75。

(5)如何解决hash碰撞?有四种方式:

具体可以参考:https://www.cnblogs.com/wuchaodzxx/p/7396599.html

这篇文章只讲了三种方式,第四种方式是建立一个公共溢出区域,就是把冲突的元素都放在另一个地方,不在hash表里面。那么在Java的HashMap中用的是拉链法,也就是为什么HashMap的数据结构会用到单向链表,实际是解决hash碰撞的一种手段。

三、为什么MashMap的容量是2的N次幂

我们在看HashMap的源码的时候会看到这样一句注释:

为什么官方要求HashMap的数组长度必须为2的n次幂呢?

在讲这个问题之前先说一个预备知识点,就是Java的位运算。Java的位运算有与运算(&)、或运算(|)、非运算(~)、异或运算(^)、左位移运算(<<)、右位移运算(>>)和无符号右位移运算(>>>)。下面简单介绍一下以上各种位运算(详细介绍请参考其他资料):

(1)&:两个操作数对应位上都为1则结果为1

(2)|:两个操作数对应位上都为0则结果为0

(3)~:将操作数每一位取反

(4)^ :两个操作数对应位上不一样则结果为1(异为1)

(5)<<:将二进制数向左移动指定位数,低位补0,高位截断

(6)>>:将二进制数向右移动指定位数,高位补充符号位,低位截断

(7)>>>:将二进制数向右移动指定位数,高位补0,低位截断

这里说一下HashMap是怎么定位到某一个具体的元素(节点,数据被封装进Node对象里面,后面会介绍)的呢?首先是用hash算法定位到被查找的元素在数组的哪一个桶里,然后再遍历该桶上的链表。为什么要先用hash算法来定位桶呢?这又涉及到一个跟本文主题关系不大的另外一个知识点——hash算法的优势。由于元素本身和元素在hash表中的位置存在一种函数关系,那么用hash算法可以快速定位到某一元素在hash表中的位置。如果没有hash算法,那么要定位hash表中的某一个元素,需要一个一个地遍历每一个桶,直到找到为止,那么时间复杂度很大程度上取决于遍历的次数。好,回到正题,假如现在让你设计一个HashMap,你怎么让key的hash值分布在0~数组长度之内?我们的第一反应应该是让H(key)对数组长度取模,模值就是数组的下标,即index = H(key) % length。前面已经说过hash算法是将任意输入值转换为固定长度的int值,其实我们并不需要关心hash算法是怎么做到的,我们需要关心的是如何利用这个hash值计算出的索引落在0~数组长度的范围内以及如何尽量保证分布式均匀的。假如数组长度是16,如果通过hash值计算出的索引下标值为17,那明显这个结果是无意义的。刚才说的方法 H(key) % length确实可以达到目的,但是jdk的作者出于效率的考虑决定采用二进制移位的方式来达到相同的目的。因为取模运算需要和十进制进行转换,而移位操作是直接操作二进制数,效率要高于取模运算。那么jdk的作者是通过什么方法来达到目的的呢?

答案就是tab[i = (n - 1) & hash。hash是通过HashMap的一个混淆hash算法算出来的,后面会单独介绍在HashMap中hash值的计算方式。我们把2的N次幂转换成二进制可以发现:第N+1位是1,后面全是0,比如16的二进制是0001 0000。源码里的n是什么?n是数组的长度或者说是hash表中桶的个数,那么如果n是2的N次幂,那么n - 1也就是2^N - 1,转换成二进制就是第N位及其后面的值全是1,如15的二进制表示为0000 1111。下面的内容是关键:一个数X与二进制位全是1的数进行&运算,结果为Y,那么最终的运算的结果和X相等,即Y=X,而且由于是与2^N - 1进行&运算,那么运算结果(数组下标)最大就是2^N - 1,而2^N是数组长度,说明最终计算得到的下标会落在0~数组长度之内而不会越界。这里的X带入到源码里就是上面红框框出来的hash。说明什么?说明最终计算出的索引下标index的值只取决于hash值本身,进一步说,index分布是不是均匀取决于hash值本身是不是均匀,厉害就厉害在这里!只要保证hash值本身的分布是均匀的,那么index的分布就是均匀的,hash碰撞的机率就会减少,这里不得不佩服HashMap的设计者是如此地高明!666!那么为了让hash值分布更加均匀,HashMap自己定义了一个hash算法,下一个主题会详细分析这个算法为什么会使散列的分布更加均匀,这里暂且略过。那么为什么MashMap的容量必须是2的n次幂呢?我们来反推,要想达到前面讲的Y=X的效果,那么必须让hash值与一个二进制位全是1的数进行&运算,那么什么样的数二进制位全是1呢?我们来推导一下:

  • 如果二进制只有一个有效位,而且是1,即0000 0001,转换成十进制是1,即2^1 - 1
  • 如果二进制有两个有效位,而且都是1,即0000 0011,转换成十进制是3,即2^2 - 1
  • 如果二进制有三个有效位,而且都是1,即0000 0111,转换成十进制是7,即2^3 - 1
  • 如果二进制有四个有效位,而且都是1,即0000 1111,转换成十进制是15,即2^4 - 1
  • ……
  • 如果二进制有十个有效位,而且都是1,即0011 1111 1111,转换成十进制是1023,即2^10 - 1

所以我们发现,二进制位全是1的数转换成十进制是2^N - 1,将这个结论带入到源码中就是截图里面的n - 1,而n就是数组长度。这就彻底解释了为什么官方要求数组的长度必须是power of two(2的N次幂)。也通过前面的分析,我们可以知道其实(n - 1) & hash=H(key) % length,和取模运算是一个效果但效率比它高。

四、HashMap的hash值是怎么算出来的,为什么这么算

上一个主题分析了为什么MashMap的容量是2的N次幂,最终得出了一个结论:通过(n - 1) & hash算出来的数组下标是否均匀直接取决于hash值本身是否均匀,这个主题就是来分析jdk的作者是如何让hash值分布得更加均匀的。

在HashMap的源码里,put方法是这样的:

在HashMap里自定义了一个hash方法,我们来看一下这个方法:

这个代码只有两行,在第二行里有两个关键的地方:一个是异或运算(^),一个是无符号右移(>>>)16位。(对于不熟悉位运算操作的朋友可以看一下我写的上一个主题,里面做了简单介绍,由于这些知识不是本篇文章的主题,所以一带而过,想要了解更详细的内容请参考其他资料,致歉!)

这里的重点在于三元运算符的后面,首先通过key的hashcode()方法获得了一个int值也就是hash值,并且赋值给了变量h,那为什么用h和它自己右移16位后的结果进行异或(^)运算呢?所有的这些都是基于一个假设,这个假设不是jdk作者凭空想出来的,估计也是根据大量的实践统计出来的,什么假设呢?那就是HashMap实际存放的元素个数,也就是size的值,(注意我不是口吃……)在绝绝绝绝绝绝绝大多数的场景里不会超过65536,也就是2的16次幂,以下的分析也是基于这个假设来进行的。2的16次幂,对于32位操作系统来说,第17位是1后面全是0,当然前面也会补0,即0000 0000 0000 0001 |0000 0000 0000 0000,注意这里我在第16位和17位中间加了一个分隔符(|)目的是为了方便描述,这样就将这32位分为高16位和低16位。这个假设是:大多数场景都会小于这个值,也就是高16位全是0,低16位任意。(敲黑板,划重点!!!)我们再回顾一下数组下标的计算过程:(n - 1) & hash,可以看到最终是跟hash值做&运算的。那么既然高16位全是0,那么最终的&运算的结果高16位也全是0,也就是hash值的高16位压根儿就不起作用,也就是实际参与数组下标运算的只有hash值的低16位。在上一个主题也分析了最终index的值是不是均匀直接取决于hash值本身是不是均匀,那如何让hash值本身分布得更加均匀呢?要说jdk的作者就是高明,不佩服不行!如何充分利用hash值的高16位而不浪费掉又要使最终的散列值分布更加均匀同时还要保证性能呢?那就是无符号右移(>>>)16位,使hash值自己的高16位和自己的低16位进行异或运算,这样既可以使高16位参与到运算中来而不是到一边去休息,又由于是直接操作二进制数所以性能也是杠杠的。但是无符号右移16位只是让高16位参与到运算中来,不能保证计算的最终结果会比“让高16位休息”的计算结果分布更加均匀,使分布更加均匀的手段是异或(^)运算,即(高16位)^ (低16位)。因为与运算(&)会使最终的结果偏1,或运算(|)运算会使最终的结果偏0,只有异或运算(^)会使最终结果分布更加均匀,所以(h = key.hashCode()) ^ (h >>> 16)就是这么来的。

五、HashMap的put()过程

在介绍HashMap的put过程之前,先介绍下HashMap的体系结构。

在java.util.HashMap类中比较重要的元素有这么几个:

DEFAULT_INITIAL_CAPACITY初始化默认数组长度,值为16

MAXIMUM_CAPACITY:数组最大长度,值为Integer的最大值

DEFAULT_LOAD_FACTOR:默认加载因子,值为0.75

TREEIFY_THRESHOLD:由单向链表转为红黑树的阈值,值为8

UNTREEIFY_THRESHOLD:有红黑树转为单向链表的阈值,值为6

table:哈希表,就是实际存放数据的数组,我们说的HashMap底层数据结构是数组+单项链表+红黑树,其中的数组就是这个table

entrySet:这个不用说,是存放键值对的集合

size:HashMap中存放数据的总个数,注意区别数组的长度,capacity是数组的长度

modCount:修改次数,每做一次增删改修改次数就会+1,此变量只增不减

threshold:扩容阈值,当size > threshold时引发HashMap扩容

loadFactor:用户自定义加载因子

还有一个静态内部类很重要:

这个静态内部类Node实现了Map接口的内部接口Entry,而Entry是一个键值对的数据结构。在Node(以下称为节点)中,存储了key的hash值,key值,value值,和该节点的下一个节点(单向链表)。

还有几个构造方法,其中有两个比较有代表性:

先说第二个,第二个是无参构造方法,这里面只干了一件事,那就是把默认加载因子复制给自定义加载因子变量,其它的什么都没做。也许你会问:你指望它还做什么?其实有个非常重要的事情没有做:那就是最最最最最重要的用于存放数据的数组没有初始化,也就是说当你new了一个HashMap时,用于存放数据的数组居然还是null!再来看截图里面第一个构造方法,它传入一个用户指定大小的容量,也就是数组长度。在上面的专题里已经分析了,jdk官方声明数组的长度MUST be a power of two,那如果用户传进去的不是2的N次幂怎么办,比如传一个13,传一个100。呵呵,jdk作者早替你想好了,如果传进去的不是2^N,那么它会进行一系列的转换,最终将数组长度转换为比用户传进来的长度大的最小的2的N次幂,比如比13大的最小的2的N次幂是16,那么最终数组的初始大小为16。整个的转换过程比较复杂,看其源码的话是下面这一段:

好,以上都是本主题的铺垫,旨在让这篇文章的读者对HashMap的类结构有一个大致的了解。下面回到主题上来,说一下整个put的过程。

put方法会先计算key的hash值,前面已经分析过,这里就不再分析了,然后调用putVal方法。我们来看一下putVal方法。

我用不同的颜色将putVal方法分成了4个大的区域,起哄黄色的第三个区域又细分为三个小区域。我们一个一个来看。先看第一个区域。前面我埋下了一个伏笔,就是在调用HashMap默认构造器的时候并没有初始化数组,其实其它构造器也没有对数组初始化,也就是说你调用任何一个HashMap的的构造函数都不会导致数组的初始化。那什么时候初始化的呢?不初始化不行啊,数组是HashMap最核心的数据结构啊,单向链表和红黑树都是挂在数组上面的啊,数组为null怎么能行呢?其实数组的初始话是在第一次调用put的时候进行的,也就是在区域一中的resize()方法。这种思想就是延迟加载的思想。后面会有一个单独的专题来讲解HashMap是怎么扩容的,然后会带着大家分析resize()的源码,这里就知道它是一个扩容方法就可以了。区域一的代码主要就是判断是不是第一次调用put方法,如果是(table为空),那么就需要初始化数组并给数组一个默认初始大小或者一个经转换后的用户指定大小。然后将数组的长度赋给变量n。区域一结束,下面是区域二。

在区域二中,有一段上面已经大篇幅分析过的代码:i = (n - 1) & hash,这里就不再赘述,可以查看上面的专题。这里要说的是第630行的if判断条件是什么意思。经过复杂的运算得出数组的下标并赋值给变量i,tab[i]表示数组的第i个元素,判断它是不是空,如果是空说明该位置没有任何元素,那么就新建一个Node节点房改该数组的位置上。如果不为空,则进入区域三。

我们来看区域3.1。进入了区域3.1说明什么?说明下表为i的数组位置已经有一个节点了,换句话说hash碰撞了对不对?前面已经分析过HashMap解决hash碰撞的方式采用的是拉链法,也就是用单向链表的方式来解决散列冲突。那既然桶位i的位置有一个节点了,把要插入的新节点挂在老节点的下面就行了。不错,但先别着急,我们先看一下新老节点的key是不是相等,比较的方式是比较Node节点里面存储的key的hash值和要插入的新节点的hash值是不是一样,并且两者的key是不是equals。如果是相同的,就把老节点赋值给临时变量e,然后就到了代码554行,用新value来替换老的value,这也就是在我们实际使用的过程当中如果key一样就会用新的K-V来替换老的K-V的原因。如果534行的判断不通过,索命不存在一样的key的节点,救会进入到代码区域3.2或者3.3。

如果key不一样,那么就要把新节点挂到桶位为i的位置上,但是这涉及到一个问题:这个位置上的数据结构到底是单向量表还是一个红黑树?所以还需要根据类型来判断从而采用不同的插入方式。如果类行为红黑树,就按照红黑树的方式来插入数据(这要有红黑树的知识),也就是代码区域3.2,否则就是单向链表,代码区域3.3。但先链表的插入规则是判断每一个节点的next节点是不是空,如果为空说明在插入前该节点是单向链表的尾节点,否则就顺着每个节点的next节点往下遍历直到为空时就挂在后面。最后没有被框出来的是代码区域4。这里主要是改变修改次数以防止多线程环境下出现问题,还有就是判断是不是要扩容。整个put方法结束。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章