Hashmap常见面试题
HashMap常见面试题
你了解数据结构中的HashMap么?能跟我聊聊他的结构和底层原理么?
HashMap是我们非常常用的数据结构,由数组和链表组合构成的数据结构。
大概如下,数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry,在Java8中叫Node。
因为他本身所有的位置都为null,在put插入的时候会根据key的hash去计算一个index值。
就比如我put(”Silince“,520)
,我插入了为”Silince“的元素,这个时候我们会通过哈希函数计算出插入的位置,计算出来index是2那结果为 $hash(“Silince”)= 2$
你提到了还有链表,为啥需要链表,链表又是怎么样子的呢?
我们都知道数组长度是有限的,在有限的长度里面我们使用哈希,哈希本身就存在概率性,就是”Silince“和”Silence“我们都去hash有一定的概率会一样,就像上面的情况我再次哈希”Silence“极端情况也会hash到一个值上,那就形成了链表。
每一个节点都会保存自身的hash、key、value、以及下个节点,我看看Node的源码。
说到链表我想问一下,你知道新的Entry节点在插入链表的时候,是怎么插入的么?
java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,就像上面的例子一样,因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。
但是,在java8之后,都是所用尾部插入了。
为啥改为尾部插入呢?
有人认为是作者随性而为,没啥luan用,其实不然,其中暗藏玄机
首先我们看下HashMap的扩容机制:数组容量是有限的,数据多次插入的,到达一定的数量就会进行扩容,也就是resize。
什么时候resize呢?
有两个因素:
- Capacity:HashMap当前长度。
- LoadFactor:负载因子,默认值0.75f。
怎么理解呢,就比如当前的容量大小为100,当你存进第76个的时候,判断发现需要进行resize了,那就进行扩容,但是HashMap的扩容也不是简单的扩大点容量这么简单的。
扩容?它是怎么扩容的呢?
分为两步
- 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
- ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
为什么要重新Hash呢,直接复制过去不香么?
因为长度扩大以后,Hash的规则也随之改变。
Hash的公式: $index = HashCode(Key) \& (Length - 1)$
原来长度(Length)是8你位运算出来的值是2 ,新的长度是16你位运算出来的值明显不一样了。
说完扩容机制我们言归正传,为啥之前用头插法,java8之后改成尾插了呢?
如果两个线程同时触发扩容,在移动节点时会导致一个链表中的2个节点相互引用,从而生成环形链表。
我先举个例子吧,我们现在往一个容量大小为2的put两个值,负载因子是0.75。2*0.75 = 1
所以插入第二个的时候就要resize了
现在我们要在容量为2的容器里面用不同线程插入A,B,C,假如我们在resize之前打个断点,那意味着数据都插入了但是还没resize那扩容前可能是这样的。
我们可以看到链表的指向A->B->C
⚠️:A的下一个指针是指向B的。头插法代码在resize()方法中体现
因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
就可能出现下面的情况,大家发现问题没有?
B的下一个指针指向了A
一旦几个线程都调整完成,就可能出现环形链表
如果这个时候去取值,悲剧就出现了——Infinite Loop。
小伙子有点东西呀,但是你都都说了头插是JDK1.7的那1.8的尾插是怎么样的呢?
因为java8之后链表有红黑树的部分,大家可以看到代码已经多了很多if else的逻辑判断了,红黑树的引入巧妙的将原本O(n)的时间复杂度降低到了O(logn)。
使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。
就是说原本是A->B,在扩容后那个链表还是A->B。
Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。
Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。
那是不是意味着Java8就可以把HashMap用在多线程中呢?
我认为即使不会出现死循环,但是通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。
那我问你HashMap的默认初始化长度是多少?
我记得我在看源码的时候初始化大小是16
你那知道为啥是16么?
在JDK1.8的 236 行有1«4就是16,为啥用位运算呢?直接写16不好么?
这样是为了位运算的方便,位与运算比算数计算的效率高了很多,之所以选择16,是为了服务将Key映射到index的算法。
我前面说了所有的key我们都会拿到他的hash,但是我们怎么尽可能的得到一个均匀分布的hash呢?
是的我们通过Key的HashCode值去做位运算。
- 假如key为”Silince“的十进制为766132那二进制就是 1011 1011 0000 1011 0100
-
根据 index的计算公式:index = HashCode(Key) & (Length- 1)
- 15的的二进制是1111,那1011 1011 0000 1011 0100 &1111 十进制就是4
用按位与运算
效果与取模
一样,性能却提高了不少!
那为啥用16不用别的呢?
这是为了实现均匀分布。
因为在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。
只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
16是2的幂,8也是,32也是,为啥偏偏选了16?
我觉得就是一个经验值,定义16没有很特别的原因,只要是2次幂,其实用 8 和 32 都差不多。
用16只是因为作者认为16这个初始容量是能符合常用而已。
Hashmap中的链表大小超过八个时会自动转化为红黑树,当删除小于六时重新变为链表,为啥呢?
根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。
为啥我们重写equals方法的时候需要重写hashCode方法呢?你能用HashMap给我举个例子么?
因为在java中,所有的对象都是继承于Object类。Ojbect类中有两个方法equals、hashCode,这两个方法都是用来比较两个对象是否相等的。
在未重写equals方法我们是继承了object的equals方法(相当于==),那里的 equals是比较两个对象的内存地址,显然我们new了2个对象内存地址肯定不一样
- 对于值对象,Object.equals()比较的是两个对象的值
- 对于引用对象,Object.equals()比较的是两个对象的地址
大家是否还记得我说的HashMap是通过key的hashCode去寻找index的,那index一样就形成链表了,也就是说”Silince“和”Silence“的index都可能是2,在一个链表上的。
我们去get的时候,他就是根据key去hash然后计算出index,找到了2,那我怎么找到具体的”Silince“还是”Silence“呢?
equals!是的,所以如果我们对equals方法进行了重写,建议一定要对hashCode方法重写,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值。
不然一个链表的对象,你哪里知道你要找的是哪个,到时候发现hashCode都一样,这不是完犊子嘛。
可以可以小伙子,我记得你上面说过他是线程不安全的,那你能跟我聊聊你们是怎么处理HashMap在线程安全的场景么?
面试官,在这样的场景,我们一般都会使用HashTable或者CurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是CorruentHashMap。
HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,currentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度都比前者好太多了。
HashMap为什么线程不安全 🤔
jdk1.7中的HashMap
在jdk1.8中对HashMap做了很多优化,这里先分析在jdk1.7中的问题,相信大家都知道在jdk1.7多线程环境下HashMap容易出现死循环,这里我们先用代码来模拟出现死循环的情况:
/**
* @description: jdk1.7中的HashMap 扩容造成死循环分析过程
* @author: Silince
* @create: 2021-01-17 20:26
**/
public class HashMapTest {
public static void main(String[] args) {
HashMapThread thread0 = new HashMapThread();
HashMapThread thread1 = new HashMapThread();
HashMapThread thread2 = new HashMapThread();
HashMapThread thread3 = new HashMapThread();
HashMapThread thread4 = new HashMapThread();
thread0.start();
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
class HashMapThread extends Thread{
private static AtomicInteger ai = new AtomicInteger();
private static Map<Integer,Integer> map = new HashMap<>();
@Override
public void run() {
map.put(ai.get(),ai.get());
ai.incrementAndGet();
}
}
上述代码比较简单,就是开多个线程不断进行put操作,并且HashMap与AtomicInteger都是全局共享的。在多运行几次该代码后,出现如下死循环情形:
其中有几次还会出现数组越界的情况:
这里我们着重分析为什么会出现死循环的情况,通过jps和jstack命名查看死循环情况,结果如下:
从堆栈信息中可以看到出现死循环的位置,通过该信息可明确知道死循环发生在HashMap的扩容函数中,根源在transfer函数中,jdk1.7中HashMap的transfer函数如下:
该函数的主要作用:在对table进行扩容到newTable后,需要将原来数据转移到newTable中,这里可以看出在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转,这里也是形成死循环的关键点。下面进行详细分析。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length; // 获取新Entry数组的容量
// 遍历旧的Entry数组
for (Entry<K,V> e : table) {
// e的下一个entry不为null则循环(链表上的循环)
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); // 根据e的hash值和容量计算出index值
e.next = newTable[i]; // e的下一个Entry为新数组i位置上的值
newTable[i] = e; // e添加到新数组的i位置上
e = next; // next赋值给e
}
}
}
扩容造成死循环分析过程
前提条件:
- hash算法为简单的用key mod链表的大小。
- 最开始hash表size=2,key=3,7,5,则都在table[1]中。
- 然后进行resize,使size变成4。
未resize前的数据结构如下:
如果在单线程环境下,最后的结果如下:
这里的转移过程,不再进行详述,只要理解transfer函数在做什么,其转移过程以及如何对链表进行反转应该不难。
然后在多线程环境下,假设有两个线程A和B都在进行put操作。线程A在执行到transfer函数中第11行代码处挂起(第一次循环),因为该函数在这里分析的地位非常重要,因此再次贴出来。
此时线程A中运行结果如下:
线程A挂起后,此时线程B正常执行,此时B线程还是为以下状态:
完成resize操作后的结果如下:
这里需要特别注意的点:由于线程B已经执行完毕,根据Java内存模型,现在newTable和table中的Entry都是主存中最新值:7.next=3,3.next=null。
此时切换到线程A上,在线程A挂起时内存中值如下:e=3,next=7,newTable[3]=null,代码执行过程如下:
newTable[3]=e ----> newTable[3]=3
e=next ----> e=7
此时结果如下:
继续循环:
e=7
next=e.next ----> next=3【从主存中取值】
e.next=newTable[3] ----> e.next=3【从主存中取值】
newTable[3]=e ----> newTable[3]=7
e=next ----> e=3
结果如下:
再次进行循环:
e=3
next=e.next ----> next=null
e.next=newTable[3] ----> e.next=7 即:3.next=7
newTable[3]=e ----> newTable[3]=3
e=next ----> e=null
注意此次循环:e.next=7,而在上次循环中7.next=3,出现环形链表,并且此时e=null循环结束。
结果如下:
在后续操作中只要涉及轮询hashmap的数据结构,就会在这里发生死循环,造成悲剧。
扩容造成数据丢失分析过程
遵照上述分析过程,初始时:
线程A和线程B进行put操作,同样线程A挂起:
此时线程A的运行结果如下:
此时线程B已获得CPU时间片,并完成resize操作:
同样注意由于线程B执行完成,newTable和table都为最新值:5.next=null。
此时切换到线程A,在线程A挂起时:e=7,next=5,newTable[3]=null。
执行newtable[i]=e,就将7放在了table[3]的位置,此时next=5。接着进行下一次循环:
e=5
next=e.next ----> next=null,从主存中取值
e.next=newTable[1] ----> e.next=5,从主存中取值
newTable[1]=e ----> newTable[1]=5
e=next ----> e=null
将5放置在table[1]位置,此时e=null循环结束,3元素丢失,并形成环形链表。并在后续操作hashmap时造成死循环。
jdk1.8中HashMap
在jdk1.8中对HashMap进行了优化,在发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程的情况下仍然不安全,这里我们看jdk1.8中HashMap的put操作源码:
这是jdk1.8中HashMap中put操作的主函数, 注意第6行代码,如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入第6行代码中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/**
*1)transient Node<K,V>[] table; 表示存储Map集合中元素的数组。
*2)(tab = table) == null 表示将空的table赋值给tab,然后判断tab是否等于null,第一次肯定是null
*3)(n = tab.length) == 0 表示将数组的长度0赋值给n,然后判断n是否等于0,n等于0
*由于if判断使用双或,满足一个即可,则执行代码 n = (tab = resize()).length; 进行数组初始化。
*并将初始化好的数组长度赋值给n.
*4)执行完n = (tab = resize()).length,数组tab每个空间都是null
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**
*1)i = (n - 1) & hash 表示计算数组的索引赋值给i,即确定元素存放在哪个桶中
*2)p = tab[i = (n - 1) & hash]表示获取计算出的位置的数据赋值给节点p
*3) (p = tab[i = (n - 1) & hash]) == null 判断节点位置是否等于null,如果为null,则执行代码:tab[i] = newNode(hash, key, value, null);根据键值对创建新的节点放入该位置的桶中
* 小结:如果当前桶没有哈希碰撞冲突,则直接把键值对插入空间位置
*/
6 if ((p = tab[i = (n - 1) & hash]) == null)
//创建一个新的节点存入到桶中
tab[i] = newNode(hash, key, value, null);
else {
// 执行else说明tab[i]不等于null,表示这个位置已经有值了。
Node<K,V> e; K k;
/**
*比较桶中第一个元素(数组中的结点)的hash值和key是否相等
* 1)p.hash == hash :p.hash表示原来存在数据的hash值 hash表示后添加数据的hash值 比较两个hash值是否相等
* 说明:p表示tab[i],即 newNode(hash, key, value, null)方法返回的Node对象。
* Node<K,V> newNode(int hash, K key, V value, Node<K,V> next)
* {
* return new Node<>(hash, key, value, next);
* }
* 而在Node类中具有成员变量hash用来记录着之前数据的hash值的
* 2)(k = p.key) == key :p.key获取原来数据的key赋值给k key 表示后添加数据的key 比较两个key的地址值是否相等
* 3)key != null && key.equals(k):能够执行到这里说明两个key的地址值不相等,那么先判断后添加的key是否等于null,如果不等于null再调用equals方法判断两个key的内容是否相等
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 说明:两个元素哈希值相等,并且key的值也相等
// 将旧的元素整体对象赋值给e,用e来记录
e = p;
// ⚠️ hash值不相等或者key不相等;判断p是否为红黑树结点
else if (p instanceof TreeNode)
// 说明是该桶中是红黑树的头节点,把新节点放入红黑树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 说明是链表节点
else {
/**
* 1)如果是链表的话需要遍历到最后节点然后插入
* 2)采用循环遍历的方式,判断链表中是否有重复的key
*/
for (int binCount = 0; ; ++binCount) {
/**
* 1)e = p.next 获取p的下一个元素赋值给e
* 2)(e = p.next) == null 判断p.next是否等于null,等于null,说明p没有下一个元素,那么此时到达了链表的尾部,还没有找到重复的key,则说明HashMap没有包含该键;将该键值对插入链表中
*/
if ((e = p.next) == null) {
/**
* 1)创建一个新的节点插入到尾部 ⚠️⚠️⚠️ 尾插法!
* p.next = newNode(hash, key, value, null);
* Node<K,V> newNode(int hash, K key, V value, Node<K,V> next)
* {
* return new Node<>(hash, key, value, next);
* }
* 注意第四个参数next是null,因为当前元素插入到链表末尾了,那么下一个节点肯定是null
* 2)这种添加方式也满足链表数据结构的特点,每次向后添加新的元素
*/
p.next = newNode(hash, key, value, null);
/**
*1)节点添加完成之后判断此时节点个数是否大于TREEIFY_THRESHOLD临界值8,如果大于
* 则将链表转换为红黑树
*2)int binCount = 0 :表示for循环的初始化值。从0开始计数。记录着遍历节点的个数。值是0表示第一个节点,1表示第二个节点...
* TREEIFY_THRESHOLD - 1 = 7;
* 如果binCount的值是7(加上数组中的的一个元素,元素个数是8),此时转换红黑树
*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//转换为红黑树
treeifyBin(tab, hash);
// 跳出循环
break;
}
/**
*执行到这里说明e = p.next 不是null,不是最后一个元素。继续判断链表中结点的key值与插入的元素的key值是否相等
*/
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
/**
*要添加的元素和链表中的存在的元素的key相等了,则跳出for循环。不用再继续比较了
* 直接执行下面的if语句去替换去 if (e != null)
*/
break;
/**
*说明新添加的元素和当前节点不相等,继续查找下一个节点。
*用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
*/
p = e;
}
}
/**
*表示在桶中找到key值、hash值与插入元素相等的结点
*也就是说通过上面的操作找到了重复的键,所以这里就是把该键的值变为新的值,并返回旧值
*这里完成了put方法的修改功能
*/
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
//e.value 表示旧值 value表示新值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
//修改记录次数
++modCount;
// 判断实际大小是否大于threshold阈值,如果超过则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
小结
首先HashMap是线程不安全的,其主要体现:
- 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
- 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。
总结
HashMap绝对是最常问的集合之一,基本上所有点都要烂熟于心的那种,HashMap常见面试题:
- HashMap的底层数据结构?
- HashMap的存取原理?
- Java7和Java8的区别?
- 为啥会线程不安全?
- 有什么线程安全的类代替么?
- 默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?
- HashMap的扩容方式?负载因子是多少?为什是这么多?
- HashMap的主要参数都有哪些?
- HashMap是怎么处理hash碰撞的?
- hash的计算规则?
既已览卷至此,何不品评一二: