Java集合框架(四):Map、AbstractMap与JDK1

文章详细分析了HashMap的构造方法、put操作中的inflateTable过程,以及如何处理哈希冲突和扩容。重点介绍了构造函数如何处理容量和负载因子,put方法中的初次插入和后续替换策略,以及inflateTable中确定容量和调整阈值的逻辑。
摘要由CSDN通过智能技术生成

我们来看看他的注释

在这里插入图片描述

原来这个方法是跟构造函数和伪构造函数挂钩的,所以构造函数之所以没有调用super来构造继承的AbstractMap是因为调用了init方法吗?

总结构造方法
  • 作者不想让我们使用自定义的容量和负载因子

  • 默认的容量为16,负载因子为0.75

  • 无参构造方法就是使用默认参数来调用有参构造方法

  • 构造方法并没有马上去给table数组设置容量,而是将阈值设为了容量

put方法

在这里插入图片描述

我们来看看方法上的注释

在这里插入图片描述

从注释上就可以得知,当再次put相同key时会发生value的替换

从构造方法上可以知道,构造方法并没有立即给底层空数组立即扩上默认的容量,而是将门限值设为了默认容量

所以在put方法的开头,判断操作的table是不是EMPTY_TABLE,即针对第一次插入要对底层数组进行操作

在这里插入图片描述

调用inflateTable方法进行操作,这个参数是使用threshold作为参数的,也就是作为了下面的toSize,

inflateTable

下面来看看inflateTable方法做了什么

在这里插入图片描述

步骤如下

  • 找到指定容量最近的一个2的幂次方数(要大于等于指定容量),作为真正的容量

  • 然后对threshold进行计算,取最大的容量+1和容量乘于负载因子的最小值

  • 然后重新给底层主数组table赋值,容量为指定容量最近的一个2的幂次方数

  • 然后初始化hashSeed(前面提到过,这个hashSeed是关联哈希算法的)

所以,可以得知,构造方法将threshold设为指定容量,只是用来暂时存储指定的容量,底层主数组真正有容量是在第一次put之后

接下来我们看看怎么获取最近的一个2的幂次方数(该2的幂次方数大于等于指定容量)的,也就是roundUpToPowerOf2方法

在这里插入图片描述

步骤如下

  • 比较指定容量是否大于等于最大允许的容量

  • 如果大于指定容量就取最大容量( 2 30 2^{30} 230)

  • 如果小于指定容量就再判断指定容量是否大于1

  • 如果小于等于1,容量就取1(1也是2的幂次方,为零次幂)

  • 如果大于1,那么就调用Integer的highestOneBit方法

Integer的highestOneBit方法是用来获取指定参数的最小2次幂

现在我们分析一下,为什么要取指定容量减一的两倍的最小2次幂,也就是为什么参数为(nuber-1) << 1

前面已经提过,设置的容量是最近的大于或等于指定容量的2次幂,我们就围绕他来论证

2次幂,也就是0、1、2、4、8、16。。。。。这些二次幂数会形成一个个区间,如同下面所示

在这里插入图片描述

那么指定容量就会出现两种情况

  1. 指定容量在区间内

  2. 指定容量就是一个2次幂

当指定容量在区间内时,代表这个容量大于左边的二次幂数,小于右边的二次幂数,因此就有这样一个表达式 2 ( n − 1 ) < x < 2 n 2^{(n-1)} < x < 2^n 2(n−1)<x<2n(x代表容量),那么我们对条表达式进行乘2变成 2 n < 2 x < 2 ( n + 1 ) 2^n < 2x < 2^{(n+1)} 2n<2x<2(n+1),所以2倍的容量的最近的小二次幂就是容量的最近的大二次幂

当指定容量就是一个2次幂,也就是不落在上述的区间内,那么就需要进行减1,来让其落在区间内(而且这个减1操作对于本就在区间内的数没有影响),当然,比如2和1,如果指定容量为2,那么减一就变成了1,但这里要注意的是必须要大于1,才会执行这个方法,所以我们不用考虑1的情况

至于Integer的highestOneBit方法的细节,能力不足,看不懂,这里就贴上源码自己看吧

在这里插入图片描述

最后就是进行initHashSeedAsNeeded,这个方法是用来使用代替散列才使用的, 不太重要,因为我们一般使用hashMap都不会使用字符串来代替散列

inflateTable方法到此结束

总结一下inflateTable干了什么东西

  • 确定容量,容量大小为指定容量的最近的大二次幂

  • 修改扩容门限,扩容门限为扩容因子乘上容量

  • 如果要使用代替散列,对hashseed变量进行修改

添加Key不为null元素

先贴上代码

在这里插入图片描述

我们将其分为两部分

  • 第一部分是形成链表时独有的操作

  • 第二部分是没有形成链表和形成链表时都有的操作

第一个部分步骤如下

  1. 先判断要添加的键值对的key是否为null,如果为空,就调用putForNullKey方法

  2. 如果key不为空,调用hash方法来计算key的hash值

  3. 调用indexFor来找到key的hash值对应的索引

  4. 最后根据索引找到位置,判断这个位置是否已经有键值对

  5. 如果有,代表整个位置形成了之前发生过哈希冲突,形成了链表,就需要遍历这个链表看是否出现相同的键

  6. 判断相同的键是先根据链表中的键值对的hash值是否与当前要添加的键值对相等,如果不相等,再比较key是不是相同,判断是否是相同的key是根据是不是同一个与equals方法进行综合考虑的

  7. 如果出现相同的键,那么就记录旧的键值对的value,将旧的键值对的value修改成新的value(也就是键不变,值替换)

  8. 调用recordAccess方法

  9. 最后将旧的value返回

第二个部分步骤如下

  • 让modCount自增(之前研究ArrayList也有这个变量,是用来记录年代的,也就是这个底层数组的版本变化)

  • 最后调用addEntry方法,参数为哈希值,键,值,索引

总体的架构就是如上,现在来看调用的各个方法

hash方法

先来看看hash值是怎么计算的

在这里插入图片描述

上面那部分不用管,是替代散列时使用的(使用字符串替代散列值)

步骤如下

  1. 先调用key自己拥有的hashCode方法

  2. 最后使用一序列的扰乱函数

所以键值对的hash值生成是跟key有关的,而且细节来说是跟key的hashCode有关(这也就是为什么使用集合来存储对象时,一定要重写hashCode方法,而且hashCode要跟对象的属性值有关,这样就可以将相同属性值的对象视为同一个对象)

接下来我们看看扰乱函数是干什么的,从注释来看

在这里插入图片描述

总的来说这个扰乱函数就是用来减少哈希冲突

indexFor方法

这个方法就是用来根据哈希值寻找到底层主数组的索引的

在这里插入图片描述

可以看到这个方法单纯是用哈希值与上了底层数组的最大索引(也就是长度减一)

这里是利用了与运算的特点,前面已经提到过,底层数组的长度一定是2的幂次方,那么减一操作就会让其变成全为1的二进制数,也就确保了底层数组的索引数量化成二进制里面所有数字一定全部都为1

那么此时这个算法就会等效成用哈希值对最大索引进行取余,也就是h % ( length - 1 )

所以索引的计算方法其实就是哈希值对最大索引进行取余操作,保证不会超过底层数组的最大索引

这也是为什么长度必须为2的幂次方

recordAccess

在这里插入图片描述

可以看到这个方法什么都没干,可能是为了以后方便拓展

我们再看下注释

在这里插入图片描述

注释上提到这个方法是出现重复key的时候都会调用

addEntry

最后我们再看看addEntry的方法

在这里插入图片描述

我们可以看到,addEntry方法里面竟然包含了扩容操作即resize方法

步骤如下

  • 判断当前HashMap的元素数量是否大于门限

  • 如果大于,再进行比较当前要添加键值对的位置是否为空

  • 如果也为空,那就会进行扩容操作

  • 所以,要扩容必须满足两个条件

  • 一个是HashMap的元素数量大于门限值

  • 另一个是当前添加键值对的索引位置必须发生冲突

  • 下面进行扩容,扩容调用的是resize方法,而且传的参数是当前底层数组长度的两倍

  • 扩容了之后,首先进行再次hash,即重新计算当前键值对的key的hash值

  • 然后再用新的hash值计算出新的索引位置

  • 最后就是调用createEntry方法来正式添加元素

扩容

我们来看看resize是如何进行扩容的

在这里插入图片描述

可以看到,扩容操作也是会进行判断

  • 先判断旧的容量,也就是当前主数组的长度,是否等于最大容量

  • 如果已经等于,就不可以继续扩了

  • 然后将扩容门限设为Integer的最大值

  • 结束

  • 如果主数组的长度,不等于最大长度,那么就代表一定小于最大容量

  • 那么就会创建一个新容量的数组(前面已经知道容量为旧容量的两倍)

  • 然后调用transfer方法

  • 让底层的table数组指向新容量的数组

  • 最后就是重置了门限值,让门限值为最大的容量+1和新的容量乘上负载因子的最小值

可以看到,是不是少了就是将旧的数据放在新数组上的步骤,所以,可以推测到,这一步是放在了transfer上去完成的

在这里插入图片描述

可以看到,对于旧数组,不是像ArrayList那样单纯的进行复制过去,而是要重新散列的

可以看到,使用了增强for来桥套while循环,来遍历所有元素(增强for遍历数组,while循环来遍历每个项的链表),然后对所有键值对的hash(键值对不仅保存键值,还保存着哈希值和下一个键值对的指向)进行了重新计算,这一步称为rehash,当然,计算的规则依然是使用key来计算的,重新计算了hash当然也要重新计算索引,然后根据重新计算的索引,对应存放在新创建的扩容数组里面

不过这里有一个问题?如果rehash了之后重新装填又发生了哈希冲突会怎样?

根据源码来看,如果重新装填的过程发生了哈希冲突,那么后面的键值对就会替代前面的键值对

至此,整个扩容过程就结束了

总结一下

  • 扩容要先判断旧的容量是否已经最大,最大的话,只是单纯改变了门限值为Integer最大值

  • 扩容后的容量为旧的容量的2倍

  • 扩容后要将旧的数据重新装填进新的数组里面,重新装填并不是单纯的复制,而是要遍历所有项的链表进行重新hash,重新计算索引值,然后根据新的索引值放在数组里面

  • 假如重新装填过程中发生哈希冲突,产生的索引值一样,那么就会进行数据替换,会产生数据丢失

  • 最后将底层table指向新扩容后的数组

  • 将门限改为新的容量乘上负载因子

createEntry

最后我们来看看createEntry方法

在这里插入图片描述

这个方法也就是真正底层的添加操作

  1. 首先获取底层数组指定索引位置处的旧键值对

  2. 让后新建一个键值对,键值对存要添加的键值、哈希值和指向下一个的旧键值对

在这里插入图片描述

可以知道,底层的插入使用的是一个头插法

补充:当添加的是key为null的元素

前面put方法源码上,是会判断key是否为null的,假如为null就会执行putForNullKey的方法

这样拆开是因为hash值是根据key的hashCode来生成的,如果key为null就不能生成hashCode,所以需要进行拆开

在这里插入图片描述

可以判断,putForNullKey的执行跟put方法后面的操作差不多

可以看到for循环里面,遍历的是底层table数组的第一个项,也就是第一个项的链表

那么就可以知道,key为null的键值对都是放置在底层table数组第一个项里面来形成链表的,当然里面还存在key不为null的键值对对象,然后如果发现有key也为null,而且value相等的,就会发生value替换,返回旧的value

可以看到,addEntry里面的bucketIndex变为了0,hash值也变成了0

所以,key为null的元素的hash为0,即使判断要进行扩容,重新hash的时候,因为key为null,索引也是为0的,因为bucketIndex为0,所以是存储在底层table数组里面的第一个项里面

get方法

接下来看看get方法是如何执行的

在这里插入图片描述

步骤如下

  • 先判断key是否为null

  • 如果为null就要在执行getForNullKey方法

  • 如果Key不为null,执行getEntry方法

  • 然后判断是否找到

  • 通过比对获得的entry是否为Null来判断

  • 如果为null就返回null

  • 如果不为null,就返回entry的value属性

getEntry

我们先来看看如果key不为null时执行的getEntry方法

在这里插入图片描述

步骤如下

  • 先判断HashMap里面的元素数量是否为0,如果为0就直接返回null

  • 如果不为0,就要根据给的key计算哈希值

  • 如果Key为null,那么对应的哈希值就为0

  • 如果key不为null,对应调用实现的hashCode去获取hash值

  • 使用indexFor方法获取哈希值的索引(0余上长度-1依然为0,所以不用担心0对索引的影响)

  • 遍历底层table数组指定索引的链表,通过hash和key比较

  • 首先比较哈希值是否相同

  • 如果哈希值相同,再比较key是不是相同

  • 先比较key是否是同一个对象,如果是,就代表key相同

  • 如果key不是同一个对象,使用equals方法比较key值是否相同,如果是,就表示key相同

  • 只要哈希值不同,就不会比较key是否相同

  • 如果最后判断hash值相同,同时key也相同,就代表找到那个键值对了,返回键值对的value属性

  • 如果遍历完了都还找不到,就返回Null

getForNullKey

接下来我们看看如果key为Null时会怎样

key为Null时,调用的是getForNullKey

在这里插入图片描述

可以看到,这个方法相比于getEntry没有那么复杂

因为key为null时只需遍历底层table数组的第一个项的链表即可。

注意这里返回的是遇到的第一个key为null的键值对,因为采用头插法,也就是最新插入的一个key为null的键值对

remove方法

最后我们看下删除方法

在这里插入图片描述

步骤如下

  • 调用removeEntryForKey的方法

  • 判断返回的entry是不是为空

  • 如果为空,返回null

  • 如果不为空,返回键值对的value属性

removeEntryForKey方法

源码如下

final Entry<K,V> removeEntryForKey(Object key) {

//判断底层数组元素数量是否为0

//如果为0直接返回null

if (size == 0) {

return null;

}

总结

无论是哪家公司,都很重视高并发高可用的技术,重视基础,重视JVM。面试是一个双向选择的过程,不要抱着畏惧的心态去面试,不利于自己的发挥。同时看中的应该不止薪资,还要看你是不是真的喜欢这家公司,是不是能真的得到锻炼。其实我写了这么多,只是我自己的总结,并不一定适用于所有人,相信经过一些面试,大家都会有这些感触。

最后我整理了一些面试真题资料,技术知识点剖析教程,还有和广大同仁一起交流学习共同进步,还有一些职业经验的分享。

面试了阿里,滴滴,网易,蚂蚁,最终有幸去了网易【面试题分享】

tps://img-blog.csdnimg.cn/20210607154527839.png#pic_center)

步骤如下

  • 调用removeEntryForKey的方法

  • 判断返回的entry是不是为空

  • 如果为空,返回null

  • 如果不为空,返回键值对的value属性

removeEntryForKey方法

源码如下

final Entry<K,V> removeEntryForKey(Object key) {

//判断底层数组元素数量是否为0

//如果为0直接返回null

if (size == 0) {

return null;

}

总结

无论是哪家公司,都很重视高并发高可用的技术,重视基础,重视JVM。面试是一个双向选择的过程,不要抱着畏惧的心态去面试,不利于自己的发挥。同时看中的应该不止薪资,还要看你是不是真的喜欢这家公司,是不是能真的得到锻炼。其实我写了这么多,只是我自己的总结,并不一定适用于所有人,相信经过一些面试,大家都会有这些感触。

最后我整理了一些面试真题资料,技术知识点剖析教程,还有和广大同仁一起交流学习共同进步,还有一些职业经验的分享。

[外链图片转存中…(img-G15IbLbe-1714138391557)]

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值