《面试王者系列》HashMap 之 面试题大全

100 阅读8分钟

您的点赞是我更新的动力,欢迎关注,免费答疑
有挺多大中小厂内推通道,正式和外包都有,欢迎找我内推

重点关注

说一下HashMap的原理

为什么要用数组 + 链表?

添加数据到链表中,是用头插法,还是尾插法,为什么?

请说一下扩容机制

HahsMap是线程安全的吗?

说一下HashMap的原理

HahsMap的数据结构由数组 + 链表和红黑树 组成

每个存储在HashMap里的数据,都是以key、value这种键值对的形式存储在链表或者红黑树里的

它的核心就是利用key计算出来的hash值和数组的长度进行取模,来确定数据在数组中的位置

如果这个位置已经存在数据,说明出现哈希冲突了,就会在对应位置上形成一条链表

如果某一个位置的哈希碰撞很多,链表太长是会影响查询效率的,所以链表长度达到一个阈值之后,会转成红黑树来提高查询效率

最后就是扩容了,数据越多,数组里空格的地方也就越少,也越容易发生哈希冲突

所以当数据总数达到一个阈值之后(默认是当前容量的75%),就会触发扩容,每次扩容,都是原来的2倍

为什么要用数组 + 链表

首先数组搭配hash取模的效率是最高的,o(1)的时间复杂度

但是哈希算法会有哈希冲突的问题

链表就是来解决这个问题的,当出现哈希冲突,会在数组的对应位置形成一条链表

用ArrayList或者LinkedList代替数组可以吗?

ArrayList不行,LinkedList可以,但效率太低

ArrayList不行是因为它每次扩容都是1.5倍的,而HashMap要求容量必须是2的n次方

LinkedList虽然可以,但它是链表结构的,就算hash取模已经得到下标了,LinkedList的底层依然需要遍历到这个位置才能拿到数据,时间复杂度是o(n)。

所以不建议使用它们。

什么是哈希冲突?

不同的关键字,通过一个特殊算法计算出来的地址,都指向了哈希表上的同一个位置,这就是哈希冲突。

解决哈希冲突,为什么不能直接用红黑树,而是先用链表?

维护一颗红黑树的成本比链表要大很多,它占用空间比链表大,而且每次添加数据还得左旋、右旋、变色这些操作让叶子保持平衡,来保证它查询效率的稳定性。

而链表长度低于8的时候,查询效率并不比红黑树低多少

但是添加数据的效率和占用空间方面要比红黑树优秀很多

所以长度低于8的时候,综合算下来链表比红黑树性价比高,这就是先用链表的原因

哈希冲突还有哪些解决方法?

有4种比较出名的解决方案

开放地址法、链地址法、再哈希法、公共溢出区域法

Tip:具体的大家可以自行去搜索一下

什么时候转红黑树?

在调用put()方法新增数据时

会判断对应下标位置的链表长度是否达到8,且容量也达到64,同时满足这俩条件,会把链表转成红黑树。

如果链表长度达到8,但容量没有达到64,就扩容2倍。

为什么链表达到 8 和 容量达到 64 才转红黑树?

首先是链表长度达到8,作者在代码中有注释,是根据泊松分布定理,综合计算出来8是比较合适的数值

然后是容量达到64,我个人的思考是因为作者经过严谨的计算,认为绝大多数情况下链表长度达到8已经是非常困难,所以容量在64之前,通过扩容的操作让哈希冲突的几率减小2倍,是性价比较高的做法。

Tip:每次添加数据时,当链表长度达到8,而容量没有达到64的情况下,都会进行扩容2倍的操作,按最小初始容量2来计算,链表的长度也最多到13,问题不大的。

红黑树会转回链表吗?在什么时候?

红黑树的长度退回到6的时候,会转回链表。

Tip:维护一颗红黑树的成本比链表要大很多,它占用空间大,每次添加数据还得一通操作让叶子保持平衡,所以长度低于6的时候,再去维护一个树,不划算。

红黑树可以用二叉树代替吗?

可以,但二叉树在极端情况下,可能会形成一条很长的枝干,这就和链表没有区别了,性能不稳定。

而红黑树是一种平衡树,不会像二叉树一样形成一条很长的枝干,性能稳定。

所以不建议使用二叉树。

添加数据到链表中,是用头插法,还是尾插法,为什么?

JDK7的时候,使用的是头插法,JDK8以后,优化成了尾插法

因为头插法,在多线程并发扩容的时候,可能会出现环形链表的情况

到了JDK8,改成了使用尾插法,第一可以避免环形链表的发生,第二尾插法扩容时不会改变数据在链表中的顺序。

拓展知识

JDK7的扩容核心代码

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    // 遍历旧数组
    for (Entry<K,V> e : table) {
        // 遍历链表
        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.next = newTable[i];
            newTable[i] = e;
            // 继续向后遍历,直到next=null
            e = next;
        }
    }
}

举个栗子

原数组下标1的地方存储着 A和B两条数据

我们假设,在扩容后,A和B会迁移到新数组下标2的位置

然后有2个线程同时进入到transfer()方法里,t1 和 t2

t1 执行到 while(null != e) { 这个循环内,CPU时间片用完了,挂起,此时e=A

t2 开始执行,第一轮遍历到A,把A放到新数组下标2的位置,第二轮遍历到B,由于使用的是头插法,所以B会插在A的前面,顺序也就变成了 B -> A

这个时候,t2的CPU时间片结束,挂起

t1 继续执行,注意了,执行到这行代码的时候 e.next = newTable[i]; ,此时的newTable[i]已经被t2线程设置成 B ,而B.next又是A,一个环,就这样形成了

由于此时还在while循环体内,t1线程会进入死循环

请说一下扩容机制

有两种情况会触发扩容

一种是在put()添加数据时,size 大于扩容阈值,会触发扩容

还有一种也是在put()添加数据时,key对应位置上的链表长度达到8,但是容量却没有达到64,也会触发扩容,目的是通过扩容的方式,减小这个位置再次发生哈希冲突的几率

容量有最大值限定,在小于这个最大值的时候,才会进行扩容,每次扩容都是在原来的基础上*2

最后根据新的容量创建新数组,把旧数组里面的数据,重新计算位置,然后迁移到新数组里,扩容就完成了。

为什么每次扩容都是原基础的2倍?

因为HashMap的容量必须是2的n次方,乘2可以保证扩容完成后,容量还是2的n次方

Tip:其实也可以是乘4、8、16 等等这些2的n次方的倍数,2倍应该是作者综合计算后取的一个性价比最优的数值

为什么容量必须是2的n次方?

HashMap是通过 位与& 运算符来 取模 的,因为 位与& 运算符 比 取余% 运算符快很多

但使用 位与& 运算符的前提是,容量必须是2的n次方

而且容量等于2的n次方也有助于减少哈希碰撞的几率

为什么扩容后,需要重新计算位置?

数据存放在数组当中的位置,是基于 数组长度关键字的hash值 取模计算出来的

扩容后的新数组长度增加了2倍,取模计算出来的数值,也很有可能和原来不一样了

举个栗子

扩容前,容量是 8 ,算出来的下标是0

扩容后,容量是 16,算出来的下标是9

这明显不一样了,所以扩容后,得重新计算在数据在新数组中的位置。

HahsMap是线程安全的吗?

不是线程安全的

虽然JDK8之后用了尾插法,不会出现环形链表了

但依然有很多线程安全的问题,比如添加数据的时候,被另一个线程覆盖,或者同一个key两次查询,结果不一样,因为另外一个线程改了这个key的数据。

多线程的场景建议使用HashTable或者ConcurrentHashMap