Skip to content
Go back

HashMap的哈希扰动与扩容机制

HashMap 的哈希扰动:为什么不是简单取模?

一句话结论(30s)

HashMap 的本质是用「位运算取代取模」的哈希表,因为容量固定为 2 的幂,所以能用 (n-1) & hash 一步定位桶;关键设计是 h ^ (h>>>16) 扰动让高位参与桶定位,扩容时用 hash & oldCap 一次位运算决定节点去留,JDK 8 改用尾插法消除 JDK 7 并发扩容的死循环。权衡:以「容量受限为 2^n」换来位运算比取模快约 5 倍,以树节点约 2 倍内存换来极端碰撞下 O(log n) 的查找保证。

核心原理(2min)

主流程:put 时先对 key 做 h ^ (h>>>16) 扰动(XOR 使输出 bit 为 1 的概率最接近 50%,分布最均匀),再用 (n-1) & hash 定位桶;碰撞时先挂链表,链表长度 ≥ 8 且数组 ≥ 64 时树化为红黑树。扩容时容量翻倍,用 hash & oldCap 判断多出的那一个 bit:为 0 留原下标、为 1 偏移 oldCap,用 lo/hi 双链表尾插迁移,一次位运算 O(1) 决策、O(n) 迁移。树化阈值 8 是泊松分布(λ=0.5)下几乎不可能触发的防御信号,退树化阈值 6 而非 8 是为了避免 resize 引起的「树化↔链化」振荡。读到这先别急着往下背结论,自己回答三个问题:扰动函数为什么偏偏是高 16 位异或低 16 位,而不是别的位?容量为什么非得是 2 的幂?这两点如果做不到,get 会慢多少、甚至会不会出错?带着答案继续读,后面的每一节其实都在回答其中之一。

底层深入(5-10min)

一个看似简单的问题

HashMap 的容量是 2 的幂(如 16、32、64),用 (n-1) & hash 定位桶。问题是:(n-1) 的二进制全是低位的 1(如 n=16 时掩码 0b1111),如果 key 的 hashCode 高位差异大但低位相同,直接取模会让它们全落在同一个桶——本来该是 O(1) 的散列查找,直接退化成了对整条链表的线性遍历。先停下来想想:怎么才能让「低位掩码看不到」的高位也参与定位?

这就是”扰动函数”存在的理由——让高位信息也参与桶定位。

JDK 8 的扰动函数

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

核心操作:将 hashCode 的高 16 位与低 16 位做 XOR,结果存入低 16 位。

为什么是 XOR 而非 AND 或 OR?

运算输出 1 的概率问题
AND25%偏向 0,分布不均
OR75%偏向 1,分布不均
XOR50%均衡分布 ✅

XOR 让高低位信息均匀混合,输出 bit 为 1 的概率最接近 50%,保证桶分布最均匀。

举个例子

hashCode = 0b0001_0010_0011_0100_0101_0110_0111_1000
                                高16位 = 0b0001_0010_0011_0100
                                低16位 = 0b0101_0110_0111_1000

h >>> 16   = 0b0000_0000_0000_0000_0001_0010_0011_0100
h          = 0b0001_0010_0011_0100_0101_0110_0111_1000
XOR        = 0b0001_0010_0011_0100_0100_0100_0100_1100

现在低 16 位包含了两部分的信息,(n-1) & hash 使用的掩码能”看到”高位差异。

putVal:定位、覆盖、树化的完整流程

扰动后的 hash 最终交给 putVal 落地,完整实现如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

这段代码把写入拆成三个分支:桶为空时直接新建节点;桶首节点命中(hash 相同且 equals 相等)则走覆盖分支;否则沿链表向后找,找不到就尾插新节点,插入后若 binCount >= TREEIFY_THRESHOLD - 1(即链表里已有 8 个节点)就调用 treeifyBin 尝试树化。注意每处命中判断都是「先 p.hash == hash 快速筛,再 key.equals(k) 精确比」,这正是 hash 与 equals 分两步协作在写路径上的体现。

容量为什么是 2 的幂:tableSizeFor

(n-1) & hash 能当取模用,前提是容量 n 永远是 2 的幂。那为什么不干脆用任意容量、老老实实 hash % n 取模?想想看:只要 n 不是 2 的幂,n-1 的二进制里就会出现 0,掩码会漏掉一部分桶,散列立刻不均——所以「容量必须是 2 的幂」不是洁癖,而是位运算正确性的硬前提。这个前提靠 tableSizeFor 在构造时对齐:

static final int tableSizeFor(int cap) {
    int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

numberOfLeadingZeros 算出 cap - 1 最高位 1 的位置,-1 >>> ... 据此生成一个低 k 位全 1 的掩码,再加 1 就得到恰好 >= cap 的 2 的幂。所以无论你传初始容量 17 还是 30,HashMap 都会把它对齐成 32;这就是「容量永远是 2 的幂」的机制来源(JDK 8 用的是逐步右移填充高位的写法,现行 JDK 用 numberOfLeadingZeros 直接算出,效果相同——向上取整到最近的 2 的幂)。

扩容:hash & oldCap 的魔法

HashMap 扩容时容量翻倍(newCap = oldCap * 2),但不需要重新计算每个 key 的 hash。先想一个问题:为什么必须翻倍,不能 +1 或 ×3?因为翻倍后新掩码 newCap - 1 只比旧掩码多出最高位那个 1,而这个多出的 bit 恰好就是 oldCap——正是这个巧合,才让下面「一次位运算决定去向」的捷径成立。JDK 8 用 hash & oldCap 一次位运算判断节点去向:

// 扩容时拆分链表的核心逻辑
if ((e.hash & oldCap) == 0) {
    // 该 bit = 0 → 下标不变 → 加入 lo 链表
} else {
    // 该 bit = 1 → 下标偏移 oldCap → 加入 hi 链表
}

为什么这样是对的?

扩容前 n = 16 = 0b10000n-1 = 0b01111。扩容后 n = 32 = 0b100000n-1 = 0b11111

原来掩码是 4 bit,现在是 5 bit,多出的第 5 bit 恰好就是 oldCap(16 = 0b10000)。所以 hash & oldCap 就是检查”hash 中这个新增 bit 的值”:

一次位运算,O(1) 决策,O(n) 迁移。

resize() 的真实实现

hash & oldCap 的决策逻辑就藏在 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;
    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;
    @SuppressWarnings({"rawtypes","unchecked"})
    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;
}

这段代码先算新容量 newCap(旧容量翻倍,超过 MAXIMUM_CAPACITY 则不再扩)和新阈值 newThr,再遍历旧表把节点搬到新表。单个节点直接按 newCap - 1 重新定位,树节点走 split 拆分,普通链表则用 hash & oldCap 把每个节点归入 lo/hi 两条链,lo 接到原下标 j、hi 接到 j + oldCap;由于 next 在改指针前先保存、尾插时只动 tail.next,链表相对顺序保持不变——这正是 JDK 8 消除 JDK 7 头插死循环的那段代码。

树化:为什么是 8?

static final int TREEIFY_THRESHOLD = 8;

链表转红黑树不是只看长度 ≥ 8,还要 MIN_TREEIFY_CAPACITY = 64(数组长度 ≥ 64)。

为什么是 8?JDK 注释给出了数学解释:在理想哈希分布下(泊松分布,λ = 0.5),链表长度达到 8 的概率约为 0.00000006(6 × 10⁻⁸),几乎不可能。所以链表 ≥ 8 本身就是”哈希分布异常”的信号,树化是一种防御措施。

树化后查找从 O(n) 变为 O(log n),但树节点占用空间约是普通节点的 2 倍,所以添加了”数组长度 ≥ 64”的前提——小表直接扩容就够了,不必树化。

这个「先扩容、够大才树化」的判断就写在 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);
    }
}

第一行的 if 就是那层防御:表还没建或长度 < MIN_TREEIFY_CAPACITY(64)时不树化,而是直接 resize 扩容。只有当数组够大、且这个桶确实还是长链表时,才把链上的每个 Node 逐个替换成 TreeNode、用 prev/next 串成双向链表,最后调用 hd.treeify(tab) 真正构造成红黑树。

退树化:为什么是 6 而不是 8?

static final int UNTREEIFY_THRESHOLD = 6;

用不同值(8 和 6)作为树化和退树化阈值,避免因反复 resize 导致的”树化 ↔ 链化”振荡(一个 put 让长度到 8 触发树化,一个 remove 让长度到 7 又退树化,如此反复)。

JDK 7 vs JDK 8:头插法为什么会死循环

JDK 7 扩容时用头插法(新节点插入链表头部),并发扩容时两个线程同时迁移链表,next 指针交叉修改形成 A→B→A 循环引用,get() 进入死循环,CPU 飙高。

JDK 8 改用尾插法 + lo/hi 双链表:遍历时用尾插将节点追加到 loHead → loTailhiHead → hiTail,链表内相对顺序保持不变,彻底消除循环引用。

// JDK 8 尾插法(简化)
if (loTail == null)
    loHead = e;
else
    loTail.next = e;  // 尾部追加,顺序不变
loTail = e;

if (hiTail == null)
    hiHead = e;
else
    hiTail.next = e;
hiTail = e;

扩容完成后分别将 loHead 接到 newTab[j]hiHead 接到 newTab[j + oldCap]

总结:HashMap 设计中的工程权衡

设计机制代价收益
扰动函数h ^ (h >>> 16)一次位运算分布均匀度大幅提升
2 的幂容量(n-1) & hash容量受限为 2^n位运算替代取模(快 ~5 倍)
尾插法lo/hi 双链表额外头尾指针消除 JDK 7 死循环
树化链表 → 红黑树树节点内存 ×2极端哈希碰撞下 O(log n) 保证

章末提问

  1. 为什么 JDK 8 要把头插法改成尾插法? 结论:为了消除并发扩容的死循环。因为头插法在并发迁移时两个线程会交叉改写 next 指针,形成 A→B→A 循环引用,get 进去就死循环;尾插法保持链表相对顺序,配合 lo/hi 双链表后链表不会成环。

  2. 扩容为什么要 2 倍,不能 3 倍或随便加一个数? 结论:为了把「重算下标」降成一次位运算。因为翻倍后新掩码 newCap - 1 只比旧掩码多最高位一个 1,这个 bit 恰好等于 oldCap,用 hash & oldCap 就能 O(1) 决定节点去留;其他扩容因子都没有这种对称性。

  3. 扰动函数为什么是高 16 位异或低 16 位,而不是别的位? 结论:为了让高位参与桶定位、分布更均匀。因为 (n-1) & hash 只取低 log n 位,高位不参与时,高位差异大、低位相同的 key 会全部碰撞;XOR 混合后输出 1 的概率最接近 50%,分布最均匀。

  4. 为什么树化阈值是 8,而不是 2 或 100? 结论:8 是「哈希分布异常」的防御信号。因为在理想泊松分布(λ=0.5)下链表达到 8 的概率约 6×10⁻⁸,几乎不可能,一旦出现说明 hash 质量出问题;又因树节点内存约 2 倍,所以还要求数组 ≥ 64 才树化。

  5. 为什么不直接用 hash % n 取模,偏要 (n-1) & hash 结论:位运算比取模快。因为容量固定为 2 的幂时两者结果等价,而位与指令比取模快约 5 倍;代价是容量被限制为 2^n,用 tableSizeFor 对齐兜底。


Share this post on:

Previous Post
Java Record与Sealed类——代数数据类型(ADT)的语言级支持
Next Post
ConcurrentHashMap——从JDK7分段锁到JDK8桶级锁