200字
HashMap笔记(二)resize
2025-12-18
2025-12-18

上一篇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,我会在后续详细讲讲树形结构的处理。

Comment