HashMap 面试题整理

HashMap 面试题整理

介绍下 HashMap 的底层数据结构

在 JDK1.7 和 JDK1.8 中有所差别:

在 JDK1.7 中,由“数组+链表”组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。

在这里插入图片描述

在 JDK1.8 ,由“数组+链表+红黑树”组成。当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O(loogn),而链表是O(n)。因此,JDK1.8 对数据结构做了进一步的优化,引入了红黑树,链表和红黑树在达到一定条件会进行转换:

  • 当链表长度大于等于 8 且数组长度大于等于 64 才会转为红黑树
  • 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行扩容,而不是转换为红黑树,以减少搜索时间。

在这里插入图片描述

为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?

因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡;而单链表不需要。当元素小于 8 个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于 8 个的时候,红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。

因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这时浪费性能的。

不用红黑树,用二叉树可以吗?

可以。但是二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。

当链表转为红黑树后,什么时候退化成链表?

当 6 的时候退化成链表。中间有个差值 7 可以防止链表和红黑树之间频繁地切换。假设一下,如果设计成链表个数超过 8 则链表转换成树结构,链表个数小于 8 则树结构转换成链表,如果一个 HashMap 不停的插入、删除元素,链表个数在 8 左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

为什么链表改为红黑树的阈值是 8 ?

是因为泊松分布,我们来看看作者在源码中的注释:

在这里插入图片描述

翻译过来大概的意思:理想情况下使用随机的哈希码,容器中节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表,可以看到链表中元素个数为 8 时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了 8,是根据概率统计而选择的。

默认加载因子为什么是 0.75,而不是 0.6 或者 0.8 ?

回答这个问题前,我们先来看下 HashMap 的默认构造函数:

在这里插入图片描述

Node[] table 的初始化长度 length(默认值是 16),Load factor 为负载因子(默认值是 0.75),threshold 是 HashMap 所能容纳键值对的最大值。threshold = length * factor。

也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

默认的 loadFactor 是 0.75,0.75 是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下:

  • 如果内存空间很多而又对时间效率要求很高,可以降低负载因子 Load factor 的值
  • 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子 loadFactor 的值,这个值可以大于 1.

我们来追溯性下坐着在源码中的注释(1.7):

在这里插入图片描述

翻译过来大概得意思是:作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。较高的值会降低空间开销,但提高查询成本(体现在大多数的 HashMap 类的操作,包括 get 和 put)。设置初始大小时,应该考虑预计的 entry 数在 map 及其负载系数,并且尽量减少 rehash 操作的次数。如果初始容量大于最大条目数除以负载因子,rehash 操作将不会发生。

HashMap 中 key 的存储索引是怎么计算的?

首先根据 key 的值计算出 hashcode 的值,然后根据 hashcode 计算出 hash 值,最后通过 hash & (length - 1) 计算得到存储的位置。看看源码的实现:

在这里插入图片描述

1.7 索引计算:

在这里插入图片描述

1.8 索引计算:

这里的 Hash 算法本质上就是三步:取 key 的 hashcode 值、根据 hashcode 计算出 hash 值、通过 & 计算下标。其中 JDK1.7 和 JDK1.8 的不同之处,就在于第二步。我们来看看详细的过程,以 JDK1.8 为例,n 为 table 的长度:

在这里插入图片描述

JDK1.8 为什么要 hashcode 异或其右移十六位的值?

因为在 JDK1.7 中扰动了多次,计算 hash 值的 性能会稍差一点点。从速度、功效、质量来考虑,JDK1.8 优化了高位运算的算法,通过 hashcode() 的高 16 位异或低 16 位实现:(h = k.hashCode() ^ (h >>> 16))。这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低 Bit 都参与到 Hash 的计算中,同时不会有太大的开销。

为什么 hash 值都要与 length -1 相与?

  • 把 hash 值对数组长度取模运算,模运算的消耗很大,没有位运算快
  • 把 length 总是 2 的 n 次方时,h & (length-1)运算等价于对 length 取模,也就是 h%length,但是 & 比 % 具有更高的效率。

HashMap 数组的长度为什么是 2 的幂次方

这样做效果上等同于取模,在速度、效率上比直接取模也要快得多。除此之外,2 的 N 次幂有助于减少碰撞几率。如果 length 为 2 的幂次方,则 length-1 转化为二进制必定是 1111… 的形式,在与 h 的二进制与操作效率会非常快,而且空间不浪费。我们举个例子,看看下图:

在这里插入图片描述

在这里插入图片描述

当 n=15 时,6 和 7 的结果一样,这样表示他们在 table 存储的位置是相同的,也就是产生了碰撞,6、7 就会在一个位置形成链表,4 和 5 的结果也是一样,这样就会导致查询速度降低。

如果我们进一步分析,还会发现空间浪费非常大,以 length=15 为例,在 1、3、5、7、9、11、13、15 这八处没有存放数据。因为 hash 值在与 14(即 1110)进行 & 运算时,得到的结果最后一位永远都是 0,即 0001、0011、0101、0111、1001、1011、1101、1111 位置处是不可能存储数据的。

JDK1.7 和 1.8 的 put 方法去区别是什么?

区别在两处:

解决哈希冲突时,JDK1.7 只使用链表,JDK1.8 使用链表+红黑树,当满足一定条件,链表会转为红黑树

链表插入元素时,JDK1.7 使用头插法插入元素,在多线程的环境下有可能导致环形链表,扩容的时候会导致死循环。因此 JDK1.8 使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了,但 JDK1.8 的 HashMap 仍然是线程不安全的。

HashMap 的扩容方式?

HashMap 在容量超过负载因子所定义的容量之后,就会扩容。Java 里的数组是无法自动扩容的,方法是将 HashMap 的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。

那扩容的具体步骤是什么?让我们来看看源码 。

在这里插入图片描述

这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer() 方法将原有 Entry 数组的元素拷贝到新的 Entry 数组里。

在这里插入图片描述

newTable[i] 的引用赋给了 e.next ,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话)。

扩容在 JDK1.8 中有什么不一样?

JDK1.8 做了两处优化:

  1. resize 之后,元素的位置在原来的位置,或者原来的位置 + oldCap(原来哈希表的长度)。不需要像 JDK1.7 的实现那样重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引 + oldCap”。这个设计非常的巧妙,省去了重新计算 hash 值的时间。

​ 如下图所示,n 为 table 的长度,图(a)表示扩容前的 key1 和 key2 两种 key 确定索引位置的实例,图(b)表示扩容后 key1 和 key2 两种 key 确定索引位置的示例,其中 hash1 是 key1 对应的哈希与高位运算结果。

在这里插入图片描述

元素再重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1 bit(红色),因此新的 index 就会发生这样的变化:

在这里插入图片描述

  1. JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(头插法)。JDK1.8 不会倒置,使用尾插法。

下图为 16 扩充为 32 的 resize 示意图:

在这里插入图片描述

key 可以为 NULL 吗?

可以,key 为 NULL 的时候,hash 算法最后的值以 0 来计算,也就是放在数组的第一个位置。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值