红黑树 键值_大厂面试官:说一下JDK1.8 HashMap有哪些亮点?

上篇我们介绍了JDK1.7版的HashMap,今天我们来讲解下JDK1.8版的HashMap。

JDK1.7的实现大家看出有没有需要优化的地方?

其实一个很明显的地方就是:当 Hash 冲突严重时,在桶上形成的链表会变的越来越长,这样在查询时的效率就会越来越低;时间复杂度为 O(N)。

因此JDK1.8 中重点优化了这个查询效率。

1、JDK1.8 HashMap 数据结构图

我们会发现优化的部分就是把链表结构变成了红黑树。原来jdk1.7的优点是增删效率高,于是在jdk1.8的时候,不仅仅增删效率高,而且查找效率也提升了。

注意:不是说变成了红黑树效率就一定提高了,只有在链表的长度不小于8,而且数组的长度不小于64的时候才会将链表转化为红黑树。

问题一:什么是红黑树呢?

红黑树是一个自平衡的二叉查找树,也就是说红黑树的查找效率是非常的高,查找效率会从链表的o(n)降低为o(logn)。如果之前没有了解过红黑树的话,也没关系,你就记住红黑树的查找效率很高就OK了。

问题二:为什么不一下子把整个链表变为红黑树呢?

这个问题的意思是这样的,就是说我们为什么非要等到链表的长度大于等于8的时候,才转变成红黑树?在这里可以从两方面来解释

(1)构造红黑树要比构造链表复杂,在链表的节点不多的时候,从整体的性能看来, 数组+链表+红黑树的结构可能不一定比数组+链表的结构性能高。就好比杀鸡焉用牛刀的意思。

(2)HashMap频繁的扩容,会造成底部红黑树不断的进行拆分和重组,这是非常耗时的。因此,也就是链表长度比较长的时候转变成红黑树才会显著提高效率。

2、HashMap构造方法

构造方法一共有四个:

这四个构造方法很明显第四个最麻烦,我们就来分析一下第四个构造方法,其他三个自然而然也就明白了。上面出现了两个新的名词:loadFactor和initialCapacity。我们一个一个来分析:

(1)initialCapacity初始容量

官方要求我们要输入一个2的N次幂的值,比如说2、4、8、16等等这些,但是我们忽然一个不小心,输入了一个20怎么办?没关系,虚拟机会根据你输入的值,找一个离20最近的2的N次幂的值,比如说16离他最近,就取16为初始容量。

(2)loadFactor负载因子

负载因子,默认值是0.75。负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。 所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成浪费,但是此时索引效率高。

3、put方法

我们在存储一个元素的时候,大多是使用下面的这种方式。

在这里HashMap

,第一个参数是键,第二个参数是值,合起来叫做键值对。存储的时候只需要调用put方法即可。那底层的实现原理是怎么样的呢?这里还是先给出一个流程图:

上面这个流程,不知道你能否看到,红色字迹的是三个判断框,也是转折点,我们使用文字来梳理一下这个流程:

(1)第一步:调用put方法传入键值对

(2)第二步:使用hash算法计算hash值

(3)第三步:根据hash值确定存放的位置,判断是否和其他键值对位置发生了冲突

(4)第四步:若没有发生冲突,直接存放在数组中即可

(5)第五步:若发生了冲突,还要判断此时的数据结构是什么?

(6)第六步:若此时的数据结构是红黑树,那就直接插入红黑树中

(7)第七步:若此时的数据结构是链表,判断插入之后是否大于等于8

(8)第八步:插入之后大于8了,就要先调整为红黑树,在插入

(9)第九步:插入之后不大于8,那么就直接插入到链表尾部即可。

上面就是插入数据的整个流程,光看流程还不行,我们还需要深入到源码中去看看底部是如何按照这个流程写代码的。

鼠标聚焦在put方法上面,按一下F3,我们就能进入put的源码。来看一下:

也就是说,put方法其实调用的是putVal方法。putVal方法有5个参数:

(1)第一个参数hash:调用了hash方法计算hash值

(2)第二个参数key:就是我们传入的key值,也就是例子中的张三

(3)第三个参数value:就是我们传入的value值,也就是例子中的20

(4)第四个参数onlyIfAbsent:也就是当键相同时,不修改已存在的值

(5)第五个参数evict :如果为false,那么数组就处于创建模式中,所以一般为true。

知道了这5个参数的含义,我们就进入到这个putVal方法中。

乍一看,这代码完全没有读下去的欲望,第一次看的时候真实恶心到想吐,但是结合上一开始画的流程图再来分析,相信就会好很多。我们把代码进行拆分(整体分了四大部分):

(1)Node

[] tab中tab表示的就是数组。Node

p中p表示的就是当前插入的节点

(2)第一部分:

这一部分表示的意思是如果数组是空的,那么就通过resize方法来创建一个新的数组。在这里resize方法先不说明,在下一小节扩容的时候会提到。

(3)第二部分:

i表示在数组中插入的位置,计算的方式为(n - 1) & hash。在这里需要判断插入的位置是否是冲突的,如果不冲突就直接newNode,插入到数组中即可,这就和流程图中第一个判断框对应了。

如果插入的hash值冲突了,那就转到第三部分,处理冲突

(4)第三部分:

我们会看到,处理冲突还真是麻烦,好在我们对这一部分又进行了划分

a)第三部分第一小节:

在这里判断table[i]中的元素是否与插入的key一样,若相同那就直接使用插入的值p替换掉旧的值e。

b)第三部分第二小节:

判断插入的数据结构是红黑树还是链表,在这里表示如果是红黑树,那就直接putTreeVal到红黑树中。这就和流程图里面的第二个判断框对应了。

c)第三部分第三小节:

如果数据结构是链表,首先要遍历table数组是否存在,如果不存在直接newNode(hash, key, value, null)。如果存在了直接使用新的value替换掉旧的。

注意一点:不存在并且在链表末尾插入元素的时候,会判断binCount >= TREEIFY_THRESHOLD - 1。也就是判断当前链表的长度是否大于阈值8,如果大于那就会把当前链表转变成红黑树,方法是treeifyBin。这也就和流程图中第三个判断框对应了。

(5)第四部分:

插入成功之后,还要判断一下实际存在的键值对数量size是否大于阈值threshold。如果大于那就开始扩容了。

4、resize方法

为什么扩容呢?很明显就是当前容量不够,也就是put了太多的元素。为此我们还是先给出一个流程图,再来进行分析。

这个扩容就比较简单了,HaspMap扩容就是就是先计算 新的hash表容量和新的容量阀值,然后初始化一个新的hash表,将旧的键值对重新映射在新的hash表里。如果在旧的hash表里涉及到红黑树,那么在映射到新的hash表中还涉及到红黑树的拆分。整个流程也符合我们正常扩容一个容量的过程,我们根据流程图结合代码来分析:

这代码量同样让人恶心,不过我们还是分段来分析:

(1)第一部分:

根据代码也能看明白:首先如果超过了数组的最大容量,那么就直接将阈值设置为整数最大值,然后如果没有超过,那就扩容为原来的2倍,这里要注意是oldThr << 1,移位操作来实现的。

(2)第二部分:

首先第一个else if表示如果阈值已经初始化过了,那就直接使用旧的阈值。然后第二个else表示如果没有初始化,那就初始化一个新的数组容量和新的阈值。

(3)第三部分

第三部分同样也很复杂,就是把旧数据复制到新数组里面。这里面需要注意的有下面几种情况:

A:扩容后,若hash值新增参与运算的位=0,那么元素在扩容后的位置=原始位置

B:扩容后,若hash值新增参与运算的位!=0,那么元素在扩容后的位置=原始位置+扩容后的旧位置。

这里面有一个非常好的设计理念,扩容后长度为原hash表的2倍,于是把hash表分为两半,分为低位和高位,如果能把原链表的键值对, 一半放在低位,一半放在高位,而且是通过e.hash & oldCap == 0来判断,这个判断有什么优点呢?

举个例子:n = 16,二进制为10000,第5位为1,e.hash & oldCap 是否等于0就取决于e.hash第5 位是0还是1,这就相当于有50%的概率放在新hash表低位,50%的概率放在新hash表高位。

5、hash方法

Java 8中的散列值优化函数:

源码中模运算就是把散列值和数组长度做一个"与"操作:

这也正好解释了为什么HashMap的数组长度要取2的整次幂,因为这样(数组长度-1)正好相当于一个“低位掩码”,“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。

以初始长度16为例,16-1=15,2进制表示是00000000 00000000 00001111,和某散列值做“与”操作如下,结果就是截取了最低的四位值:

但这时候问题就来了:这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重,这时候“扰动函数”的价值就体现出来了:

右位移16位,正好是32位一半,自己的高半区和低半区做异或,就是为了混合原始hashCode的高位和低位,以此来加大低位的随机性,而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来,即降低了哈希冲突的风险又不会带来太大的性能问题。这个设计很巧妙!

6、hash冲突

通过异或运算能够是的计算出来的hash比较均匀,不容易出现冲突。但是偏偏出现了冲突现象,这时候该如何去解决呢?

在数据结构中,我们处理hash冲突常使用的方法有:开发定址法、再哈希法、链地址法、建立公共溢出区。而hashMap中处理hash冲突的方法就是链地址法。

这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。

7、table数组用transient修饰

从HashMap 的源码,会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。我们再回到源码中,考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不序列化的话,别人还怎么还原呢?

这里简单说明一下吧,HashMap 并没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容。这样做是有原因的,试问一句,HashMap 中存储的内容是什么?不用说,大家也知道是键值对。所以只要我们把键值对序列化了,我们就可以根据键值对数据重建 HashMap。有的朋友可能会想,序列化 table 不是可以一步到位,后面直接还原不就行了吗?这样一想,倒也是合理。但序列化 talbe 存在着两个问题:

1)table 多数情况下是无法被存满的,序列化未使用的部分,浪费空间。

2)同一个键值对在不同 JVM 下,所处的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能会发生错误。

以上两个问题中,第一个问题比较好理解,第二个问题解释一下。HashMap 的get/put/remove等方法第一步就是根据 hash 找到键所在的桶位置,但如果键没有覆写 hashCode 方法,计算 hash 时最终调用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能会有不同的实现,产生的 hash 可能也是不一样的。也就是说同一个键在不同平台下可能会产生不同的 hash,此时再对在同一个 table 继续操作,就会出现问题。

综上所述,大家应该能明白 HashMap 不序列化 table 的原因了,下面是HashMap自定义的序列化代码:

8、HashMap非线程安全

HashMap源码里面方法是没有synchronized或lock处理的,无法保证线程安全。于是出现了线程安全的ConcurrentHashMap,这个我们后续讲解。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值