大家好,又见面了,我是你们的朋友全栈君。
前言
HashMap作为Java中使用最频繁的数据结构之一,它的技术原理与细节在面试中经常会被问到。笔者在面试美团时曾被面试官问到HashMap扩容机制的原理。这个问题倒不难,但是有些细节仍需注意。
JDK1.8对HashMap进行的较大的改动,其中对HashMap的扩容机制进行了优化。在JDK1.8前,在多线程的情况下,使用HashMap进行put操作会造成死循环。这是因为多次执行put操作会引发HashMap的扩容机制,HashMap的扩容机制采用头插法的方式移动元素,这样会造成链表闭环,形成死循环。JDK1.8中HashMap使用高低位来平移元素,这样保证效率的同时避免了多线程情况下扩容造成死循环的问题。这篇博客重点介绍扩容时使用到的高地低平移算法。
注:本文所有代码均来自JDK1.8
正文
HashMap利用resize()方法实现扩容,与此同时resize()方法也承担着HashMap初始化工作。
我们知道在HashMap中以Node<K,V>来存放键值对,在此基础上利用Node<K,V>数组来存放所有的Node结点。resize()方法的作用就是判断如果当前数组为空,resize方法会创建一个长度为16的Node<K,V>数组,同时设置数组的临界点最大容纳Node节点个数为12(16*0.75,16为Node结点数组的长度,0.75为负载因子)。如果当前存放Node结点的数组不为空,resize方法双倍扩容数组大小,创建一个两倍于原长度大小的数组,同时将原数组的结点平移至新数组。
代码语言:javascript复制final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 步骤1:旧数组不为空
if (oldCap > 0) {
// 步骤1.1:如果旧数组长度大于等于最大容量,重新设置临界值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 步骤1.2:如果旧数组容量大于默认容量且右移一位小于最大容量,双倍扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// 步骤2.如果旧数组为空,临界值大于0,设置新数组容量为临界值
else if (oldThr > 0)
newCap = oldThr;
// 步骤3.如果旧数组为空,临界值小于等于0,设置容量与临界值为默认值
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 步骤4:如果新数组临界值为0,设置临界值
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"})
// 步骤5:创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 步骤6:如果旧数组不为空,遍历旧数组将结点平移至新数组
if (oldTab != null) {
for (int j = 0; j < oldCap; j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 步骤6.1:如果oldTab[index]只有一个Node结点,重新计算index,将该结点注入新数组
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 步骤6.2:如果oldTab[index]是树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 步骤6.3:如果oldTab[index]为链表,按照高低位平移链表至新数组
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 步骤6.3.1:如果为低位,将链表按顺序平移到以loHead为头,loTail为尾的链表中
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 步骤6.3.2:如果为高位,将链表按顺序平移到以hiHead为头,loTail为尾的链表中
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 步骤6.3.4:将loHead赋值给新数组
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 步骤6.3.5:将hihead赋值给新数组
if (hiTail != null) {
hiTail.next = null;
newTab[j oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
高低位算法
解释一下步骤6.3。如果旧数组不为空,当我们在扩容时就需要将旧数组的数组迁移到新数组,数据迁移需要遍历旧数组,将旧数组每个下标位的数据移动到新数组中。这里的重点是如果数组当前位置存放的元素是Node链表,这时需要对Node结点的Hash值与旧数组长度进行&运算。如果计算出来的值为0,表示该结点为低位结点,将旧数组所有低位结点组合成一个新的Node链表,并赋值到新数组相同位置,即newTab[j] = loHead。如果计算出来的值不为0,表示该结点为高位结点,将旧数组所有高位结点组合成一个新的Node链表,赋值给新数组当前位置加上旧数组长度的位置,即newTab[j oldCap] = hiHead。这就是HashMap扩容机制中的高低位算法。
想要理解这个过程,首先需要明白HashMap中如何计算数组下标位。
代码语言:javascript复制int index = node.hash&(table.length-1);
注:&运算能够保证计算出来的值小于等于其中任何一个值,因此计算出来的数组下标index小于等于table.length-1。
在扩容机制下数组两倍扩容,数组的长度发生了变化,同时我们也必须要严格遵守计算数组下标index的算法,否则在新数组调用get()无法获取到相应的Node结点。因此将旧数组中的数据迁移到新数组的过程中,需要按照新数组长度重新计算数组下标。HashMap采用高低位算法计算结点在新数组中的下标。这种算法也将结点分为两种情况。
注:如何判断Node结点高低位,请仔细比较结点Hash值的二进制表示
情况一:Hash值与旧数组长度的&运算不为0
代码语言:javascript复制node.hash & oldTable.length != 0;
我们假设Node结点的Hash值的二进制是1000010101,旧数组长度为16,二进制即10000。此时计算出来的index为5。
代码语言:javascript复制Hash :1000010101
length-1 :0000001111
——————————
index : 0000000101
当我们对数组进行扩容,数组的长度变成了32,Node结点的Hash值依然为1000010101。此时计算出来的index为21。
代码语言:javascript复制Hash :1000010101
length-1 :0000011111
——————————
index : 0000010101
结点平移后,此时计算出来的存放结点的新数组下标为旧数组下标加上旧数组的长度,即newIndex = oldIndex oldLength;
情况二:Hash值与旧数组长度的&运算为0
代码语言:javascript复制node.hash & oldTable.length == 0;
我们假设Node结点的Hash值的二进制是1000000101,旧数组长度为16,二进制即10000。此时计算出来的index为5。
代码语言:javascript复制Hash :1000010101
length-1 :0000001111
——————————
index : 0000000101
当我们对数组进行扩容,数组的长度变成了32,Node几点的Hash值依然为1000000101。此时计算出来的index仍为5。
代码语言:javascript复制Hash :1000000101
length-1 :0000011111
——————————
index : 0000000101
结点平移后,此时计算存放结点的新数组下标与旧数组下标相等,即newIndex = oldIndex。
为什么使用高低位算法?
大家注意到HashMap扩容时按照结点的类型将数据迁移分为三种情况,1:当前下标结点为单结点,2:当前下标的结点为红黑树结点。3:当前下标结点为链表结点。我们现在来比较一下情况1和情况3。
情况一:
当前下标结点为单结点
代码语言:javascript复制e.next == null
此时采用的数据迁移采用的方案是直接赋值
代码语言:javascript复制newTab[e.hash & (newCap - 1)] = e
情况三:
当前下标结点为链表结点,此时采用的数据迁移方案为高地位算法。
思考两个问题,问题一:为什么当下标结点为链表结点时,不采用单结点数据迁移的方式,即直接赋值?问题二:当采用高低位算法迁移数据的时候,为什么需要将链表分为低位链表和高位两个链表。
这两个问题的关键在于一个点,在旧数组中如果某个下标位的元素为链表,表示这个链表的所有结点的Hash值计算出来的下标位相同,这不意味着链表所有结点的Hash值相同。当数组扩容时,链表所有的结点必须根据新数组的长度重新计算下标位,此时即使链表中每个结点的Hash值不尽相同,但是由于&运算和数组两倍扩容的特殊性,可以根据高低位算法将链表分为高位链表和低位链表,并将这两个链表迁移到新数组不同的下标位。
还有一个重要的性质是如果两个结点在新数组计算出来的下标位相同,那这两个结点在旧数组计算出来的下标位也必然相同。这保证了在HashMap扩容迁移数据时,不会存在不同的链表迁移到新数组相同的下标位。
JDK1.8源码–HashMap容量细节
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/191046.html原文链接:https://javaforall.cn