简介
JDK中Hashmap设计的非常巧妙,其内部大量使用二进制位计算来处理hash寻址。在扩容时元素向新Hashmap中迁移时也使用了逻辑&操作进行寻址。
如下图所示HashMap有很多特性但也存在一些问题,因此在使用时需要根据具体问问具体分析后使用。
下图为JDK1.8中通过链表+红黑树的方式处理hash冲突,引入红黑树提高在hash冲突时查询效率。
本文重点分析扩容时逻辑,关于何时扩容,扩容前内存空间大小如何分配以及为何hashmap中数组长度是2的幂次可以阅读上一篇文章HashMap内存分配机制
resize方法分析
当插入元素后 hashmap的容量达到指定阈值触发扩容,扩容时调用的方法为resize(),接下来重点分析该方法。
首先翻一下该方法注释,翻译出来感觉还是很拗口
初始化或者扩容时将table长度变为原来的2倍
如果为空,则根据字段阈值中默认初始容量目标进行分配。
否则的话通过double原来table方式进行扩展,如果hash桶在原表的位置为index,那么扩容后在新表的索引要么与index相同要么在index+老table的长度的位置
由于resize方法比较长因此分割成几个代码片段进行分析
-
扩容后容量和扩容阈值的计算
-
扩容后老table的数据如何转移到新table中
确定新表的容量和扩容逻辑
-
1 查看老表的容量是否大于0 否进入步骤2。 如果大于0判断是否已经超出最大容量,如果超出最大容量直接返回,否则进入步骤 3
-
2 此时oldCap=0 如果oldThr>0 (代表此时使用初始化容量创建hashmap)新表容量即为oldThr。否则进入步骤5
new HashMap(10);
-
3 通过位运算将老表容量翻倍作为新表容量,翻倍后未超过最大容量则将阈值进行翻倍此时新表容量与扩容阈值均完成直接返回,否则进入步骤4
-
4 如果新阈值等于0且新容量未超过最大值计算新阈值为新容量*加载因子
-
5 此时设置新表容量与阈值为默认值,代表使用默认构造函数创建hashmap
new HashMap();
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
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;
....省略部分代码
}
新旧表数据迁移
- 遍历整个老表所有hash桶,如果桶中节点next指针指向为空直接将该节点hash值与新表容量-1做&操作等价于%操作并分配到新的hash表中,这也是为什么hash的表容量为2的幂次(因为是2的幂次通过这种方式可以高效的求模运算)
- 如果该节点next指针不为空则需要判断当前节点类型,如果是树节点则进行分割重新在新表定位,如果是链表同样也是分割后定位,但是分割方式略有不同
- 树节点分割通过节点的hash值与老表容量&操作将会得到0与非0两种情况,根据2种情况分为2个tree,如果树节点少于8则变为链表
- 链表分割也是做节点hash值与老表容量&操作,如果值为0则node在新表中的位置与老表 相同如果不为0则在新表的位置为老表index+老表长度
final Node<K,V>[] resize() {
... 省略部分代码
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
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;
}
( e.hash & oldCap) == 0 这个方法很巧妙的将冲突链中的元素进行重定位。
在hashmap中通过key的hashcode%(table.length-1)来定位元素在table数组中的位置。oldCap值是2的整数幂对应二进制最高有效位为1其余位0,逻辑与运算只有 1&1 =1 其他&运算都是0,因此( e.hash & oldCap)=0 或者oldCap在无其他结果,如下图所示
其实e.hash & oldCap 还有更深一层的含义
思考为什么 newTab[j] = loHead; newTab[j + oldCap] = hiHead; 为什么不用%运算定位而是直接用老表中下标 j或j+oldCap定位冲突连在新表中的位置?
这其实跟二进制运算特性有关,这样做必须有一个前提就是hashmap的容量必须是2的整数幂。
假设扩容前 容量为16,元素的hashcode为 37,扩容前与扩容后计算结果都是5.
其实通过观察很好理解扩容后table.length-1二进制比扩容前多一位最高有效位,这样就保证元素在新表中的位值要么与老表相同要么在老表下标 + 老表长度
总结
关于hash表长度严格为2的幂次通过上述的操作应该可以发现在两个地方非常有用
- 做%运算可以更高效
- 在进行链表拆分是将原来冲突的链表分为2个链表,因此是2的幂次所以在hash值&操作时链表容量二进制数低位为0所以进行根据是否为0分割