HashMap面试题总结

1.Hashmap怎样实现的?

        JDK1.7中底层数据结构是数组+链表。即使hash函数取得再好也无法实现元素百分百均匀分布,当有大量元素存到同一个Entry[] table中时,这个桶会有一条很长的链表,这时候hashmap就相当于一个单链表,便利的时间复杂度就是O(n),

        所以,JDK1.8中底层数据结构是数组+链表+红黑树(查找时间复杂度O(logn))

        以JDK1.8为例,数组元素通过映射关系(散列函数),映射到桶数组对应的索引位置,插入该位置时,如果发生冲突,就会从冲突的位置拉一个链表,把冲突的元素放到链表。如果链表长度>8且数组大小>=64,链表转为红黑树,如果红黑树节点个数

2.为什么要用红黑树,为什么不用二叉树?为什么不用平衡二叉树?

        红黑树是一种平衡二叉树,其插入、删除、查找的最坏时间复杂度都为O(logn),避免了二叉树最坏情况下O(n)时间复杂度。

        平衡二叉树是比红黑树更严格的平衡树,为了保持平衡,需要旋转的次数更多,也就是说平衡二叉树保持平衡效率更低,所以平衡二叉树插入和删除效率比红黑树要低

3.为什么在JDK1.8中链表大于8时会转红黑树?

        红黑树平均查找长度为logn,如果平均查找长度log(8)=3,链表的平均查找长度为n/2,当长度为8 的时候,平均查找长度为8/2=4,这才有必要转化为树;链表长度如果小于等于6,6/2=3,而log(6)=2.6,虽然速度也很快,但转化为树结构和生成书的时间并不会太短。

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

4.Hashmap线性安全吗?如何保证安全?

        HashMap不是线程安全的,多线程下扩容会死循环,可以使用HashTabel、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全。

        HashTable在每个方法加上synchronized关键字,粒度比较大;

        Collections.synchronizedMap使用Collections集合的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内部通过对象锁实现;

        ConcurrentHashMap在JDK1.7中使用分段锁,在JDK1.8中使用CAS算法+synchronized.

5.HashMap在JDK1.78和JDK1.8有哪些不同?

数据结构不同:

        1.7中的HashMap是数组+链表的结构 1.8中的HashMap是数组+链表+红黑树的结构

链表插入方式不同:

        1.7使用的是头插法,头插法在进行扩容时存在线程安全问题导致链表死循环

        1.8使用的是尾插法

扩容后重新计算索引的方式不同:

        1.7将会使用扩容后的大小重新与hash计算索引

        1.8会判断之前hash中需要加入计算索引位置是0还是1,是0则保持原位,1则在现在索引的基础上加上新增的容量则是计算后的索引

JDK1.8主要解决优化的问题:

1.resize扩容优化

2.引入了红黑树,目的是避免单条链表过长而影响查询效率

3.解决了多线程死循环问题,但仍然时非线程安全的,多线程可能会造成数据丢失问题。

6.Hash计算

        当我们put的时候,首先计算key的hash值,这里调用了 hash方法,hash方法实际是key.hashCode()与key.hashCode()>>>16进行异或操作,高16bit补0,一个数和0异或不变,所以 hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。按照函数注释,因为bucket数组大小是2的幂,计算下标index = (table.length - 1) & hash,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logn)的树结构来提升碰撞下的性能。

为什么这么算?

        如果当n即数组长度很小,假设是16的话,那么n - 1即为1111 ,这样的值和hashCode直接做按位与操作,实际上只使用了哈希值的后4位。如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了,所以这里把高低位都利用起来,从而解决了这个问题。

PS:为什么要用异或运算符?

        保证了对象的hashCode的32位值只要有一位发生改变,整个hash()返回值就会改变。尽可能的减少碰撞。

7.HashMap的put方法?

1:当传入一个k-v的时候,首先会根据hash()方法计算一个hash值进行高位运算(这个值不决定在数组的位置)
2:在put的时候才会进行数组的初始化,判断数组是否存在,如果不存在就调用resize()方法创建默认数组容量为16的数组
3:通过key的hash值与数组最大索引进行异或运算,确定在数组中的位置
4:获取改位置是否有是否有元素,如果没有元素就创建一个新的Node节点元素存在在该数组中
5:如果该位置有元素的话就判断put进来的key与当前数组Node节点的key是否相同,如果相同的话就把就进行值的替换
6:当前的条件没有成立的话,判断该位置是红黑树还是链表
7:如果是红黑树,那么就把当前节点放在红黑树上面
8:如果是链表的话,就遍历该链表,把Node追加在链表中
9:这个时候也会去判断链表的长度,如果链表的长度超过8的话看是否需要树化(
会调用treeifyBin方法,如果数组容量小于64的话,就只会进行扩容,大于64才会转换为红黑树
)
10:返回覆盖的值

ps:详细解读JDK1.7和JDK1.8

put方法的流程如下:

进入到put方法之后,首先会将你给定的key通过hash算法以及与运算的方法将其运算得出数组的下标
如果数组的下标位置元素为空,则将对应的key value 封装成一个对象放入该数组位置。(JDK1.7中的叫做Entry对象 ,JDK1.8中的叫做Node对象)其实也就是存放key value键值对的对象而已
如果数组下标元素不为空的话,那么就要分jdk版本以及链表的长度进行讨论
(1)如果是JDK1.7的话,会先判断是否需要扩容,如果要扩容就先进行扩容,如果不用扩容的话就将key value封装成Entry对象。使用头插法的方式将该Entry对象添加到当前位置的链表中
(2)如果是JDK1.8的话,会先判断该位置上Node对象的类型是属于链表还是红黑树
a:如果Node是红黑树节点的话,会将key value封装成一个红黑树的Node节点将其添加到红黑树中,并且在这个过程中会判断红黑树是否存在要插入的key,如果存在该key,则直接更新value即可。
b: 如果该位置上Node节点的类型是链表的话,同样将该key value 封装成一个链表Node节点。然后使用尾插法的方式插入到该链表的最后位置中,在进行遍历的时候,同样会遍历链表的key值,如果存在插入的key值的话,那么直接更新value值即可。如果不存在就插入到链表的最后一个位置上。插入到链表之后,会将链表的长度更新,如果链表长度大于等于8的话,会将该链表更新会红黑树。
c: 将key value封装成Node对象将其插入到链表或者红黑树中后,在判断是否需要扩容,如果需要就进行扩容的操作,如果不需要扩容那么就退出put方法。返回执行的操作。
ps:这里你可能会问为什么是在链表长度大于等于8的情况下会进行转化,其实也就是为了减少在链表中的查询时间。在链表中的查询的时间复杂度为O(n/2),而红黑树的查询的时间复杂度为O(log(n))在。如果长度为8的话,那么链表查询时间复杂度为4,为红黑树的时间复杂度为3。但是如果在小于8的人情况下并不会有太多的时间复杂度的提升,反而会因为转换为红黑树的过程降低效率。
 

8.HashMap解决hash冲突的方法?

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

        1.开放地址法:也称线性探测法,就是从发生冲突的那个位置,按照一定次序从Hash表中找到一个空闲的位置, 把发生冲突的元素存入到这个位置。而在java种ThreadLocal就用到了线性探测法,来解决Hash冲突。

        2.链式寻址法:通过单向链表的方式来解决哈希冲突,Hashmap就是用了这个方法。(但会存在链表过长增加遍历时间)

        3.再哈希法:key通过某个哈希函数计算得到冲突的时候,再次使用哈希函数的方法对key哈希一直运算直到不产生冲突为止 (耗时间,性能会有影响)

        4.建立公共溢出区:就是把Hash表分为基本表和溢出表两个部分,凡是存在冲突的元素,一律放到溢出表中

        而HashMap处理hash冲突的方法就是连地址法,JDK1.7中对于存在冲突的key,HashMap把这些key组成一个单向链表,然后采用尾插法把这个key保存到链表的尾部,然后还会使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均;,JDK1.8版本中是通过链式寻址法以及红黑树来解决Hash冲突的问题,其中红黑树是为了优化Hash表的链表过长导致时间复杂度增加的问题,当链表长度大于等于8并且Hash表的容量大于64的时候,再向链表添加元素,就会触发链表向红黑树的一个转化。

9.HashMap的get方法?

        1.首先对传入值keya进行hash运算,依然是高位和地位进行^运算,得到的hash值和n-1进行&运算,最终得到索引值

        2.首先判断该数组是否为空,是否长度为0,当前索引位置的第一个元素是否为null,满足任意一个条件返回null,结束

        3.将传入值keya的hash值与当前索引位第一个元素的hash值进行比较,一样则继续比较是否为相同引用,或者equals是否相等,若相等,返回其value,结束。

        4.不相等的话,会出现两种可能性,如果当前索引位置后面还有元素,则可能在后面,如果没有元素了,直接退出,返回null,结束。

        5.当前索引位置不止1个元素的情况下,首先判断,当前索引位置的第一个节点是否为树节点,如果是树节点,在红黑树里面进行遍历。

        6.如果不是树节点,那么就是链表,在while中进行遍历,判断当前元素是否传入值相等,不等则循环到链表的最后一个元素为止。相等则返回该value.

10.HashMap存储流程

       ①元素到达,先判断HashMap是否为空或到达扩容阈值,是则先进行扩容操作;

       ②使用hash函数确定元素所在数组的索引,若此时该位置为空,直接插入;

       ③若此时位置不为空,若存储为链表,判断插入后是否大于8,大于则转变为红黑树,否则直接插入即可;

       ④插入元素后查看HashMap是否达到扩容阈值,若达到则对其进行扩容。
 

11.HashMap什么时候进行扩容?

HashMap进行扩容取决于以下两个元素:

Capacity:HashMap当前长度。

LoadFactor:负载因子,默认值0.75f。
     当Map中的元素个数(包括数组,链表和红黑树中)超过了16*0.75=12之后开始扩容。
     具体怎么进行扩容呢?将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing ,因为它将会调用hash方法找到新的bucket位置。

        JDK1.8中,当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,结点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的结点个数低于6,也会再把树转换为链表。

12.JDK1.7扩容的时候为什么要重新Hash呢,为什么不直接复制过去?

     是因为长度扩大以后,Hash的规则也随之改变。比如原来长度(Length)是8,你位运算出来的值是2 ,新的长度是16你位运算出来的值明显不一样了。

13.为什么HashMap的底层数组长度为何总是2的n次方?

        1.当length为2的N次方的时候,h & (length-1) = h % length,因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高

        2.当length为2的N次方的时候,数据分布均匀,减少冲突

14.那么为什么默认是16呢?怎么不是4?不是8?

        这个默认容量的选择,JDK并没有给出官方解释,那么这应该就是个经验值,既然一定要设置一个默认的2^n 作为初始值,那么就需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。所以,16就作为一个经验值被采用了。

        选择16是为了服务于从Key映射到index的Hash算法,在性能和内存的使用上取平衡,实现一个尽量均匀分布的Hash函数,选取16,是通过位运算的方法进行求取的。位运算求HashMap函数,位运算:index=Hash数据(Key)&(length-1),进行的是二进制的与(&)运算。

        长度为16时,可能会出现各种结果出现,因为(Length-1=15)  15的二进制1111,可以出现所有结果,在Length为16的前提下,只要输入的Hash数据本身分布均匀,Hash算法的结果就是均匀的。

15.为什么重写equals,必须重写hashCode方法?

1.为了提高效率,采取重写hashcode方法,先进行hashcode比较,如果不同,那么就没必要在进行equals的比较了,这样就大大减少了equals比较的次数,这对比需要比较的数量很大的效率提高是很明显的,一个很好的例子就是在集合中的使用。

2.为了保证同一个对象,保证在equals相同的情况下hashcode值必定相同,如果重写了equals而未重写hashcode方法,可能就会出现两个没有关系的对象equals相同的(因为equal都是根据对象的特征进行重写的),但hashcode确实不相同的。

重写后两个方法的关系:

equals()相等的两个对象,hashcode()一定相等;
hashcode()不等,一定能推出equals()也不等;
hashcode()相等,equals()可能相等,也可能不等。
所以先进行hashcode()判断,不等就不用equals()方法了。
但equels是是根据对象的特征进行重写的,有时候特征相同,但hash值不同,也不是一个对象。 所以两个都重写才能保障是同一个对象。


 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值