HashMap的面试问题
一、HashMap的数据结构
数据结构示意图如下:

其中,桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。
-
数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置
-
如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素
-
如果链表长度>8&数组大小>=64,链表转为红黑树
-
如果红黑树节点个数<6 ,转为链表
二、HashMap的put流程
流程图如下:

-
1、首先通过key.hashCode()获取哈希码为高位,再与哈希码的低进行异或,最终获取哈希值。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } -
2、判断tab是否位空或者长度为0,如果是则进行扩容操作。
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; -
3、根据哈希值计算下标,如果对应小标正好没有存放数据,则直接插入即可否则需要覆盖
tab[i = (n - 1) & hash]) -
4、判断tab[i]是否为树节点,否则向链表中插入数据,是则向树中插入节点
-
5、如果链表中插入节点的时候,链表长度大于等于8 且 数组长度大于 64,则需要把链表转换为红黑树
treeifyBin(tab, hash); -
6、最后所有元素处理完成后,判断是否超过阈值; threshold ,超过则扩容。
三、HashMap怎么查找元素的呢?
先看流程图:

HashMap的查找就简单很多:
-
使用扰动函数,获取新的哈希值
-
计算数组下标,获取节点
-
当前节点和key匹配,直接返回
-
否则,当前节点是否为树节点,查找红黑树
-
否则,遍历链表查找
四、如果初始化HashMap,传一个17的值newHashMap<>,它会怎么处理?
static final int tableSizeFor(int cap)
{ int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
简单来说,就是初始化时,传的不是2的倍数时,HashMap会向上寻找离得最近的2的倍数 ,所以传入 17,但HashMap的实际容量是 32。
五、解决哈希冲突有哪些方法呢?
我们到现在已经知道,HashMap使用链表的原因为了处理哈希冲突,这种方法就是所谓的:
- 链地址法:在冲突的位置拉一个链表,把冲突的元素放进去。
除此之外,还有一些常见的解决冲突的办法:
-
开放定址法:开放定址法就是从冲突的位置再接着往下找,给冲突元素找个空位。
找到空闲位置的方法也有很多种:
线行探查法: 从冲突的位置开始,依次判断下一个位置是否空闲,直至找到空闲位置 平方探查法: 从冲突的位置x开始,第一次增加 1^2个位置,第二次增加 2^2…,直至找到空闲的位置

-
再哈希法:换种哈希函数,重新计算冲突元素的地址
-
建立公共溢出区:再建一个数组,把冲突的元素放进去
六、那扩容机制了解吗?
扩容方法:resize()
final Node<K,V>[] resize() {
// 旧数组
Node<K,V>[] oldTab = table;
// 旧数组大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧阈值
int oldThr = threshold;
// 定义新数组和新阈值
int newCap, newThr = 0;
// 如果旧数组的容量大于0,说明不是第一次扩容
if (oldCap > 0) {
// 如果扩容前数组超过最大值就不用再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 新数组=2倍旧数组,扩容不能大于最大值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
新阈值=2倍旧阈值
newThr = oldThr << 1;
}
// 如果旧数组的容量为0,但旧阈值大于0,说明是在初始化时指定了初始容量
else if (oldThr > 0)
newCap = oldThr;
else { // 如果旧数组容量和阈值都为0,使用默认值进行初始化
// 16
newCap = DEFAULT_INITIAL_CAPACITY;
// 12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新的阈值未被设置,则根据新的容量和加载因子计算新的阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 创建一个新的数组newTab,并将其赋值给table
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 {
// 链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 判断原索引位置 和扩容后的索引位置是否相同,而拆分为高位链和低位链
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);
if (loTail != null) {
loTail.next = null;
// 低链位:新数组索引位=旧数组索引位,则直接赋值
newTab[j] = loHead;
}
if (hiTail != null) {
// 高链位:新数组索引位 = 旧数组索引位 + 旧数组长度
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容方法中的((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//参数介绍
//tab 表示保存桶头结点的哈希表
//index 表示从哪个位置开始修剪
//bit 要修剪的位数(哈希值)
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
//如果当前节点哈希值的最后一位等于要修剪的 bit 值
if ((e.hash & bit) == 0) {
//就把当前节点放到 lXXX 树中
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
//然后 loTail 记录 e
loTail = e;
//记录 lXXX 树的节点数量
++lc;
}
else { //如果当前节点哈希值最后一位不是要修剪的
//就把当前节点放到 hXXX 树中
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
//记录 hXXX 树的节点数量
++hc;
}
}
if (loHead != null) {
//如果 lXXX 树的数量小于 6,就把 lXXX 树的枝枝叶叶都置为空,变成一个单节点
//然后让这个桶中,要还原索引位置开始往后的结点都变成还原成链表的 lXXX 节点
//这一段元素以后就是一个链表结构
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
//否则让索引位置的结点指向 lXXX 树,这个树被修剪过,元素少了
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
//同理,让 指定位置 index + bit 之后的元素
//指向 hXXX 还原成链表或者修剪过的树
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
从上述代码可以看到,HashMap 扩容时对红黑树节点的修剪主要分两部分,先分类、再根据元素个数决定是还原成链表还是精简一下元素仍保留红黑树结构。
1、分类
指定位置、指定范围,让指定位置中的元素 (hash & bit) == 0 的,放到 lXXX 树中,不相等的放到 hXXX 树中。
2、根据元素个数决定处理情况
符合要求的元素(即 lXXX 树),在元素个数小于等于 6 时还原成链表,最后让哈希表中修剪的痛 tab[index] 指向 lXXX 树;在元素个数大于 6 时,还是用红黑树,只不过是修剪了下枝叶;
不符合要求的元素(即 hXXX 树)也是一样的操作,只不过最后它是放在了修剪范围外 tab[index + bit]。
七、jdk1.8对HashMap主要做了哪些优化呢?为什么?
jdk1.8 的HashMap主要有五点优化:
1、数据结构:数组 + 链表改成了数组 + 链表或红黑树
原因 :发生 hash 冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由O(n) 降为O(logn)
2. 链表插入方式:链表的插入方式从头插法改成了尾插法
简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后。
原因 :因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。
3. 扩容rehash:扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,不需要重新通过哈希函数计算位置,新的位置不变或索引 + 新增容量大小。
原因: 提高扩容的效率,更快地扩容。
4、扩容时机:在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容;
5、散列函数:1.7 做了四次移位和四次异或,jdk1.8只做一次。
原因 :做 4 次的话,边际效用也不大,改为一次,提升效率。
八、深入探究 Java1.7 中的HashMap为什么会产生死循环
多线程下扩容死循环。JDK1.7 中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致链表形成环形数据结构,一旦形成环形数据结构,在 get(key) 的时候就会产生死循环。如下图所示:

死循环原因
HashMap 导致死循环的原因是由以下条件共同导致的:
-
HashMap 使用头插法进行数据插入(JDK 1.8 之前);
-
多线程同时添加;
-
触发了 HashMap 扩容。
什么是头插法
头插法是指新来的值会取代原有的值,插入到链表的头部,如下图所示。
原链表如下图所示:

此时使用头插入插入一个元素 Z,如下图所示:

头插法会导致 HashMap 在进行扩容时,链表的顺序发生反转,如下图所示

因为在 HashMap 扩容时,会先从旧 HashMap 的头节点读取并插入到新 HashMap 节点中,旧节点的读取顺序是 A -> B -> C,于是插入到新 HashMap 中的顺序就变成了 C -> B -> A,这样就破坏了链表的顺序,导致了链表反转
死循环产生过程
死循环执行步骤1
死循环是因为并发 HashMap 扩容导致的,并发扩容的第一步,线程 T1 和线程 T2 要对 HashMap 进行扩容操作,此时 T1 和 T2 指向的是链表的头结点元素 A,而 T1 和 T2 的下一个节点,也就是 T1.next 和 T2.next 指向的是 B 节点,如下图所示:

死循环执行步骤2
死循环的第二步操作是,线程 T2 时间片用完进入休眠状态,而线程 T1 开始执行扩容操作,一直到线程 T1 扩容完成后,线程 T2 才被唤醒,扩容之后的场景如下图所示:

从上图可知线程 T1 执行之后,因为是头插法,所以 HashMap 的顺序已经发生了改变,但线程 T2 对于发生的一切是不可知的,所以它的指向元素依然没变,如上图展示的那样,T2 指向的是 A 元素,T2.next 指向的节点是 B 元素。
死循环执行步骤3
当线程 T1 执行完,而线程 T2 恢复执行时,死循环就建立了,如下图所示:

因为 T1 执行完扩容之后 B 节点的下一个节点是 A,而 T2 线程指向的首节点是 A,第二个节点是 B,这个顺序刚好和 T1 扩完容完之后的节点顺序是相反的。T1 执行完之后的顺序是 B 到 A,而 T2 的顺序任是 A 到 B,这样 A 节点和 B 节点就形成死循环了,
这就是 HashMap 死循环导致的原因。
九、HashMap 是线程安全的吗?多线程下会有什么问题 ?
HashMap不是线程安全的,可能会发生这些问题:
-
多线程下扩容死循环。JDK1.7 中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8 使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
-
多线程的 put 可能导致元素的丢失。多线程同时执行put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在 JDK 1.7 和 JDK 1.8 中都存在。
-
put 和 get 并发时,可能导致 get 为 null。线程 1 执行put 时,因为元素个数超出 threshold 而导致rehash,线程 2 此时执行 get,有可能导致这个问题。这个问题在 JDK 1.7 和 JDK 1.8 中都存在。
十、有什么办法能解决HashMap线程不安全的问题呢 ?
Java 中有 HashTable、Collections.synchronizedMap、以及 ConcurrentHashMap 可以实现线程安全的 Map:
-
HashTable是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大;
-
Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
-
ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized。

浙公网安备 33010602011771号