HashMap面试宝典
前言
本文源码分析基于jdk1.8
版本(持续更新中)
1、HashMap数据结构与工作原理
这是基础中的基础,这个都不能掌握,面试大概率要翻车。源码自己看,这里讲流程。
HashMap数据结构.png在Jdk1.8中,HashMap数据结构是数组+链表+红黑树,数组也叫做hash表,每条链表也叫做桶(bucket),红黑树是为了提高查询效率。
- 1、存放元素的时候会先根据key的hash值去计算元素下标,如果这个下标没有元素,就创建一个Node节点放进去;
- 2、如果数组下标有数据,先判断key是否相同,相同的话替换元素的value;不同的话插在链表的尾节点。注意:同一个链表中的节点不一定是发誓了hash冲突的,有可能hash值不同;
- 3、链表长度大于8,且数组长度大于64,会把链表转位红黑树,红黑树本质是一颗自平衡的二叉查找树,查找时间复杂度为o(logn);
- 4、元素容量size超过阈值会扩容;
下面这张美团技术画的图可以很清晰的表达整个流程。
工作流程(美团).png
2、HashMap如何解决hash碰撞(hash冲突)的?
拉链法。当存储元素出现hash冲突时,意味着hash值相同的多个元素要存储在数组中的同一个位置,这时候就通过一个单链表来解决,每次新增的元素插在尾节点。这个链表也叫做hash桶(bucket),注意:在同一个链表中的元素不能给说明一定是冲突的,有可能hash值不相同。
3、为什么数组容量必须是2^n
(初始化和扩容)?
为了让添加的元素均匀分布在HashMap的数组上,减少hash碰撞。
//put(),计算存储的元素的下标
//n是数组长度,默认16
i = hash & (n - 1)
这种求下标的做法和hash % n
取模运算是一样的,只是说&运算是操作的二进制数,在计算效率上更高一些,反正源码都很喜欢这种位运算。
我们在计算下标的时候当然是希望尽可能让元素分散到0~n-1
位置,这样可以减少冲突,让查询效率更高。下面就来看一下HashMap是怎么做到的。
hash是int类型,转换为2进制数是32位,为了简化,假设
hash=0101 0101,n-1= 15
这样就可以限制&运算的结果在0000~1111
之间,转为10进制数就是0~15
,是不是和求余运算的结果一致?如果n=17,n-1=0001 0000,这样&运算结果的低位全为0,数组中有很多位置利用不到,这样会出现大量的hash冲突。
结论:只有数组长度为2^n
,才能保证n-1的低位的值全为1,这样元素就可以更均匀的分散在数组上。
4、扰动函数
为了散列效果更好,减少碰撞,减少冲突。
在上面的&运算中,尽管已经让元素更分散了,但是还是存在一个问题,由于n-1的高位全为0,所以&运算的结果只和hash的低位有关,这样的话,发生hash冲突的次数会比较多。但是我们看HashMap源码,会发现已经通过重写hash方法优化了这一点。
//计算key的hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里并没有直接使用Object的hashCode()
方法,而是重写了这个散列方法。有些同学可能不太看得懂这里的位运算,我就给大家拆解开来看一下。
static int hash2(Object key) {
if ((key == null)) return 0;
int h = key.hashCode(); //计算hash值
int high = h >>> 16; //右移16位,那就只保留了h的高16位
int newHash = h ^ high; //异或运算,相同为0,不同为1
return newHash;
}
这样的话,大家应该能看懂了吧。下面借用一张图来说明上面的计算。
image.png
h >>> 16
只保留了h的高16位,h ^ high
是让h的高16位与低16位做异或运算,这样高16位与低16位都参与了hash的运算,使hash值更加不确定 降低了hash碰撞的概率。
5、树化的条件是什么?
网上很多具有误导性的文章说链表长度大于8就会转为红黑树,实际上是错误的。树化其实需要2个条件,链表长度为8且数组长度为64
存放元素树化.png 数组长度大于64才可以树化.png6、HashMap扩容是怎么做的?
扩容有3个触发时机,一个是初始化,也就是第一次put()
存放数据时,另一个是存储的元素数量大于阈值threshold
时;还有一个是树化的时候(这一点很多人应该不知道),最后都是调用resize()
方法完成扩容和数据迁移的。
如果你没看过hashmap扩容实现,你猜扩容是怎么实现的?难道和ArrayList一样,数组拷贝,把元素照般到新的数组中相同的位置就好了?实际上不是的,原来数组中的元素在扩容后只有2种选择,第一,在原来的位置;第二,在原来位置基础上再加上原来数组长度。这里先说结论,后面再源码分析。
再来回顾下,我们是通过如下方式计算元素下标的。记住一点,&运算算法:2个都是1,结果才为1,否则为0。
//n是数组长度,默认16
i = hash & (n - 1)
下面这幅图是扩容前后A、B元素的数组下标的计算过程(有区别的地方做了标示)。在扩容前A、B的hash值不一样,但是&运算后的下标却是一样的;扩容后发生了一个变化,就是n变成了2原来的2倍,变成2倍可以用左移1位表示,也就是从0000 1111(16)
变成0001 1111(32)
,那扩容后与运算,A在高位的第4位&运算结果为0;B在高位的第4位&运算结果为1;也就是说A还是在原来的位置,B在原来的位置(5),再往后移动16位,也就是B移动到21了。
图解元素移动.png这里的思路很巧妙,利用了移位运算和&运算,
n-1
的值扩容后会向左移一位,那只需要看看原来的hash值中和这个新增1相同位置的值是1还是0就好了,是0的话下标没变,是1的话下标变成“原下标+oldCap"。
源码解析
final Node<K,V>[] resize() {
// oldTab 指向旧的 table 表
Node<K,V>[] oldTab = table;
// oldCap 代表扩容前 table 表的数组长度,oldTab 第一次添加元素的时候为 null
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的扩容阈值
int oldThr = threshold;
// 初始化新的阈值和容量
int newCap, newThr = 0;
// 如果 oldCap > 0 则会将新容量扩大到原来的2倍,扩容阈值也将扩大到原来阈值的两倍
if (oldCap > 0) {
// 如果旧的容量已经达到最大容量 2^30 那么就不在继续扩容直接返回,将扩容阈值设置到 Integer.MAX_VALUE,并不代表不能装新元素,只是数组长度将不会变化
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}//新容量扩大到原来的2倍,扩容阈值也将扩大到原来阈值的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//oldThr 不为空,代表我们使用带参数的构造方法指定了加载因子并计算了
//初始初始阈值 会将扩容阈值 赋值给初始容量这里不再是期望容量,
//但是 >= 指定的期望容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// 空参数构造会走这里初始化容量,和扩容阈值 分别是 16 和 12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果新的扩容阈值是0,对应的是当前 table 为空,但是有阈值的情况
if (newThr == 0) {
//计算新的扩容阈值
float ft = (float)newCap * loadFactor;
// 如果新的容量不大于 2^30 且 ft 不大于 2^30 的时候赋值给 newThr
//否则 使用 Integer.MAX_VALUE
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新全局扩容阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//使用新的容量创建新的哈希表的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//如果老的数组不为空将进行重新插入操作否则直接返回
if (oldTab != null) {
//遍历老数组中每个位置的链表或者红黑树重新计算节点位置,插入新数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;//用来存储对应数组位置链表头节点
//如果当前数组位置存在元素
if ((e = oldTab[j]) != null) {
// 释放原来数组中的对应的空间
oldTab[j] = null;
// 如果链表只有一个节点,
//则使用新的数组长度计算节点位于新数组中的角标并插入
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//如果当前节点为红黑树则需要进一步确定树中节点位于新数组中的位置。
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//因为扩容是容量翻倍,
//原链表上的每个节点 现在可能存放在原来的下标,即low位,
//或者扩容后的下标,即high位
//低位链表的头结点、尾节点
Node<K,V> loHead = null, loTail = null;
//高位链表的头节点、尾节点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;//用来存放原链表中的节点
do {
next = e.next;
// 利用哈希值 & 旧的容量,可以得到哈希值去模后,
//是大于等于 oldCap 还是小于 oldCap,
//等于 0 代表小于 oldCap,应该存放在低位,
//否则存放在高位(稍后有图片说明)
if ((e.hash & oldCap) == 0) {
//给头尾节点指针赋值
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}//高位也是相同的逻辑
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}//循环直到链表结束
} while ((e = next) != null);
//1.将低位链表存放在原index处,
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//2.将高位链表存放在新index处,也就是原来index+原来的数组长度
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
return newTab;
}
这一部分代码量非常大,很多同学在这里迷失了,不过这里给大家写了详细的注释,可以帮助理解。这里其实分为2部分:
- 1.设置扩容后的数组大小
newCap
和扩容后新的阈值newThr
,都是扩大2倍; - 2.扩容后原来数组数据的迁移,只有2种情况,要么原位置,原来原位置+ oldCap
如果上面的代码还没有看懂,推荐一下这个视频,非常清晰
HashMap你不知道的小秘密
7、为什么加载因子为什么是 0.75?为什么树化的条件是链表长度为8?为什么树退化为链表长度为6?
别去分析了,分析也意义不大,这是大量数据计算后得出的一个在时间/空间上平衡(折衷)的方案。
8、HashMap是否有序?
肯定不是啊,存放元素的时候是随机的,所以无序。要有序的话,可以选择LinkedHashMap和TreeMap。建议面试的时候说一个就好,我喜欢说LinkedHashMap。这个连环炮可以问出好多问题。
面试必备:LinkedHashMap源码解析(JDK8)
9、HashMap是否线程安全?
线程不安全。多线程去put()
的时候,有可能造成数据覆盖,扩容的时候也可能会。要做到线程安全,有这么一些方法:HashTable、Collections.synchronizedMap()、ConcurrentHashMap。这里也是一个连环坑,问这个问题的,一般希望你说一下ConcurrentHashMap原理,还会扯到多线程同步问题,锁机制,互斥锁、自旋锁、悲观锁、乐观锁、等等。
ConcurrentHashMap基于JDK1.8源码剖析