哈希表是什么?
哈希表,也叫散列表、字典,是一种根据关键字对值进行快速访问的数据结构,它通过把关键字映射到表中的一个位置来实现快速访问。对应的映射函数叫散列函数,存放数据的表叫散列表。
采用什么数据结构?
HashMap 底层使用数组 + 链表,默认容量为 16,负载因子为 0.75,扩容时默认会扩容为原来的 2 倍大小。JDK1.8 底层则使用数组 + 链表 + 红黑树的结构,链表节点大于 8 会转为红黑树。
为什么会产生冲突?
假设 f 为散列函数,不同的关键字可能会得到同一个散列地址,即 k1 != k2,而 f(k1) == f(k2),这种现象被称为冲突。因为底层采用的是数组结构,散列函数散列后难免会出现不同关键字散列后出现在一个下标位置的情况。
如何解决下标冲突的问题?
不同的键经过哈希函数计算出来的下标可能会冲突,要解决这种冲突可以使用拉链法和线性探测法,JDK 默认使用的是拉链法。
扩容方式?
当存储的数据容量到达阈值就会触发扩容,默认会扩容为原来的 2 倍容量。旧数组的数据会逐条搬运到新数组内,JDK1.7 会针对每个节点计算新的下标位置,JDK1.8 会根据高位标识直接批量处理。
为什么可以用位运算替代模运算?
计算除 2^n 的余数可以理解为取 2^n 的低 n-1 位,所以可以转换成与运算。
并发场景下的死循环问题?
由于 HashMap 是非线程安全的,如果在并发场景下不正确地使用了 HashMap 则可能会导致死循环问题,进而导致服务器 cpu 飙高。 该问题之所以会出现,部分原因是因为 HashMap 底层扩容后复制数据时使用的是头插法,当多个线程同时对 HashMap 扩容时就可能导致链表成环。 当 HashMap 中的某个桶内的链表成环之后,下一次查询如果命中该桶就会导致查询线程卡死在环形链表中。具体细节可以参考这篇文章:JAVA HASHMAP的死循环。
JDK1.8做了哪些优化?解决了哪些问题?
引入了红黑树
之所以要引入红黑树,是为了避免极端情况下 HashMap 的查询性能退化成 O (n)。使用红黑树,即使在最极端的情况下,所有键值对都冲突到一个桶,HashMap 依然能够提供 O (logn) 的查询、插入、删除效率。
关于红黑树的更多细节,可以看这篇红黑树笔记,介绍了红黑树是怎么一步步从2-3树变成红黑树的,也介绍了左倾红黑树的插入和删除操作的实现细节,可以帮助我们更好的理解工程中常用的标准红黑树。
扩容不再直接计算桶下标
由于 HashMap 默认是扩容为原来的 2 倍,因此扩容后一个节点要么依然处于原来的下标 index,要么就是处于 index+oldCap 的位置。而扩容后节点是否仍然处于原位置, 可以通过判断 hash&oldCap==0 的结果来确认,若等式成立则说明扩容后节点仍然处于 index 位置,若不成立则说明应该处于 index+oldCap 位置。
扩容为原来的 2 倍大小,用于计算数组下标的掩码只是高位多出来一个 1. 加上又是 & 运算,若 hashCode 对应的位为 0,那下标肯定不变,若对应的位为 1,那只需要把原来的下标加上那一位对应的数值即可。
JDK1.8 源码中根据高位为 0 或 1 把节点分成了 2 条链表,直接用构造好的链表更新数组,这样也变相解决了此前可能出现的死循环问题。
代码实现有哪些有趣的细节?
数组容量计算规则
HashMap 允许传入不为 2^n 的容量 cap,但是内部会通过先计算 cap 的掩码 mask,再对 mask+1 来得到大于 cap 的最小的 2^n 的值。
掩码计算规则
n |= n >>> x 执行多次,x 分别使用 1、2、4、8、16,最后返回 n+1。其实就是把从最高位的 1 开始的之后的所有位数变成 1,因为 int 最多 32 位,所以 x 到 16 即可覆盖所有数据位。