上一篇HashMap的笔记讲到了一些比较基础的内容,这次继续展开,首先从上次只讲了一半的resize开始,看看除了初始化会调用到这个方法以外,还有哪些核心逻辑。
开始探讨之前
需要回顾一下3个重要参数:
Capacity
桶数组长度,即tab.length,大小为2的幂次
Load Factor
加载因子,默认为0.75,决定了 HashMap 什么时候扩容
• 更小的 loadFactor → 更早扩容 → 更少冲突、更快查找,但 更耗内存/更频繁扩容
• 更大的 loadFactor → 更晚扩容 → 更省内存,但 冲突更多、查找/插入可能变慢(链表/树化压力更大)Threshold
HashMap元素个数阈值,threshold = (int)(capacity * loadFactor)
继续探讨resize
之前讲到,当桶为空或者桶数组长度为0的时候,resize会对桶进行初始化。那么其实还剩下两种情况,会和resize有密切的联系——插入元素以及转树状结构的情况。
插入
插入的情况其实有很多种,比如putVal、putMapEntries、merge、compute/computeIfAbsent,但是核心逻辑基本都围绕着size > threshold条件来进行调用,也就是说,当当前hashmap元素个数将要超过阈值时,resize将会出马~
TreeifyBin
让我们来看下treeifyBin的代码
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}在 JDK8 的 HashMap 里,“数据太多/链表太长”并不一定意味着要立刻树化。当桶数组还比较小的时候,出现长链表更可能是因为 容量太小导致整体冲突率高(很多 key 被挤在少量桶里),这时优先扩容往往能把链表“拆散”,比把该桶树化更划算。
resize
话不多说,很好理解,都在代码里了
final Node<K,V>[] resize() {
// 旧的table数据
Node<K,V>[] oldTab = table;
// 旧的Capacity
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的元素个数阈值
int oldThr = threshold;
// 新的table和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;
}
// 这是 JDK8 HashMap 一个“省事”的技巧:当你 new HashMap<>(initialCapacity) 时,构造函数并不会立刻分配 table(懒初始化)。它会先把“将来要用的容量”暂存在 threshold 里。
else if (oldThr > 0)
newCap = oldThr;
// new HashMap<>() 的第一次初始化:
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr在上面分支中存在未被赋值的情况,在这里使用newCap * loadFactor来计算
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"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
......
}
return newTab;
}oldTab->newTab
当然了,假如你仔细看上面的代码,会发现我把最后一部分省略了,当然,不是不讲,而是要分两部分来讲,第一部分着重体现了newCap和newThreshold在扩容情况下的赋值,接下来讲讲原有的这些元素要怎么处理。
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);
// 旧桶里的元素是链表的,那就定义两条链表,一条是桶数组下标不变的,一条是桶数组下标加一倍旧桶Capacity的
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;
}
}
}
}
}看上面的代码,最重要的内容在于当旧桶元素是链表形式的时候,定义了两条链表loHead和hiHead,这要怎么理解呢?让我们回到上一篇文章讨论(cap-1) & hash的时候:
当桶扩容时, 桶容量从n变为2n,桶数组下标的计算从(n-1) & hash变为(2n-1)& hash,从二进制层面来说,当哈希值重新分桶时, 不需要重新取模,只需判断哈希的新增加的那一位是 0 还是 1。
还是举上面的例子:
(n-1) & hash = 00000111 & 10110100
(2n-1)&hash = 00001111 & 10110100
也就是说:元素要么“留在原桶”,要么“移动到原桶 + n”的位置。(运气不错,这个情况下留在了原桶)所以当我们在讨论 if ((e.hash & oldCap) == 0) 这个判断条件的时候,我们在讨论什么?讨论的就是oldCap有效二进制高一位的值是0还是1。如果是1,代表新的桶元素下标需要移动到原桶 + n,如果是0,则留在原桶。
TreeNode.split
最后再回到树状结构的处理,如果你看代码,就知道和链表的处理逻辑也就是差不多一回事。由于本篇主要讨论的是resize,我会在后续详细讲讲树形结构的处理。