面试必问HashMap
无论是小厂还是大厂面试,HashMap
的出场率一直居高不下。作为Java
语言中Map
数据结构的经典实现,对其内部原理的掌握和理解程度在一定程度上能反映出一个开发人员的水平与实力。
下面我们提供出一些常见面试问法以及对应的官方回答(建议熟读并背诵):
HashMap
底层是如何实现的?JDK7
和JDK8
的实现有何区别?
答:HashMap
底层存储元素的数据结构是数组。在存储元素时如果发生了哈希冲突,JDK7
使用“链地址法”(俗称“拉链法”)来解决,采用“头插法”进行元素插入。在JDK8
中,当哈希冲突不是很严重时,仍然使用“链地址法”解决,但采用的是“尾插法”;而当哈希冲突特别严重,即链表的长度大于8
并且数组的容量大于64
时,会将链表结构转化成红黑树结构用以解决链表过长带来的性能问题。
- 加载因子
loadFactor
为什么是0.75
?
答:这是一个基于容量和性能考虑的折衷值。如果加载因子设置过大,带来的好处是扩容的门槛提高,节约了内存空间,但出现哈希冲突的几率会变大,从而导致对元素的操作性能下降;如果加载因子设置过小,带来的好处是扩容门槛降低,数组中存储的元素会比较稀疏,不易出现哈希冲突,对元素的操作性能较高。但会存在一定的空间浪费。于是折衷考虑选择了0.75
。
- 什么时机会触发扩容?
JDK8
在扩容时做了哪些优化?
答:当存储元素数量大于等于阈值时触发扩容。JDK7
在扩容时会对所有元素进行rehash
重新哈希计算数组下标;而JDK8
在扩容时通过高位运算(哈希码 & 旧容量)来判断元素是否需要移动,如果高位运算结果高一位为0
,那么扩容后该元素的位置不会发生变化,如果高位运算结果高一位为1
,那么扩容后该元素的位置发生了变化,新的数组下标位置为原下标位置加上原数组长度。
- 描述插入一个元素的完整过程。
答:首先判断哈希表是否为空,如果为空则表示第一次插入,进行扩容;否则根据键计算出数组下标,然后判断该下标位置的元素是否为null
,如果是,则直接插入;否则判断键是否相等(先比较hashCode
,哈希码不相等再进行equals
比较),如果相等,则直接覆盖值;否则判断是红黑树还是链表,分别执行对应数据结构的插入逻辑。其中链表数据结构在JDK7
中使用头插法进行插入,在JDK8
中使用尾插法。而红黑树在JDK8
才引入,如果已经是红黑树了,则执行红黑树的插入逻辑,如果是链表,在使用尾插法遍历节点时如果发现链表长度大于8
了且数组容量小于64
时,将链表转换成红黑树后在执行插入,否则执行链表的尾部插入。元素插入完成后,判断哈希表容量是否超过阈值,超过则进行扩容。
HashMap
的线程不安全体现在哪?如何去应对?
答:在多线程条件下,JDK7
的HashMap
在扩容时会出现死循环的问题,JDK8
的HashMap
在插入时会出现值被覆盖的问题。应对这种线程不安全,可以使用同步容器HashTable
或并发容器ConcurrentHashMap
。推荐使用ConcurrentHashMap
。
以上面试题及答案基本可以应对那些普通公司的一问一答式的面试了,背诵就完事了。
但有些面试官可能会追着其中某个点一直往下细问,这个时候光靠上面背诵的这点东西就有点力不从心了,你必须对整个HashMap
的原理真正的有一定了解才能够从容去应对。
下面我们针对上面的面试题,阅读JDK8
的HashMap
源码,真正的去搞懂原理。
如何存储元素?
1 | transient Node<K,V>[] table; |
元素都存储在这个Node
数组里面,我们来看下Node
类:
1 | /** |
可以看到是一个单链表的数据结构,也就是说table
数组中存储的是一个一个的Node
节点,而这每一个Node
节点都是一个单链表的头节点,当没有发生哈希冲突时,链表长度为1
。
静态变量
下面我们来看一些主要的静态变量:
1 | // 默认的初始容量-必须为2的整数幂 |
有关加载因子loadFactor
为什么是0.75
?
还记得文章开头的解答吗?这是一个基于容量和性能考虑的折衷值。如果加载因子设置过大,带来的好处是扩容的门槛提高,节约了内存空间,但出现哈希冲突的几率会变大,从而导致对元素的操作性能下降;如果加载因子设置过小,带来的好处是扩容门槛降低,数组中存储的元素会比较稀疏,不易出现哈希冲突,对元素的操作性能较高。但会存在一定的空间浪费。于是折衷考虑选择了0.75
。
其实这个答案已经很能说明原因了,但有些面试官就是纠结这个数值为什么是0.75
,为什么不是0.74
或者0.76
等。下面我们尝试用一种八股文来对抗面试官:
为什么加载因子是0.75
呢?
理论:通过预测存储桶是否为空,可以避免链接并利用分支预测。如果存储桶为空的可能性超过0.5
,则该存储桶可能为空。
证明:s
代表大小,n
代表增加的键数。根据牛顿二项式定理,存储桶为空的概率为:
P(0) = C(n, 0) * (1/s)^0 * (1 - 1/s)^(n - 0)
因此,如果少于log(2)/log(s/(s - 1))
个键,当s
达到无穷大时并且如果添加的键数使得P(0) = 0.5
时,则n/s
迅速接近log(2)
。
log(2)
约等于0.693
。
结论:log(2)
不是约等于0.693
吗?为什么会选择0.75
呢?因为阀值需要一个整数,默认初始容量是16
,当加载因子等于0.75
时乘积才为整数。0.70/0.71/0.72/0.73/0.74...
等等,只有0.75
的乘积才是整数。
以上理论及证明过程来自 stackoverflow.com
所以问题来了,为什么默认初始容量是16
呢?这…,建议准备面下一家公司了…
成员变量
除了存储元素的table
数组,还有以下其它成员变量:
1 | // Set缓存,可用于迭代器 |
如何构造HashMap
对象?
一共有四个构造器,但我们常用的只有两个:无参构造器和指定初始容量的构造器。
1 |
|
一般在开发时,如果我们能提前预估出数据量,建议调用指定初始容量的构造器来创建一个合适容量的HashMap
;如果无法预估,最好使用无参构造器。
我们来看下指定初始容量和指定加载因子的构造器:
1 | public HashMap(int initialCapacity, float loadFactor) { |
初始化赋值的时候,并不会将阀值threshold
设置为initialCapacity * loadFactor
,而是预初始化成初始容量,此处会调用tableSizeFor
方法得到一个大于等于initialCapacity
且最近的2
的整数次幂的数,然后赋值给threshold
,后续第一次调用put
方法进行插入时会进行首次扩容,调用resize
方法对threshold
进行准确赋值。
tableSizeFor
方法原理
tableSizeFor
方法是得到一个大于等于initialCapacity
且最近的2
的整数次幂的数。我们来看JDK8
是如何进行巧妙实现的:
1 | static final int tableSizeFor(int cap) { |
首先我们要知道位运算符号>>>
表示无符号右移,忽略符号位,空位都以0
补齐。
假设cap = 10
,那么n = 9
。
执行n != n >>> 1
即n = 9 | (9 >>> 1)
的步骤是:
9
的二进制是1001
;9 >>> 1
即:1001 >>> 1 = 0100
;9 | (9 >>> 1) = 1001 | 0100 = 1101
。
接下来依次执行:
1 | n |= n >>> 2; |
过程分别为:
n |= n >>> 2;
,此时n = 1101
,即:n = 1101 | (1101 >>> 2) = 1101 | 0011 = 1111
;
n |= n >>> 4;
,此时n = 1111
,即:n = 1111 | (1111 >>> 4) = 1111 | 0000 = 1111
;
n |= n >>> 8;
,此时n = 1111
,即:n = 1111 | (1111 >>> 8) = 1111 | 0000 = 1111
;
n |= n >>> 16;
,此时n = 1111
,即:n = 1111 | (1111 >>> 16) = 1111 | 0000 = 1111
;
最后的结果为:n = 1111
,转换成十进制等于15
,return
语句中进行三目判断,最后返回的值为:n + 1 = 16
。
如何插入元素?
下面我们来看put
方法的实现:
1 | public V put(K key, V value) { |
整个put
方法的执行流程如下图所示:
如何优雅地进行扩容?
出现扩容的情况有三种:
- 哈希表为空且第一次执行
put
方法时,会先进行扩容再存储元素。 - 当出现哈希冲突,执行尾插法插入元素后,如果链表长度大于
8
,且数组长度小于MIN_TREEIFY_CAPACITY=64
,执行扩容逻辑。 - 新插入一个键后(元素直接插入、哈希冲突链表尾插、红黑树插入),
++size > threshold
容量达到阀值,执行扩容逻辑。
下面我们来看下扩容resize
的逻辑:
1 | final Node<K,V>[] resize() { |
可以看到在有哈希冲突时,对链表中的元素并没有进行rehash
操作,而是将冲突的链表分隔为两个链表,通过计算e.hash & oldCap
,如果结果的高一位为0
,则表示该链表节点不需要移动位置,链接至loHead
节点;如果结果的高一位为1
,则表示该链表节点需要移动位置,链接至hiHead
节点。do-while
循环结束后,以loHead
节点为头节点的链表不需要移动数组下标;而以hiHead
节点为头节点的链表需要向右移动旧容量oldCap
个位置。原链表各节点之间的相对位置未发生改变。
如何获取元素?
相比于put
插入,get
获取元素的逻辑就相对简单了。正常从HashMap
中获取元素的最好时间复杂度是常数O(1)
。在哈希冲突的情况下:最坏时间复杂度是O(n)
,最好时间复杂度是O(log n)
。
下面我们来看下get
方法的逻辑:
1 | public V get(Object key) { |
由于在put
插入的时候计算下标的方式为(n - 1) & hash
,所以get
查询的时候以同样方式计算下标,如果该下标位置无元素,那么待查询的key
一定不在HashMap
中,直接返回null
;反之,如果该下标位置有元素,那么则寻找与待查询的key
相等的键。
如何判断key
相等?
在HashMap
中,所有判断key
是否相等的逻辑都是:先比较hashCode
是否相等,再比较equals
是否相等。
这就是为什么建议在重写equals
方法的同时最好去重写hashCode
方法的一个原因。
两个key
如果hashCode
相等,equals
不一定不等。
线程不安全问题是如何产生的?
对于JDK7
,在多线程进行扩容时会产生死循环问题,由于JDK7
已成为历史,本文不再赘述,可以参考网上的一些解释。
而对于JDK8
,我们可以看看上文中分析的put
插入方法,针对第二个if
条件分支,如果未发生哈希冲突,则创建新节点直接进行赋值插入。
1 | if ((p = tab[i = (n - 1) & hash]) == null) |
以上代码在JDK8#HashMap.java
源码中分别是第630
行和第631
行。
此处如果多个线程同时对同一个HashMap
执行put
方法,根据hash
计算出的下标i
相同且i
下标处恰好为null
没有元素,同时多个线程恰好刚执行完第630
行的if
条件判断但还未执行第631
行的赋值插入。这个时候,如果多个线程按先后顺序往下执行,那么后执行的线程就会覆盖先执行的线程在i
下标处插入的值。
当然在日常的业务开发中,基本上不会使用到HashMap
作为成员变量,更多的是用作局部变量进行传参,而且现在已经不推荐使用HashMap
进行传参了,除了一些早期项目,或者使用了MyBatis
的早期版本ibatis
的项目,基本上很少会用到HashMap
。
总结
有关HashMap
,其实到这里就已经足够了,再往下深入,就是红黑树的查询和插入逻辑了,涉及到红黑树的数据结构和算法,比较复杂。面试如果遇到问红黑树的实现细节的,大可以去反问面试官会不会。
后面有空会专门写一篇文章讲讲红黑树。