集合之HashMap最全面试题

本文深入剖析HashMap的底层数据结构、hash计算规则、初始化大小选择、主要参数、哈希冲突解决、同步机制、扩容策略等核心内容,揭示其高效运作的秘密。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

后期会持续更新的哦

1.HashMap的底层数据结构?

HashMap底层实现数据结构为数组+链表的形式,JDK8及其以后的版本中使用了数组+链表+红黑树实现,解决了链表太长导致的查询速度变慢的问题。
   简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。HashMap通过key的HashCode经过扰动函数处理过后得到Hash值,然后通过位运算判断当前元素存放的位置,如果当前位置存在元素的话,就判断该元素与要存入的元素的hash值以及key是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。当Map中的元素总数超过Entry数组的0.75时,触发扩容操作,为了减少链表长度,元素分配更均匀。

2.hash的计算规则?

2.1为什么这里把key的hashcode取出来,然后把它右移16位,然后取异或?

因为int是4个字节,也就是32位,大概是有40亿的空间,如果哈希函数运用的比较松散,一般是很难出现哈希碰撞的。但是现实中一个长度为40亿的数组内存是放不下的并且HashMap在扩容前的数组的默认初始值为16,因此直接拿Hashcode值来用是不现实的。因此需要做一些运算。我们右移16位也即是把高位的数据右移到低位的16位,然后与自己做异或,那就是把高位和低位的数据进行混合,以此来加大低位的随机性,同时混合后的低位掺杂了高位的特征,这样高位的信息也被变相保存了下来。这么做主要是从速度,功效和质量来考虑的。

3.默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?

hash运算的过程其实就是对目标元素的Key进行hashcode,再对Map的容量进行取模,而JDK 的工程师为了提升取模的效率,使用位运算代替了取模运算,这就要求Map的容量一定得是2的幂。
      HashMap的容量为什么是2的n次幂,和这个(n - 1) & hash的计算方法有着千丝万缕的关系,符号&是按位与的计算,这是位运算,计算机能直接运算,特别高效,按位与&的计算方法是,只有当对应位置的数据都为1时,运算结果也为1,当HashMap的容量是2的n次幂时,(n-1)的2进制也就是1111111***111这样形式的,这样与添加元素的hash值进行位运算时,能够(充分的散列),使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞。

4.HashMap的主要参数都有哪些?

DEFAULT_INITIAL_CAPACITY:默认的初始化容量,1<<4位运算的结果是16,也就是默认的初始化容量为16。当然如果对要存储的数据有一个估计值,最好在初始化的时候显示的指定容量大小,减少扩容时的数据搬移等带来的效率消耗。同时,容量大小需要是2的整数倍。

MAXIMUM_CAPACITY:容量的最大值,1 << 30位,2的30次幂。

DEFAULT_LOAD_FACTOR:默认的加载因子,设计者认为这个数值是基于时间和空间消耗上最好的数值。这个值和容量的乘积是一个很重要的数值,也就是阈值,当达到这个值时候会产生扩容,扩容的大小大约为原来的二倍。

TREEIFY_THRESHOLD:因为jdk8以后,HashMap底层的存储结构改为了数组+链表+红黑树的存储结构(之前是数组+链表),刚开始存储元素产生碰撞时会在碰撞的数组后面挂上一个链表,当链表长度大于这个参数时,链表就可能会转化为红黑树,为什么是可能后面还有一个参数,需要他们两个都满足的时候才会转化。

UNTREEIFY_THRESHOLD:介绍上面的参数时,我们知道当长度过大时可能会产生从链表到红黑树的转化,但是,元素不仅仅只能添加还可以删除,或者另一种情况,扩容后该数组槽位置上的元素数据不是很多了,还使用红黑树的结构就会很浪费,所以这时就可以把红黑树结构变回链表结构,什么时候变,就是元素数量等于这个值也就是6的时候变回来(元素数量指的是一个数组槽内的数量,不是HashMap中所有元素的数量)。

MIN_TREEIFY_CAPACITY:链表树化的一个标准,前面说过当数组槽内的元素数量大于8时可能会转化为红黑树,之所以说是可能就是因为这个值,当数组的长度小于这个值的时候,会先去进行扩容,扩容之后就有很大的可能让数组槽内的数据可以更分散一些了,也就不用转化数组槽后的存储结构了。当然,长度大于这个值并且槽内数据大于8时,那就转化为红黑树吧。

5.哈希冲突及解决方法

如果两个不同对象的hashCode相同,这种现象称为hash冲突。有以下的方式可以解决哈希冲突:

开放定址法 开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

链地址法链地址法 将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。

再哈希法 当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。

建立公共溢出区 将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。

6.HashMap如何有效减少碰撞?

扰动函数:促使元素位置分布均匀,减少碰撞几率
使用final对象,并采用合适的equals()和hashCode()方法

7.HashMap可以实现同步吗?

HashMap可以通过下面的语句进行同步:
     Map m = Collections.synchronizeMap(hashMap);

8.为啥我们重写equals方法的时候需要重写hashCode方法呢?

hashmap中value的查找是通过 key 的 hashcode 来查找,所以对自己的对象必须重写 hashcode 方法通过 hashcode 找到对象地址后会用 equals 比较你传入的对象和 hashmap 中的 key 对象是否相同,因此还要重写 equals。

9.HashMap什么时候进行扩容?它是怎么扩容的呢?

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

Capacity:HashMap当前长度。
LoadFactor:负载因子,默认值0.75f。

当现有容量⼤于总容量 * 负载因⼦时,HashMap 扩容规则为当前容量翻倍.

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

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

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

11.为什么hashmap是线程不安全的?

HashMap在put的时候,插入的元素超过了容量(由capacity当前长度和loadfactor负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的。

12.有什么线程安全的类代替么?

1.使⽤Collections.synchronizedMap(Map)创建线程安全的map集合;
2.Hashtable
3.ConcurrentHashMap

在这样的场景,我们⼀般都会使⽤HashTable或者ConcurrentHashMap,但是因为前者的并发度的原因基本上没啥使⽤场景了,所以存在线程不安全的场景我们都使⽤的是ConcurrentHashMap。
HashTable我看过他的源码,很简单粗暴,直接在⽅法上锁,并发度很低,最多同时允许⼀个线程访问,ConcurrentHashMap就好很多了,1.7和1.8有较⼤的不同,不过并发度都⽐前者好太多了。

参考:
https://blog.csdn.net/pluto321456/article/details/107552469

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值