集合-3 HashMap

一.说一下HashMap的实现原理

1.HashMap底层是基于HashTable(散列表)实现的,即数组+链表或红黑树

2.在添加数据时,会将key进行hash()得到数组下标。若数组下标相同:

(1)若key相同,则旧值对新值进行覆盖。

(2)若key不同,则将新值添加到该槽的链表或红黑树中。

二.HashMap的put方法具体流程

1.首先会判断HashMap中的数组table是否为null,若为null则调用resize()初始化长度为16。

(HashMap是惰性加载,创建时不会初始化,只有第一次添加数据时,才会使用resize()函数将容量初始化为16)

2.计算key的哈希值,并以此计算出数组下标:i=hash(key)&(table.length-1)。此处的与运算相当于将key的哈希值对数组长度取模,防止下标越界。如果key为null则直接映射到0号槽。

3.判断table[i]是否为null,若为null则直接存入数据

4.若不为null则进行判断

(1)若table[i]的key值与新数据的key值相同,则用新值覆盖旧值。

(2)若table[i]的key值与新数据的key值不相同,则判断该槽位存的是红黑树还是链表;若为红黑树,则执行红黑树的添加逻辑。

(3)若为链表,则遍历链表,进行尾插法。在遍历链表的过程中,还需对每个节点的key值进行判断,若出现key与新值的key相同的节点,则也是直接新值覆盖旧值。

(4)若新值添加到链表尾部后,对链表长度进行判断:若链表的节点数大于等于8且数组的长度大于等于64,则将链表转化为红黑树。若红黑树节点数小于6则退化为链表。

5.每次添加完一个值,都对数组的有效容量size进行判断,若size达到了扩容阈值(threshole=0.75*table.length),则调用resize()进行扩容。初始化时调用resize()会将数组长度设置为16,后续的每次扩容都是扩容两倍。

三.为什么判断链表转红黑树的节点数是8?为什么判断红黑树退化为链表的节点数是6?

1.根据概率统计和大量实验表明,同一个槽位发生哈希冲突次数为8的概率非常小,因此将节点门槛设为8可以避免链表转红黑树的操作。

2.若将红黑树退化为链表的节点数设置为8,则可能会出现链表和红黑树的不停相互激荡转化,浪费资源,因此要将转化的节点门槛分隔开,中间用7作为缓冲。、

3.正常情况下出现链表转红黑树的概率是很低的,红黑树的存在主要是是为了防止DDos攻击导致某个槽下的链表过长,从而影响查询效率。

四.讲一下HashMap的扩容机制(resize()函数)

1.HashMap在扩容时会调用resize()函数进行扩容。第一次添加元素时会调用resize()函数,将数组长度初始化为16。后续只有当HashMap的size达到扩容阈值(threshold=length*0.75),才会调用resize()进行扩容。

2.第一次扩容初始化长度为16,后续的每次扩容都是扩大成原容量的两倍:newCap.length=oldCap.length*2。

3.扩容时,会创建新数组,然后遍历原数组的每个槽位,将数据都搬到新数组。

4.如果当前槽位只有一个节点,即没有发生过哈希冲突,则通过寻址算法i=e.hash&(newCap.length-1)计算该节点在新数组的下标。

5.如果当前槽位是一颗红黑树,则执行红黑树的扩容逻辑。

6.如果当前槽位是一条链表,则需要对链表进行·拆分,遍历链表每个节点,判断:

(1)如果(e.hash&oldCap.length==0),则节点在新数组的下标=旧数组的下标。

(2)如果(e.hash&oldCap.length!=0),则节点在新数组的下标=旧数组下标+旧数组长度。

五.讲一下HashMap的寻址算法

1.先对通过哈希函数计算key的哈希值,hash()函数的实现:

(1)先计算key的hashcode()值。

(2)进行二次哈希(又称为扰动算法):将hashcode值与右移16位后的hashcode值进行异或运算,得到hash值。

2.通过寻址算法:i=hash(key)&(capacity-1)计算出索引。

六.为什么HashMap的数组长度一定是2的次幂?

1.计算索引效率更高。只有当长度capacity为2的次幂时,才满足e.hash&(capacity-1)==e.hash%capacity,即可以用按位与运算代替取模运算,提高cpu的运算效率。

2.扩容时重新计算索引效率更高。HashMap扩容时对链表的拆分逻辑是{e.hash&oldCap.length==0 ? newIndex=oldIndex : newIndex=oldIndex+oldCap.length},也就是说新节点下标要么为旧节点下标,要么为旧节点下标+旧数组长度,根据这个逻辑就无需再对每个节点重新计算哈希值再利用寻址算法计算下标,提高了扩容效率。而上述拆分逻辑正是基于长度capacity必须为2的次幂才能成立。

例:假设oldCap.length=16=0001 0000,则节点在数组中的地址使用的是低四位,最大为1111=15

(1)如果e.hash&oldCap.length==0,说明e.hash小于16,使用低四位作为地址就足够了,因此在新数组下标就等于旧数组下标。

(2)如果e.hash&oldCap.length!=0,说明e.hash大于等于16,在原数组中通过取模运算砍掉高位防止数组越界;但对于扩容两倍后的新数组,hash值就可以多保留一位,配合低四位取到新下标。而根据数组长度为2的次幂的原则,hash值多保留高一位后其实就是旧数组的长度+旧数组的低四位地址,因此在新数组下标就等于旧数组下标+旧数组长度。

七.说一下jdk1.7和jdk1.8的HashMap的区别

1.链表插入方式不同

(1)jdk1.7对发生哈希冲突的节点,会将新节点插入该槽位链表的头部。在多线程扩容的情况下,使用链表头插法可能会发生因链表成环而导致进入死循环问题。

(2)jdk1.8对发生哈希冲突的节点,会将新节点插入该槽位链表的尾部。解决了因头插导致的多线程扩容死循环问题。

2.数据结构不同

(1)jdk1.7的HashMap底层数据结构是数组+单链表

(2)jdk1.8的HashMap底层数据结构是数组+链表/红黑树。当满足链表节点数为8且数组长度大于等于64时,链表转红黑树,提高了搜索效率。当红黑树节点数小于等于6时,红黑树退化成链表,节省了存储空间。

3.扩容方式不同

(1)jdk1.7的HashMap在扩容时,若遇到链表,会对链表的每个节点都重新计算hash值,再重新用寻址公式计算下标。

(2)jdk1.8的HashMap在扩容时,若遇到链表,会根据(e.hash&oldCap.length?=0)来判断是下标不变还是下标加上数组长度,而不用重新计算hash值,提高了效率。

4.扰动函数不同

hash()函数会先对key计算hashcode(),再调用扰动函数进行二次哈希

(1)jdk1.7是九次扰动,对key取完hashcode()后进行四次位运算+五次异或运算

(2)jdk1.8是两次扰动,对key取完hashcode()后进行一次位运算+一次异或运算。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值