HashMap基础

知识点一:Hash组成结构

这个问题我想大部分人还是都有了解的,HashMap结构是由“数组”和“链表”组成,其结构类似于下图的形式(图是百度找的)。

通过上面的图我们可以直观的看出来,我们要查找一个数据时,首先要找到数组对应下标的头部元素,而这个头部元素就是我们的链表的头,然后我们再根据链表的头部元素往下一个个匹配直到找到我们的想要的数据,或者匹配完也没找到对应的数据时就返回一个null。

当然你知道HashMap的结构之后,你还需要问自己一个问题,那就是HashMap为什么要用数组和链表来组成的数据结构? 这才是面试官想要听你说的。

  • 为什么要使用数组和链表?

两个字“性能”,HashMap是一个集添加、删除、查询都具备高性能的优点于一身。首先我们看它的结构组成之一“数组”。

数组的优势

因为每个数组都是有下标的,我们可以通过算法做查找优化,所以数组查找数据的速度非常快,如果没有数组下标,那我们只能一个个的遍历匹配,有多少数据匹配多少数据,那肯定贼慢。

数组的劣势

但是数组也有它的劣势,那就是数组插入数据非常的慢,因为每次插入数据的时候需要把后面的数据都重新挪下位置,就像下图描述的这样。

现在我们知道了,数组主要是查找数据快,但插入数据却需要挪动数据,当数组的长度越长,那么插入数据的时候,需要挪动的元素就越多,所以效率非常低。也正因为数组插入数据的效率低,所以这里就使用到了另外一个数据结构“链表”。

链表的优势

链表一个显著的优势就是插入数据快,链表不会像数组一样记录下标的值,但是链表的每个元素自己都会记录下一个元素的位置。就像我们排队一样,我们只需要记得站在我前面是谁就行, 我并不需要去记住我是队伍的第几个。

这样的好处就是,如果往链表中间插入一条数据时,比如在A->B->D>-E 链表中,如果在B节点和D节点之间插入C ,那么我只需要修改B节点保存的自己后面一个的引用,然后把C节点后面一个节点的引用指向D即可,其他的节点都不需要做任何的修改,不论这个链表长度如何。

链表的劣势

链表的劣势也很明显,就是链表非常不便于查找数据,因为它没有像数组一样记录元素的下标,所以每次查找一个元素就只能遍历整个链表去匹配。很显然这种查找方式很粗鲁。

  • 总结

因为HashMap添加数据、修改数据的操作都比较频繁,所以必须要保证查找元素的效率的同时也要保证插入元素的效率,在查找数据的时候充分利用数组的优势,定位到数据在哪个下标的链表里。在插入数据的时候充分利用链表的优势,每次插入数据时,把插入的数据放在链表的第一个节点,其他的节点不需要做任何修改。最终综合两种数据结构的优势提升整体性能。

HashMap如何PUT一个数据的?

通过上面的HashMap的结构图,我想你已经有了一个大概的模型了,现在我们继续深入了解HashMap是怎样把一个数据添加进去的,put数据的时候会涉及到多个知识点,我们再一一说明。

知识点二:HashMap初始化容量机制和扩容机制

  • 初始化容量

如果我们进行put数据的时候,首先得初始化一个数组才行,而数组创建是需要指定一个长度的,这里当我们没指定数组长度的时候,HashMap会默认给我们初始化一个默认长度为16的数组。

关于这个初始化的操作可以查看源码中的resize()方法,此方法不仅会在初始化容量的时候用到,同时在扩容的时候也会用到,在这个方法中,有一个常量DEFAULT_INITIAL_CAPACITY控制着HashMap的初始化容量。

  • 扩容机制

当我们使用数组的时候必须指定一个数组的长度,当我们数据越来越多的时候,就需要对数组进行扩容了, 进行扩容的时候会有几个问题是需要我们思考的:

(1)当数据到达多少时进行扩容?

这里通过源码我们可以知道我们是否扩容的决定性因素是DEFAULT_LOAD_FACTOR负载因子这个常量上,它的值为0.75。

那么为什么负载因子要设置成0.75呢,这个是经过了科学测试的,不能设置得太小我想大家都能理解,这个值设置太小会导致HashMap空间利用率不高,扩容的频率也会更高,扩容的时候需要把数据重新计算哈希排列,这样会影响性能。

设置成0.75为了减少哈希冲突,当我们通过科学的测试后,发现当数据量超过数组容量的0.75时,产生hash冲突的几率会很高,因为hash冲突的数据会放到同一个链表,这样会加长链表的长度,同样也会影响HashMap的性能。

(2)每次扩容的大小如何决定?

从上面我们知道初始化的容量是16,当我们需要进行扩容的时候都是对原来的容量增加一倍的长度,这里主要是保证扩容后的长度是2的N次幂。至于这里为什么要保证这种机制,后面我们在key是如何被分配到数组的某一个位置时候结合说明。

(3)最大容量是多少?

我们同样在源码里面看到一个MAXIMUM_CAPACITY的常量,这个值换算出来为1073741824,当我们的容量达到这个值时,HashMap就不再进行扩容了。

  • 总结

这里主要涉及到的知识点就是HashMap初始化容量的大小16,以及这个大小和后面的扩容机制有什么关系,这里主要是为了让HashMap的容量大小为2的N次幂(详细理解为什么要这么做请看下一个知识点),然后负载因子为什么这么设置,设置成0.75是为了减少扩容的频率,更科学合理的利用空间之余又尽量避免产生哈希冲突。

知识点三:如何定位一个key会存储到数组的哪个位置?

现在我们已经得到了一个长度为16的数组,那么我们如何定位一个Key存储到数组的哪个位置?因为现在我们的数组长度是16,那么我们有一个硬性要求就是通过一种方式把Key的存放位置定位到0-15下标之间。

  • 第一步

首先我们要把我们的Key通过hashCode()方法得到一个数字,比如说我们Key 为“name”得到的hashCode为3373707。

String key="name";
int hashCode=key.hashCode();
  • 第二步

然后我们需要对这个哈希数和数组的长度进行一个运算,得到一个1-15的数字。这我们可以使用hashCode 对我们的数组总长度16 进行取余来得到一个1-15的数字(不过这里HashMap中是使用的另外一种方式达到同样的效果,但是为了方便理解,另外一种算法稍后进行说明)

我们使用取余方式将3373707 % 16 =11 ,最后得到Key为”name”数据会存储到数组下标为11的位置,最后我们可以把数据存储到下标为11的链表里面去,如果链表里面有相同的Key则替换,没有重复的则追加到链表的尾部。

  • 比取余效率更高的元素下标定位方式

好了我想根据上面演示大家都已经理解了,如何根据hashCode来决定分配到哪个数组下标了,那么我们下面就来看HashMap 使用的另外一种更高效的数组下标定位的算法,我们看源码中是使用的什么算法。

首先解读下上面的代码:

1、获得HashMap的容量,也就是我们的数组长度。

2、把数组长度-1 然后与哈希code进行位与运算。

这里我们首先预习一下位与运算规则

两个数都转为二进制,然后从高位开始比较,如果两个数都为1则为1,否则为0。

现在按照我们的hashCode 和HashMap进行一次运算3373707 & (16-1) ,运算过程如下图

最后我们发现通过位与运算出来的结果居然和我们用取余的方式得出的结果是一致的,为了更加确认我们的猜想,我们看看在HashMap进行扩容后,或者用不同的key来进行取余和位与运算出来的结果是否一致。

  • 总结

经过试验我们可以做一个总结,因为在HashMap的容量为 2的N次幂时,我们使用 HashCode % HashMap容量 与 HashCode &(HashMap容量 -1) 计算方式得到的运算结果是一样的,而我们把容量改成其他数字就达不到这样的效果了,所以HashMap的扩容机制必须是2的N次幂,因为位与运算的速度要远高于取余的方式,所以HashMap最终采用了这种算法模式来决定一个key 会分配到哪个数组下标的位置去。

知识点四:在put数据的时候如何减少我们的哈希冲突?

这里的减少哈希冲突其实就是让我们的Key能合理均匀的分布到我们数组的各个下标里面,避免我们的元素都被集中分配到一个数组下标下面,这样会使我们的数组不能合理的利用起来,也会降低我们的查询效率,如果运气不好元素分布可能就和下图一样。

上图就是当我们的不同的key产生哈希冲突时会被分配到同一个数组下面,这样的数据会让我们在get数据时需要做一些额外的检查来判断节点是否是我们需要的那个,从而影响整个HashMap的性能,所以我们需要一套更科学的方式尽量减少哈希碰撞。 我们从源码中找到了这么一段代码。

这段代码意思是把HashCode 与HashCode无符号右移16位的值进行异或处理,这个值才是我们真正用于数组定位的HashCode值,那么为什么要进行这样一个处理呢,不直接使用key.hashCoide()不就得了吗。

这里为了方便理解,我们换一个例子来说明一下这个算法的意图,比如:如果有两个人,他们分别具备身高、年龄、性别、发型指标;如果想最大化的区分两个人的差异性,那么我们肯定把不同维度的指标都进行匹配,因为多一个维度的指标就有可能出现差异性,身高年龄相同的很多,但是身高年龄发型相同的几率就会少很多,而这里其实就是在HashCode上加一个维度的比较那么产生哈希冲突的几率就越少。

我们看HashMap用来定位数组位置的计算HashCode &(HashMap容量 -1),观察这个过程我们就不难发现为什么要加这个处理逻辑了。

我们通过上面的图可以发现,我们在计算出HashCode与数组长度进行运算时,其实我们只用到了HashCode的一小部分数据参与了运算,那么根据我们经验得知,肯定是参与匹配的维度越多,那么就越难出现哈希冲突,这HashMap中把HashCode 与HashCode无符号右移16位的值进行异或处理,正是出于想把其他没有使用到的数据也合理的利用起来参与到运算中,从而达到减少哈希冲突的结果。

  • 总结

多一个维度的比较就能减少重复,HashMap为了把HashCode充分的利用到数组位置计算中,从而达到减少哈希冲突,所以使用了上面的算法,目的就是为了让HashCode更多的数字参与到Key的位置计算中来。

知识点五:链表过长怎么办?

通过上面的知识我们知道,HashMap已经很努力的想办法减少哈希冲突了,但是数组的长度终归是有限的,这样就必然造成一部分数据会分配到同一个数组下面,像下面的图一样,既难看,又影响我们的查询效率。

遇到这种情况也没办法,必须得想个法子优化,要不然这么长的链表,匹配数据的时候要一个个去比较,肯定会贼慢,所以HashMap把这个链表结构转换成红黑树,这样通过树结构来来优化查询的算法,提高查询的性能。

那么什么时候HashMap 会触发链表转换成红黑树的操作呢,还有当红黑树的数据少于多少的时候又会转回链表呢,这里我们也可以从HashMap源码中找到答案。

当进行put数据的时候,链表长度大于static final int TREEIFY_THRESHOLD = 8;

这个长度-1的时候就会进行链表转红黑树的操作。

因为我们的HashMap会进行扩容,扩容后我们的元素又可能会分配到别的数组小标里面去,所以我们红黑树的节点也会减少,而当红黑树的节点数量减少到一定程度的时候,红黑树又会转换成链表,而这个值是由static final int NTREEIFY_THRESHOLD = 6;来设定的,当红黑树的节点小于等于6的时候会完成转换链表的操作,这个我们在resize()方法中有调用split()方法,从源码中可以看到进行红黑树转链表的操作。

  • 总结

因为数组长度有限的,所以无法避免哈希冲突造成链表数据过多,当我们链表数据过长的时候会进行一些优化,把链表转换为红黑树优化查询效率,当链表长度大于等于7的时候,进行链表转红黑树,当链表长度小于等于6的时候又会把红黑树还原成链表。

补充:HashMap get数据时是如何查找对应数据的?

其实我们明白了HashMap如何进行put数据了之后,后面如何查询、修改和删除数据其实都很简单了,这里作为补充简单了走一下get数据的流程,修改和删除整体流程和查找流程差不多只是操作不同就不再另作说明。

get数据的流程:

1、先计算出key哈希值。

2、通过哈希值定位到Key存在哪个数组下标。

3、找到后看数组下标里面有没有节点。

4、有节点的话区分节点数据是红黑树还是链表,然后分别使用对应数据结构的查找方法。

5、根据查找的key和节点里面存的Key 值判断两个key是不是equas() ,equas则返回对应的节点,否则继续匹配下一个节点,直到匹配成功返回节点,或者没有节点配后返回null;

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值