HashMap知识点总结

1.与HashMap相关的数据结构

数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下(后面会探讨下哈希冲突的情况),仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构(像栈,队列,树,图等是从基础的逻辑结构去抽象从而得到的,映射到内存中,也是这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。

比如我们要新增或查找某个元素,我们通过把当前元素的关键字通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
  
这个函数可以简单描述为:存储位置 = f(关键字) ,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:
插入过程如下图所示:
在这里插入图片描述
查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。

哈希冲突
如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。

前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀。但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?

哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。

2.HashMap底层数据结构

正如上文所说,HashMap采用了链地址法,也就是数组+链表的方式, 来解决Hash冲突。数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在相同的key即覆盖,否则新增;JDK 1.8之后底层是由“数组+链表+红黑树”组成。
总结:数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。

在这里插入图片描述

3.HashMap数据结构相关问题

1.构造哈希函数的方法

HashMap里哈希构造函数的方法为:

  • 除留取余法:H(key)=key%p(p<=N),关键字除以一个不大于哈希表长度的正整数p,所得余数为地址,当然HashMap里进行了优化改造,效率更高,散列也更均衡。

除此之外,还有这几种常见的哈希函数构造方法:

  • 直接定址法
    直接根据key来映射到对应的数组位置,例如1232放到下标1232的位置。

  • 数字分析法
    取key的某些数字(例如十位和百位)作为映射的位置

  • 平方取中法
    取key平方的中间几位作为映射的位置

  • 折叠法
    将key分割成位数相同的几段,然后把它们的叠加和作为映射的位置

2.解决哈希冲突的方法

我们到现在已经知道,HashMap使用链表的原因为了处理哈希冲突,这种方法就是所谓的:

  • 链地址法:在冲突的位置拉一个链表,把冲突的元素放进去。

除此之外,还有一些常见的解决冲突的办法:

  • 开放定址法:开放定址法就是从冲突的位置再接着往下找,给冲突元素找个空位。
    找到空闲位置的方法也有很多种:
    线行探查法: 从冲突的位置开始,依次判断下一个位置是否空闲,直至找到空闲位置
    平方探查法: 从冲突的位置x开始,第一次增加1^2 个位置,第二次增加2^2…,直至找到空闲的位置

  • 再哈希法:换种哈希函数,重新计算冲突元素的地址。

  • 建立公共溢出区:再建一个数组,把冲突的元素放进去。

3.红黑树简介,为什么不用二叉树/平衡树呢

红黑树本质上是一种二叉查找树,为了保持平衡,它又在二叉查找树(左<根<由)的基础上增加了一些规则:

  1. 每个节点要么是红色,要么是黑色;
  2. 根节点永远是黑色的;
  3. 所有的叶子节点都是是黑色的(注意这里说叶子节点其实是图中的 NULL 节点);
  4. 每个红色节点的两个子节点一定都是黑色;
  5. 从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;
    在这里插入图片描述

之所以不用二叉树:
红黑树是一种平衡的二叉树,插入、删除、查找的最坏时间复杂度都为 O(logn),避免了二叉树最坏情况下的O(n)时间复杂度。
之所以不用平衡二叉树:
平衡二叉树是比红黑树更严格的平衡树,为了保持保持平衡,需要旋转的次数更多,也就是说平衡二叉树保持平衡的效率更低,所以平衡二叉树插入和删除的效率比红黑树要低。

4.红黑树怎么保持平衡

红黑树有两种方式保持平衡:旋转和染色。

  • 旋转:旋转分为两种,左旋和右旋
    在这里插入图片描述
    在这里插入图片描述
  • 染色

在这里插入图片描述

4.插入过程

首先,初始化 HashMap(懒加载模式,只有put的时候才会初始化HashMap),提供了有参构造和无参构造。
容器默认的数组大小 initialCapacity 为 16,也可自己设置,HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 9,容量为16。

  1. 通过 HashMap 自己提供的Hash 算法算出当前 key 的Hash 值

  2. 通过计算出的Hash 值去调用 indexFor 方法计算当前对象应该存储在数组的几号位置

  3. 判断size 是否已经达到了当前阈值(当前数组大小×负载因子,以初始16为例,则阈值为16×0.75),如果没有,继续;如果已经达到阈值,则先进行数组扩容,将数组长度扩容为原来的2倍。(此处的size 是当前容器中已有 Entry 的数量,不是数组长度)

  4. 将当前对应的 Hash,key,value封装成一个 Entry,去数组中查找当前位置有没有元素,如果没有,放在这个位置上;如果此位置上已经存在链表,那么遍历链表,如果链表上某个节点的 key 与当前key 进行 equals 比较后结果为 true,则把原来节点上的value 返回,将当前新的 value替换掉原来的value,如果遍历完链表,没有找到key 与当前 key equals为 true的,就把刚才封装的新的 Entry中next 指向当前链表的始节点,也就是说当前节点现在在链表的第一个位置,简单来说即,先来的往后退。

  5. 默认情况下是使用链表节点。当同一个索引位置的节点在新增后达到9个(阈值8):如果此时数组长度大于等于 64,则会触发链表节点转红黑树节点(treeifyBin);而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容,因为此时的数据量还比较小。
    在这里插入图片描述在这里插入图片描述

在这里插入图片描述

5.扩容过程

在这里插入图片描述

6.插入/扩容相关问题

1.扩容一定要是2的n次方的原因

每次扩容之后,都要重新计算原来的 Entry 在新数组中的位置,为什么数组扩容了,Entry 在数组中的位置发生变化了呢?所以我们会想到计算位置的 indexFor 方法,为什么呢,我摘出了该方法的源码如下:

 static int indexFor(int h, int length) { // h 为key 的 Hash值;length 是数组长度
        return h & (length-1);  
 }

由源码得知,元素所在位置是和数组长度是有关系的,既然扩容后数组长度发生了变化,那么元素位置肯定是要发生变化了。HashMap 计算元素位置采用的是&运算,为什么 HashMap使用这种方式计算在数组中位置呢?

按照正常理解,取模就可以了。HashMap 用与运算主要是提升计算性能,这又带来一个新问题,为什么与运算要用 length -1 呢,回看 HashMap初始化的时候,数组长度 length必须是2的整次幂(如果手动传参数组长度为奇数n,HashMap会自动转换长度为距离n最近的2的整次幂数)

与操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度 16 为例,16-1=15。2 进制表示是0000 0000 0000 0000 0000 0000 0000 1111。和某个散列值做 与 操作如下,结果就是截取了最低的四位值。只有这样, h & (length-1) 的值才会和 h % length 计算的结果是一样的。这就是它的原因所在。
在这里插入图片描述
一个简单的例子,当长度不是2的n次方就无法做到h & (length-1) 的和 h % length 的值一样。
在这里插入图片描述

2. 阈值为8的原因

平时在进行方案设计时,必须考虑的两个很重要的因素是:时间和空间。对于 HashMap 也是同样的道理,简单来说,阈值为8是在时间和空间上权衡的结果。

红黑树节点大小约为链表节点的2倍,在节点太少时,红黑树的查找性能优势并不明显,付出2倍空间的代价作者觉得不值得。

理想情况下,使用随机的哈希码,节点分布在 Hash 桶中的频率遵循泊松分布,按照泊松分布的公式计算,链表中节点个数为8时的概率为 0.00000006,这个概率足够低了,并且到8个节点时,红黑树的性能优势也会开始展现出来,因此8是一个较合理的数字。

4.负载因子是0.75的原因

这个也是在时间和空间上权衡的结果。如果值较高,例如1,此时会减少空间开销,但是 Hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 Hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。

5.计算 key 的 Hash 值,是怎么设计的

拿到 key 的 HashCode,并将 HashCode 的高16位(一共32位,右移16位,剩下高16位 )和 HashCode 进行异或(XOR)运算,得到最终的 Hash 值。

static final int Hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.HashCode()) ^ (h >>> 16);
}

在这里插入图片描述

为什么要将 HashCode 的高16位参与运算

主要是为了在 table 的长度较小的时候,让高位也参与运算,并且不会有太大的开销
例如:a和b的Hashcode并不一样,但是由于n-1一样,所以ab的高位并没有参与运算,导致ab的存储位置相同
在这里插入图片描述
如果我们将高位参与运算,则索引计算结果就不会仅取决于低位。
在这里插入图片描述

6.红黑树和链表都是通过 e.Hash & oldCap == 0 来定位在新表的索引位置,这是为什么?

源码如下:
在这里插入图片描述
扩容前 table 的容量为16,a 节点和 b 节点在扩容前处于同一索引位置。
在这里插入图片描述
扩容后,table 长度为32,新表的 n - 1 只比老表的 n - 1 在高位多了一个1(图中标红)。
在这里插入图片描述
因为 2 个节点在老表是同一个索引位置,因此计算新表的索引位置时,只取决于新表在高位多出来的这一位(图中标红),而这一位的值刚好等于 oldCap,也就是:
在这里插入图片描述
因为只取决于这一位,所以只会存在两种情况:

  1. (e.Hash & oldCap) == 0 ,即:(00101 & 10000)== 0 则新表索引位置为“原索引位置” ;
  2. (e.Hash & oldCap) != 0,即:(10101 & 10000)== 1 则新表索引位置为“原索引 + oldCap 位置”

7.移除时阈值为6的原因

如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。

8.如何确保HashMap容量为n的2次方

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

|=(或等于):这个符号比较少见,但是“+=”应该都见过,看到这你应该明白了。例如:a |= b ,可以转成:a = a | b。

在这里插入图片描述

>>>(无符号右移):例如 a >>> b 指的是将 a 向右移动 b 指定的位数,右移后左边空出的位用零来填充,移出右边的位被丢弃。

在这里插入图片描述
假设 n 的值为 0010 0001,则该计算如下图:

在这里插入图片描述
n:00111101,n>>>4:00000011,n|=n>>>4:00111111,可以看出这5个公式会通过最高位的1,拿到2个1、4个1、8个1、16个1、32个1。当然,有多少个1,取决于我们的入参有多大,但我们肯定的是经过这5个计算,得到的值是一个低位全是1的值,最后返回的时候 +1,则会得到1个比n 大的2的N次方。

7.其他问题

1.HashMap 是线程安全的吗?多线程下会有什么问题?

  • 多线程下扩容死循环。JDK1.7 中的 HashMap 使用头插法插入元素,在多线程的环境下,会导致同一索引位置的节点在扩容后顺序反掉,导致环形链表的出现,形成死循环。因此,JDK1.8 使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。

  • 多线程的 put 可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在 JDK 1.7 和 JDK 1.8 中都存在。

  • put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而导致重新计算Hash值,线程 2 此时执行 get,有可能导致这个问题。这个问题在 JDK 1.7 和 JDK 1.8 中都存在。

2.有什么办法能解决HashMap线程不安全的问题呢?

Java 中有 HashTable、Collections.synchronizedMap、以及 ConcurrentHashMap 可以实现线程安全的 Map。

  • HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大;
  • Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
  • ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized。

参考:
JDK7之前HashMap源码分析
面渣逆袭:HashMap追魂二十三问
面试阿里,HashMap 这一篇就够了

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我顶得了

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值