一个HashMap有啥好杠的

大家好,我是哪吒,本期文章给大家讲讲HashMap的底层原理以及HashMap中涉及到的一些小的细节

初始化

如果要说到HashMap的底层数据结构,我相信大家应该都能说出来–数组加链表,HashMap的底层数据结构在JDK1.7与JDK1.8的时候是有很大区别的,在JDK1.7的时候结构是数据加链表 , 但在JDK1.8除了数组加链表外,还引入了红黑树,除了这两者的区别以外,还有其底层的hash值的计算也有区别

当我们在了解HashMap的时候,我们首先就是要去了解它的初始化的过程,我们在使用HashMap(JDK1.8)的时候,一般就是简单的去put一个值

比如一个很简单的一个例子

第一次put值

当然除了String类型的,你还可以使用int,long甚至直接用一个对象,我这边就直接用一个简单的String类型

当我们在调用put方法的时候,其实put方法里面还调用了另外的一个方法,我们点击进去可以看到

put方法内部调用

内心OS:这哪是方法调用啊,这分明就是在套娃

可以看到其put方法内部直接调用了putVal方法,那这个方法就是在我们put值的时候,会去创建一个新的数组,然后去初始化大小,还有阈值等信息

第一次调用会直接走第一个if的逻辑

当我们第一次put(“a”,“张三”)这个值的时候会直接走第一个if的逻辑,也就是说刚开始的时候成员变量table是为null

这里的成员变量table就是数据结构中的数组了,而且是一个Node对象类型的数组

所以我们在刚开始 new 一个 HashMap 对象的时候,数组并还没有创建出来,而是在我们第一次put值的时候才去创建数组

紧接着就会调用resize()方法,这个方法主要是做两个事情,一个是初始化一个数组并设置数组的长度以及扩容的阈值,二个是扩容

初始化调用resize方法

当第一次进入到这个方法里面来的时候,变量oldTab(数组)是null 、odlCap(数组的长度)为0 、oldhr(阈值)0,那么接下来就是HashMap要去初始化这些值了

第一次初始化扩容的阈值和数组长度

可以看到Hash给数组的默认长度是16,阈值是直接用 数组长度 * 0.75 算出来的

可以看到 JDK1.8 中的HashMap是在第一次 put 值得时候才会去初始化,有点类似于懒加载,这也是与 JDK1.7 的不同之处

那我们接着往下走,看看还做了哪些的初始化

初始化数组

可以看到,当走到这一步的时候,直接 new Node[newCap] 数组,并将数组的长度设置为默认的长度16,然后最终将对象传递给了成员变量 table,数组才真正的初始化出来并把数组返回,至此完成了所有的初始化

当我们继续往下走的时候就会走到这段代码

根据hash值计算存放的位置

它这边会直接根据hash值和数组的长度算出应存放在数组具体的位置,如果这个位置为 null 则直接将 计算出来的hashkeyvalue 封装成Node对象然后存放到数组对应的位置,存放完之后,第一次存放数据的逻辑就完成了

到最后我们再来看一个有意思的东西

初始化完之后modCount变量会进行一次递增

我们看到都走完所有逻辑之后会 modCount变量进行一次递增,那么这个modCount变量到底是干啥的呢

其实这也是为了安全考虑而设计只用机制–Fail-Fast机制

我们都知道HashMap是一个线程不安全的类,这个机制主要体现在迭代器中,当有一个线程在循环HapMap里面的数据的时候,如果恰好另外的一个线程操作了这个HashMap , 那么就会抛出ConcurrentModificationException 的异常

内部迭代安全机制

当这个迭代器初始化的时候,会直接把modCount值赋值给expectedModCount,可以看到HashMap的内部维护的迭代器就去判断了modCount和expectedModCount这两个是不是相等,如果不相等就代表有其他的线程对HashMap做了操作,则直接抛出异常

那说到这里又有读者要杠了

某读者:那我要是不用循环去获取数据,而是直接用get方法去获取数据,是不是这个机制就不管用了?

我:我们在使用get方法去获取数组的时候,由于get方法里面没有去做判断,所以是不会报异常,也就说这个机制也就不管用了,所以我们在使用非线程安全的数据结构的时候,尽量使用迭代器

Hash值的计算

我们上面讲过,HashMap会根据key的Hash值以及数组的长度来计算在数组中存放的位置

HashMap对key进行Hash计算的方法在JDK1.7与JDK1.8有很大的区别,那么这里我们再来分析一下HashMap对hash值的计算,这里我将用分别结合JDK1.7 和 JDK1.8 来分析

在用 JDK1.7 中的HashMap进行put操作的时候,我们可以看到JDK1.7的方法没有了套娃的这种操作,而是直接在put方法里面实现了业务逻辑

JDK1.7是直接在put方法中实现逻辑

可以看到JDK1.7的put方法并没有什么初始化的代码,这是因为初始化的操作在构造方法里面就已经完成了

在构造方法中完成初始化

我们还是用 hash.put(“a”,“张三”) 这个例子来看看 JDK1.7 是如何计算hash值的

直接调用hash方法计算hash值

可以看到,put方法内部直接调用了一个hash()方法来对key去计算hash值的,那我们再来看看这个hash方法里面主要做了啥

变量h与key的hashCode进行异或运算

这边局部变量h被赋予了默认值0 ,然后再与key的hashCode值进行了异或运算,得出结果值 97

进行两次无符号位移和异或运算

然后再用 97 进行了两次无符号位移和异或运算 ,得出hash值103

计算得出最终值

讲到这里,又有读者要杠了

某读者:为什么要进行两次进行异或和无符号位移运算呢

我:这里既然是用key的hashCode来进行运算,那无法避免有负数的情况,假如真的有负数,那计算数组索引的时候那就有问题了,因为数据的索引只能是一个正数,这里无符号位移也是为了这避免出现负数而造成索引位负数的问题,还有就是能够让hash值更散列,这样才能减少hash冲突,从而避免了链表过长

那么算出hash值之后,接下来就是根据hash值和数据的长度算出具体存放的位置了,我们照样可以进入到方法里面看看是怎么算的

计算存放到数组中的具体位置

很简单的进行了一次位与运算得出一个值 7

计算得出存放数组的索引位置

算出这个值之后,接下来的操作简单了,如果这个位置是空的,则直接直接放到这个位置上,如果这个位置被其他的元素占了,则采用头插法的方式形成一个链表

那知道了 JDK1.7 的Hash值如何计算的时候,那我们再来看看 JDK1.8 的Hash值是如何计算的

我们还是用hash.put(“a”,“张三”)这个例子来分析

直接在调用putVal方法

这边put方法内部直接调用了putVal方法,同样也是使用了hash这个方法对key进行hash运算,那我们来看看这个hash方法是怎么对key计算hash值的

计算逻辑很简单,这里是直接把局部变量h进行了一次无符号位移16位得出一个值,然后再用key的hashCode与这个值进行异或运算得出一个值 97

计算得出的最终值 97

这边算数组位置的时候与 JDK1.7 的运算相同,也是使用算出来的hash值与数组的长度进行位与运算

这里的计算数组的索引位置与之前的一样

关于HashMap如何计算Hash值我们只做一个了解就行了, 一般在面试中不会去问这些东西

链表插入的方式

HashMap数据结构是数组加链表(JDK1.7),那为什么会使用到链表呢

我们都知道HashMap是根据key计算出hash值,那这样就不免有hash冲突的情况,那链表就是为了解决这种hash冲突而加上的

那如果真的产生了hash冲突的时候,HashMap是如何将元素插入到链表里面去的呢

在 JDK1.7 的时候是使用的头插法,但在 JDK1.8 之后就改成了尾插法

说到这里又有读者要杠了

某读者:为啥要改成尾插法呢

我:其实头插法的效率比尾插法高,但是因为头插法在多线程的环境下会造成死链问题,所以为了避免这种情况就改用了尾插法的方式

我们先来看看什么是头插法

头插法,顾名思义,就是从链表的头部插入,这样的好处就是在插入的过程中不需要去遍历整个链表,可以很快的将数据插入到链表中

试想一下,假如我们现在有一个链表

数组加链表

链表中已经有了a、b、c这三个元素,但此时 d 很有想法,它也想要插入到这个链表中去,于是就想着怎么插入才是最快的,看到链表的尾部离它太远了,而且还要经过很多的元素,于是就直接从头部插入

直接从头部插入

插完之后,d 元素所处的这个位置应该就成为了头部位置,那为了让 d 元素能够成为头部,我们还需要把整个链表往下移动

从头部插入之后整个链表往下移动

至此整个头插法的过程就完成了

可别小看这么简单的一个数组加链表结构模型,当我们把这个结构模型弄清楚了之后,我们再来看HashMap中的头插法过程就会变得非常简单

那HashMap中的头插法到底是怎么样的呢

因为头插法是在 JDK1.7 版本及之前版本的实现,所以接下来的分析还是基于 JDK1.7 来分析

我们还是以hash.put(“a”,“张三”)为例,当直接new HashMap()的时候会直接初始化一个数组大小,以及阈值(因为JDK1.7是直接在构造方法中初始化的),如下图

初始化后的数组

数组中的深蓝色代表存放的位置,这四个格子分别对应数组下标6、7、8、9、10

HashMap会把键和值封装为 new Entrty(97,“a”,“张三”) 对象

JDK1.7及以前的版本的链表的节点是一个Entrty节点,也就是说会把键和值封装成为一个Entrty对象

我们暂且把这个对象叫做firstEntrty,HashMap用对"a"这个key计算出的hash值与数组的长度位与运算得到索引下标的值假设是7,firstEntrty对象会放在数组下标为 7 的这个位置

将封装好的Entrty对象放到下标为7的位置

放置完之后,如果又put了一个键值对 hash.put(“b”,“李四”),假如HashMap用对"b"这个key计算出的hash值与数组的长度位与运算得到索引下标值假设也是 7 ,那首先会把值封装为 new Entrty(97,“b”,“李四”) 对象,我们暂且将这个对象叫做lastEntrty ,因为下标为7的这个位置已经被firstEntrty这个对象占据,所以lastEntrty这个对象的会直接指向firstEntrty对象

直接从头部插入对象

lastEntrty这个对象从头部链接firstEntrty对象之后,因为要让lastEntrty这个对象处在头部位置,所以HashMap还会将这个链表整体往下移

链表整体往下移动

可以看到移完之后,lastEntrty被移到了firstEntrty 这个位置上,整个头插的过程就完成了

那知道了HashMap的头插的过程我们再来看看源码就很清楚了

HashMap中的源码

这里是直接通过计算得出的索引下标值 7 来获取 firstEntry 对象,获取到firstEntry 对象之后再去新创建一个 lastEntry 对象,lastEntry 对象直接指向 firstEntry对象,再将 lastEntry 对象放到索引下标为 7 的那个位置上,完成下移操作

注意,这里所说的指向 firstEntry 对象其实也是个赋值的操作,看到这行代码 table[bucketIndex] = new Entry<>(hash, key, value, e); 的构造方法里传了个变量e ,而这个 变量e 就是传的需要指向的下一个对象。它这里首先获取到 firstEntry 对象后,直接把这个对象传给了 lastEntry 这个对象的构造方法里的 变量next ,完成指向的操作

其实这里的下移操作非常的简单,就赋个值完事了,因此插入的速度是非常快的

看完头插法的这个过程之后,我们再来看看尾插法是怎么回事

尾插法其实就跟头插法完全相反,头插法直接从头部插入,那尾插法自然就是从尾部插入了,但是这样去插的话会去遍历整个链表,相对来说没有头插法速度快

我们同样还是有这个的一个链表

从尾部插入

此时 e 元素也是个很有想法的,它也想插入到这个链表当中,它想,既然从头部插入有问题,那我就直接从尾部插入吧

直接从尾部插入不需要移动

可以看到当 e 元素通过 d、c、b 这三个元素找到 a 的时候,直接用 a 元素的指针指向了 e 元素,指完之后 e 元素也就变成了尾部,不需要做任何移动,整个尾插法就完成了

那我们再来看看HashMap是如何完成尾插这个过程的,注意这里我的 JDK 版本就要换成 1.8 了 ,因为尾插法是 1.8 之后才有的

我们还是用hash.put(“a”,“张三”)这个例子来分析,当用HashMap第一次put值的时候首先回去初始化一个容量为16的数组,如图

初始化后数组

这里由于长度有限我就只画了4个格子

HashMap会把值封装成 new Node(97,“a”,“张三”) 对象

在 JDK1.8 的时候,链表的节点就换成了 Node 节点,因此它会把键和值封装成为一个 Node 对象

我们暂且把这个对象叫做 firstNode,HashMap根据"a"计算出hash值,然后再用hash值与数组的长度进行位与运算得到数组的下标值假设是 7 ,因此fistNode这个对象会放到数组下标为7的位置

将Node对象放到数组下标为7的这个位置

放置完之后,如果此时又有一个值put进来了hash.put(“b”,“李四”),HashMap会把值封装成 new Node(97,“b”,“李四”),我们暂且把这个对象叫做lastNode,假如HashMap根据"b"计算出hash值,然后再用hash值与数组的长度进行位与运算得到数组的下标值假设也是 7 的时候,会直接将lastNode对象插入到firstNode的尾部,也就是说firstNode的 变量next 会直接指向lastNode对象

firstNode直接指向lastNode

这样形成链表之后,就不需要往下移动了,至此HashMap整个尾插法的过程就完成了

那了解了整个插入的过程之后,我们再来看看源码中是怎么做的

HashMap遍历链表插入

这里直接用了一个循环操作去遍历整个链表,然后获取到最后一个元素并且最后一个元素的 next 为null之后,通过这段代码就知道 p.next = newNode(hash, key, value, null); 直接把最后一个元素的 next 指向新加进来的元素,完成尾插操作

可以看到其实尾插的过程也很简单,也只是一个赋值的操作,但是如果链表过长,你想想,要是这样循环去找到最后一个元素,然后再插入新加进来的元素,这个过程过程相对来说是比较慢的

于是 JDK1.8 为了优化尾插法带来的效率问题,直接引入了 红黑树 ,这就是为什么 JDK1.8 的HashMap数据结构变成了 数组+链表+红黑树

不仅仅是插入的问题,我们在查询的时候,如果链表过长,也是比较影响查询速度的,因为要去循环遍历去查找

当HashMap的链表达到一定的长度 8之后,就会直接转换成红黑树

红黑树转换的阈值

我们看完HashMap的底层数据结构的后,HashMap还有一个很重要的东西,那就是扩容,那这个扩容又是怎么一回事呢,我们继续往下看

扩容

HashMap的扩容阈值值根据数组的长度和负载因子计算出来的 数组的长度 * 0.75,当超过这个阈值之后就会自动进行扩容

那在扩容的时候这里就涉及到一个很重要的方法 resize ,扩容都是通过这个resize方法实现的

扩容方法

在扩容的时候,数组里只有一个元素,也就是说不存在Hash冲突的时候,那在扩容的时候会直接将该元素copy至新数组的对应的位置

不存在冲突时直接copy到新数组对应的位置

哈希桶数组中某个位置的节点为树节点时,则执行红黑树的扩容操作

红黑树扩容

哈希桶数组中某个位置的节点为普通节点时,则执行链表扩容操作,在JDK1.8中,为了避免之前版本中并发扩容所导致的死链问题,引入了高低位链表辅助进行扩容操作

HashMap中的细节

细节一:

我们可以先看 JDK1.7 向数组添加值的方法

JDK1.7添加元素的方法

可以看到在真正在向数组添加元素的时候,会先去判断一下数组是否大于 threshold 这个值,如果大于的话就会调用 resize方法 进行扩容,而且是进行两倍的扩容,如果不需要的话就会直接调用 createEntry方法 添加元素

所以在 JDK1.7 的时候,HashMap会先去判断是否需要进行扩容,然后再添加元素

我们再来看看 JDK1.8 的HashMap

JDK1.8添加元素的方法

我们可以在 putVal方法 里面看到是在添加完元素之后,才去判断是否大于 threshold 这个值,如果大于会直接调用 resize方法 ,如果不大于则直接返回

所以在 JDK1.8 的时候,HashMap会先添加完元素之后,再去判断是否需要进行扩容

这个细节虽然也没啥,但是如果有些比较刁钻的面试官一旦问到了这个细节,你说你在面试官面前说看过源码,很厉害,那不就尴尬了吗,哈哈哈。。。

细节二:

我们上面讲过HashCode的计算通过键的HashCode通过两次位与和一次异或,这样其实是为了让HashCode更散列

我们可以再来看看其中的源码

JDK1.8 hash值的计算

这里 JDK1.7 的hash值计算跟 JDK1.8 的差不多,这里我们就以 JDK1.8 为例

那为什么说进行了位移、异或就是为了让Hash值更散列呢

我们先来看看假如不进行位移、异或会怎样

我们还是以hash.put(“a”,“张三”)为例,假如 a 的 HashCode的二进制是 1000010001110001000001111000000 ,我们的这个HashMap的长度为 16 ,那么在它计算数组的索引时会这样去算 (16 - 1 ) & a.hashCode ** ,可以看到其实真正参与进来运算的长度是15,那对应二进制计算是这样的

此时又添加了一个hash.put(“b”,“李四”),假如b的hashCode是 0111011100111000101000010100000 ,那对应的二进制计算是这样的

可以看到添加进去的两个值计算得出的索引位置都是 0 ,那么这就直接导致了hash冲突

你想想,如果计算出的数组索引位置因为hash冲突都集中在某个位置上,那必然会造成链表的长度过长,最终会导致查询效率慢

假如上面的两个hashCode进行了位移、异或,那结果就不一样了

位移异或再与长度进行位与

所以HashMap就是通过这种位移异或操作打乱真正参与运算的低 16 位,从而避免了上面的情况发生

细节三:

我们知道HashMap在达到扩容阈值的时候会在原来的基础上扩容一倍,也就是说原来长度是16,那么扩容后就是 32 了,我不知道你们有没有发现这一点,就是扩容后的长度都是 2的n次幂,为什么会是这样呢

我们先来看看不是2的n次幂会是什么样子

假如有个HashMap的长度是 33 ,那此时计算索引位置的时候就会用32去计算,32 的二进制是 0010 0000,那么在计算的它的低四位都是 0 ,也就是说无论hashCode怎么变,最后算出来的索引值,它的低四位都是 0 ,这就会导致数组中的某个位置永远都是空的,因为由于低四位都是空,所以计算不到那个索引的位置

而如果长度是2的n次幂,假如长度是16,那么参与运算的长度就是 (16 - 1),就是15了,它的二进制位 0000 1111,可以看到低四位都是 1 ,那么当hashCode发生了变化的时候,数组中的索引位置都有可能被计算得到

其实还是为了能够让节点在数组中能够更散列,更有可能的放到数组中的每个位置,从而达到均匀分布

细节四:

我们都知道HashMap的扩容因子是0.75 ,那为什么是 0.75 呢

加载因子过高,虽然提高了空间的利用率,但增加了查询时间的成本;加载因子过低,虽然减少查询时间的成本,但是空间利用率又很低了,所以0.75是一个折中的选择,这也是符合泊松分布

符合泊松分布,是一种统计与概率学里常见到的离散概率分布

还有一点不知道有没有发现,在进行 & 计算索引位置的时候,会直接用数组的长度 - 1 然后再和hashCode进行 & 的操作

为什么要去还要减1呢,这是因为数组的索引下标是从0开始的,所以减1也是为了避免索引越界

总结

HashMap在JDK1.7和JDK1.8的改变非常大

在JDK1.7:

1、底层的数据结构是数组+链表,在添加元素前会判断是否需要进行扩容,链表插入的方法是头插法

2、在多线程环境下,扩容的时候会造成死链

在JDK1.8:

1、底层数数据结构是数组+链表+红黑树,在添加元素后会判断是否需要进行扩容,链表插入的方式也改成了尾插法

2、在扩容的过程中采用了高低位拉链,避免了死链问题

HashMap为了更好的减少hash冲突,通过位移异或等操作来打乱参与运算的低位

好了本期文章就到这里了,如果喜欢的话,还请留言点赞哦

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值