HashMap扰动 hash、索引:index = spread & (n-1);容量始终 2 的幂

32 阅读3分钟

1) 扰动(spread):为什么要“再搅一遍”?

HashMap 取桶位只看 低位(见 §2),若某些 hashCode() 高位离散、低位很烂,就会集中落某些桶

JDK 8 的做法是把 高 16 位混到低 16 位,最简且够用:

// HashMap.hash(Object key)
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • h >>> 16:把高位右移到低位;

  • ^:异或混合,让高位信息参与低位决策

  • 结果叫“spread hash”。

    直观好处:即使只取低位做索引,也“看到了”原 hash 的高位信息,让分布更均衡。

注:null key 的 spread 固定为 0(所以它总在第 0 桶)。


2) 索引:

index = spread & (n - 1)

数组下标需要 spread % n。如果把 n 设为 2 的幂,有个重要等式:

x % n == x & (n - 1)       (仅当 n 为 2 的幂)

所以 索引 = spread & (n-1) ,好处是:

  • 更快:按位与替代取模;
  • 更均衡:配合扰动,低位不均也能缓解;
  • 更简化的扩容再分布(见 §4)。

3) 为什么“容量始终是 2 的幂”

HashMap 会把容量(table 长度)始终保持为 2^k

  • 计算索引可用位与(上节);

  • 扩容从 N 到 2N 时,每个节点只可能去“原桶”或“原桶 + oldCap” ,重分布非常轻(见 §4);

  • 配合简单扰动就能获得足够均衡的落桶效果(工程折中:性能 + 简洁)。

初始容量会被“上取整到最近的 2^k”。对应代码(JDK 8):

// HashMap.tableSizeFor(int cap)
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;  n |= n >>> 2;  n |= n >>> 4;
    n |= n >>> 8;  n |= n >>> 16;
    return (n < 0) ? 1 : (n >= 1<<30) ? 1<<30 : n + 1;
}

4) 扩容再分布:只看一个比特位就够了

设扩容从 oldCap = N 到 newCap = 2N。新索引:

newIndex = spread & (2N - 1) = spread & ((N - 1) | N)

这意味着:

newIndex = (spread & (N - 1))           (如果 (spread & N) == 0)
        或 (spread & (N - 1)) + N       (如果 (spread & N) != 0

结论:每个元素不是留在原索引,就是去 原索引 + oldCap

因此扩容时:

  • 无需重新计算完整 hash
  • 只检查 一个新比特位(oldCap 位) 就能决定去向;
  • 链表/树内元素可按此规则分成两条链搬运,复杂度与桶内元素数线性相关,成本小。

5) 这套设计带来的综合效果

  1. 性能:位运算取代 %,扩容再分布也只看一位;整体更快。
  2. 分布:轻量扰动 + 2^k 容量让低位参与更多高位信息,改善低位差的 hashCode()。
  3. 实现简洁:扩容逻辑极简(“留在原地或 +oldCap”),也便于树化链退化/重建时的搬运。

6) 小例子(按位直觉)

  • 假设 oldCap = 16 (0b1_0000),n-1 = 15 (0b0_1111)

  • 某元素 spread = 0b1011_0010:

    • 旧索引:0b1011_0010 & 0b0_1111 = 0b0_0010 = 2
    • 看 oldCap 位:(spread & 0b1_0000) == 0 → 留下(还是 2)
  • 若 spread = 0b1011_1010:

    • 旧索引:仍是 2
    • spread & oldCap = 0b1_0000 ≠ 0 → 去 2 + 16 = 18

7) 相关细节与易错点

  • 扰动不是“加强版 hash” :只是把高位摊到低位,复杂度低、够用即可。真正的分布主要还是靠 key.hashCode() 质量。

  • 非 2^k 长度会怎样?

    • 索引需要 %(慢);
    • 再分布也不能用“原地或 +oldCap”的简单规则,扩容成本高。
  • null key 总在第 0 桶:spread=0 → 索引= 0 & (n-1) = 0。

  • JDK8 树化阈值:当一个桶元素数 ≥ 8 且表长 ≥ 64 时树化,帮助把最坏 O(n) 降到 O(log n) ;扩容后若该桶元素 ≤ 6 会退化回链表。

  • 请保证 equals/hashCode 正确且 key 不可变:否则再好的扰动与位运算都救不了冲突与查找失败。


8) 面试快速回答模板(30 秒版)

HashMap 用 spread = h ^ (h >>> 16) 把高位混到低位;索引用 spread & (n-1),所以容量保持 2 的幂,这样按位与就等价取模。扩容从 N 到 2N 时,每个元素只可能留在原桶或去“原桶+oldCap”,只看一个比特位决定,无需重新算 hash,既快又均衡。