【Java自顶向下】HashMap面试题(2021最新版)

1.HashMap的底层数据结构?

HashMap是我们非常常用的数据结构,由数组和链表组合构成的数据结构。数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node。因为他本身所有的位置都为null,在put插入的时候会根据key的hash去计算一个index值。

Java 7 中的HashMap的底层是一个数组+链表的设计,每个hash值的第一个值放在数组里,之后经过hash运算得到相同的hash值锁定数组下标,数组中的每一个元素都是一个单向链表(碰撞,列表)

2.为啥需要链表,链表又是怎么样子的呢?

我们都知道数组长度是有限的,在有限的长度里面我们使用哈希,哈希本身是通过Hash函数计算出来的,结果就存在概率性,两个不同的key,hash有一定的概率会一样,那就形成了链表。每一个节点都会保存自身的hash、key、value、以及下个节点。

3.新的Entry节点在插入链表的时候,是怎么插入的么?

java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。但是,在java8之后,都用尾部插入了

4.Java7中的HashMap和Java8中的HashMap的区别?

Java 7 中的HashMap的底层是一个数组+链表的设计,每个hash值的第一个值放在数组里,之后经过hash运算得到相同的hash值锁定数组下标,数组中的每一个元素都是一个单向链表(碰撞,列表)

5.java8之后为啥改为尾部插入呢?

在插入时采用尾插法(1.7是头插法),在并发场景下导致链表成环的问题。而在jdk1.8中采用尾插入法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。


什么是头插法和尾插法
在这里插入图片描述
头插法带来的环状

在这里插入图片描述

6.为啥会线程不安全?

通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。

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

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

HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,粒度比较大。

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

ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。

我们一般都会使用HashTable或者ConcurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是ConcurrentHashMap。

HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,ConcurrentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度都比前者好太多了。

8.那你知道ConcurrentHashMap的分段锁的实现原理吗?

ConcurrentHashMap成员变量使用volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现 赋值操作,多线程操作只会锁住当前操作索引的节点。 如下图,线程A锁住A节点所在链表,线程B锁住B节点所在链表,操作互不干涉。

在这里插入图片描述

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

是16,因为在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。 只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。 这是为了实现均匀分布。

10.HashMap的扩容方式?负载因子是多少?为什是这么多?

capacity:当前数组容量,始终保持 $2^n$,可以扩容,扩容后数组大小为当前的 2 倍。

loadFactor:负载因子,默认为 0.75。

threshold:扩容的阈值,等于 capacity * loadFactor。

Java 8 中的HashMap的底层是数组+链表+红黑树的方式实现。

改进的是在数据量较大的情况下(Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树)单向链表的时间复杂是O(N),采用红黑树将时间复杂度降到O(logN)。

那么, 如果我们在删除容器中的元素的时候,删到多少才使得红黑树的存储结构转为链表呢?答案是6

也就是说,在JDK8之后,创建HashMap对象=>添加数组中同一个位置元素超过8个=>该位置链表转为红黑树=>删除数组中同一个位置元素少于6个=>该位置红黑树转为列表。

最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树), 否则,若桶内元素太多时,则直接扩容,而不是树形化。为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD。

也就是说,当数组中某个桶中的元素大于8,小于64时,使用容量进行两倍扩容(其实就是用扩容代替链表转红黑树操作)。当数组中某个桶中的元素大于8且大于64时,链表转红黑树操作。

hashMap并不是在链表元素个数大于8就一定会转换为红黑树,而是先考虑扩容,扩容达到默认限制后才转换

hashMap的红黑树不一定小于等于6的时候就会转换为链表,而是只有在resize的时候才会根据 UNTREEIFY_THRESHOLD(6) 进行转换

在这里插入图片描述

11.HashMap是怎么处理hash碰撞的?

Java中HashMap是利用“拉链法”处理HashCode的碰撞问题。

在调用HashMap的put方法或get方法时,都会首先调用hashcode方法,去查找相关的key,当有冲突时,再调用equals方法。hashMap基于hasing原理,我们通过put和get方法存取对象。

当我们将键值对传递给put方法时,他调用键对象的hashCode()方法来计算hashCode,然后找到bucket(哈希桶)位置来存储对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。

HashMap使用链表来解决碰撞问题,当碰撞发生了,对象将会存储在链表的下一个节点中。hashMap在每个链表节点存储键值对对象。当两个不同的键却有相同的hashCode时,他们会存储在同一个bucket位置的链表中。

键对象的equals()来找到键值对。

12.提到HashMap的初始化,那HashMap怎么设定初始容量大小的吗?

一般如果new HashMap() 不传值,默认大小是16,负载因子是0.75, 如果自己传入初始大小k,初始化大小为大于k的2的整数次方,例如如果传10,大小为16

13.你了解HashMap在JDK8中的数据插入原理吗?

在这里插入图片描述

14.HashMap的哈希函数怎么设计的?

hash函数是先拿到通过key 的hashcode,是32位的int值,然后让hashcode的高16位和低16位进行异或操作。

15.为什么这么设计Hash函数?

这个也叫扰动函数,这么设计有二点原因:

一定要尽可能降低hash碰撞,越分散越好;

算法一定要尽可能高效,因为这是高频操作, 因此采用位运算;

16.为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?

因为key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。

int值范围为-2147483648~2147483647, 前后加起来大概40亿的映射空间。

只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。

但问题是一个40亿长度的数组,内存是放不下的。

因为如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。

源码中模运算就是把散列值和数组长度-1做一个"与"操作,位运算比%运算要快。

bucketIndex = indexFor(hash, table.length);

static int indexFor(int h, int length) {
     return h & (length-1);
}

便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。


HashMap与运算
在这里插入图片描述


HashMap碰撞
在这里插入图片描述

17.JDK8对hash函数做了优化,JDK8还有别的优化吗?

1. 数组+链表改成了数组+链表或红黑树;
2. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的 后继节点,1.8遍历链表,将元素放置到链表的最后;
3. 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
4. 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;

18.你分别跟我讲讲为什么要做这几点优化?

防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);

因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;

A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环,如上图所示

扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?

这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,怎么理解呢?

扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为0000 1111,扩容为32后的二进制就高位多了1,为0001 1111。

因为是& 运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)
HashMap中的与运算
在这里插入图片描述

19.HashMap内部节点是有序的吗?

是无序的,根据hash值随机插入

20.那有没有有序的Map?

LinkedHashMap 和 TreeMap

21.跟我讲讲LinkedHashMap怎么实现有序的?

LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。

  • 6
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
Q: HashMap如何实现的,能否简单描述一下? A: HashMap是基于哈希表的数据结构,它是由数组和链表构成的。当我们将一个键值对存储在HashMap中时,会首先根据键的哈希值计算出它在数组中的下标,如果该下标处已经有其他键值对了,那么它们就会以链表的形式存储在该位置,新的键值对就会被添加到链表的末尾。如果链表长度过长,就会转换成红黑树,以提高查询效率。 Q: HashMap的put方法是怎样实现的? A: HashMap的put方法首先会根据键的哈希值计算出它在数组中的下标,然后在该位置进行插入操作。如果该位置已经有其他键值对了,就会遍历链表或红黑树,查找是否已经存在该键,如果存在就更新它的值,如果不存在就将它添加到链表或红黑树的末尾。如果链表长度过长,就会转换成红黑树。 Q: HashMap的get方法是怎样实现的? A: HashMap的get方法首先会根据键的哈希值计算出它在数组中的下标,然后在该位置进行查找操作。如果该位置是空的,就返回null;如果该位置已经有其他键值对了,就遍历链表或红黑树,查找是否存在该键,如果存在就返回它的值,如果不存在就返回null。 Q: HashMap的扩容是怎样实现的? A: HashMap的扩容是在当前容量达到阈值时进行的。扩容后的容量是原来容量的两倍,然后将原来的键值对重新分配到新的数组中。具体操作是遍历原来的数组,将每个键值对重新计算哈希值,并根据新的哈希值找到它在新数组中的位置,然后将它插入到该位置。如果该位置已经有其他键值对了,就遍历链表或红黑树,查找是否已经存在该键,如果存在就更新它的值,如果不存在就将它添加到链表或红黑树的末尾。如果链表长度过长,就会转换成红黑树。 Q: HashMap的并发问题怎样解决? A: HashMap是非线程安全的,如果在多线程环境下使用,就会出现并发问题Java提供了ConcurrentHashMap类来解决这个问题,它是线程安全的。ConcurrentHashMap使用分段锁来保证线程安全,将整个HashMap分成多个段,每个段都有自己的锁,不同的线程可以同时访问不同的段,从而提高了并发度。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

_之桐_

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

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

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

打赏作者

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

抵扣说明:

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

余额充值