bigywater.tech
Java 内部原理 · 60

位运算与 HashMap 容量真实验证:为什么必须是 2 的幂

"HashMap 的容量必须是 2 的幂"这个规则几乎人人都知道,但很少有人真的算过为什么。这篇真实复刻 HashMap 内部的 tableSizeFor 算法,用反射拿到真实的 table 数组长度对照,再真实用穷举法找出容量不是 2 的幂时具体在哪里出问题。

(n-1)&h 全部命中2的幂时
真实验证:n=16 时,对 1000 个哈希值,(n-1)&hash 和 hash%n 结果真实完全一致,无一例外
n=15 第一个反例就是 h=1
真实验证:n=15(非2的幂)时,h=1 就真实出现 (n-1)&h=0 但 h%n=1,两者立刻不相等

上一篇:CountDownLatch 与 CyclicBarrier 真实对比

真实验证:任意初始容量都会被规整成 2 的幂

复刻 HashMap 源码里的 tableSizeFor 方法,并用反射拿到真实创建出来的 table 数组长度做对照。

BitwiseHashMapDemo.java static int tableSizeFor(int cap) { int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1); return (n < 0) ? 1 : (n >= (1<<30)) ? (1<<30) : n + 1; } HashMap<String, Integer> map = new HashMap<>(10); map.put("init", 1); // 触发真正分配 table Field tableField = HashMap.class.getDeclaredField("table");
真实实测
tableSizeFor(1) = 1,是否是2的幂: true
tableSizeFor(5) = 8,是否是2的幂: true
tableSizeFor(7) = 8,是否是2的幂: true
tableSizeFor(10) = 16,是否是2的幂: true
tableSizeFor(16) = 16,是否是2的幂: true
tableSizeFor(17) = 32,是否是2的幂: true
tableSizeFor(100) = 128,是否是2的幂: true
new HashMap(10) 首次 put 后真实 table.length: 16

真实结果验证了两件事:第一,不管构造函数传入的初始容量是多少,tableSizeFor 算法(用 numberOfLeadingZeros 找到大于等于该数的最小 2 的幂)真实把它规整成了 2 的幂,10 变成 16、17 变成 32。第二,用反射拿到 new HashMap<>(10) 真实创建出来的内部 table 数组长度,结果是 16,和算法计算值完全对上,证明这不是纸面推导,是 HashMap 真实的运行时行为。

真实验证:(n-1)&hash 只在 n 是 2 的幂时等价于 hash%n

这才是"必须是2的幂"的真正原因:HashMap 定位桶用的是位运算而不是取模,而这个位运算只在特定条件下才和取模结果相同。

BitwiseHashMapDemo.java int n = 16; for (int h = 0; h < 1000; h++) { if (((n-1) & h) != (h % n)) { allMatch = false; break; } }
真实实测 · n=16
n=16(2的幂) 时,(n-1)&hash 与 hash%n 是否对所有测试值都相等: true
真实实测 · n=15
n=15(非2的幂) 时,(n-1)&hash 与 hash%n 是否对所有测试值都相等: false
首个不一致样例: h=1, (n-1)&h=0, h%n=1
真实数据背后的原理。当 n 是 2 的幂(比如 16 = 10000)时,n-1 的二进制是全 1 的低位掩码(01111),(n-1)&h 真实等价于"只保留 h 的低 4 位",这在数学上和 h % 16 完全等价——这不是巧合,是这两种运算在二进制表示下的天然性质。但 n=15(01111,凑巧也是低位全 1,但 n-1=14=01110,不是掩码模式)时这个等价关系真实被破坏了,穷举 1000 个值,第一个反例在 h=1 就出现:(14)&1=0,但 1%15=1,两者不一致——HashMap 如果用非 2 的幂容量,定位到的桶下标会真实和取模语义不符,直接破坏哈希分布的正确性。这就是为什么 HashMap 宁可在构造时悄悄把你传入的容量"改"成 2 的幂,也不愿意改用真正的取模运算(取模的开销比位运算大)。

真实验证:扰动函数确实改变了哈希值

HashMap 在真正做 (n-1)&hash 之前,会先对 hashCode() 做一次扰动(h ^ (h>>>16)),目的是让高位也参与低位的运算。

真实实测
"abc".hashCode()=96354, 扰动后=96355, 是否相同: false
Integer.bitCount(255)=8(应为8,二进制全1)
Integer.highestOneBit(100)=64(100的二进制最高位对应的2的幂)
Integer.numberOfLeadingZeros(1)=31(32位中1前面有多少个0)

真实验证了扰动函数确实产生了效果(96354 变成 96355)。因为最终定位桶只用了 hashCode() 的低几位(容量越小,用到的位数越少),如果不做这一步异或高16位的处理,那些只在高位上有差异、低位完全相同的对象会真实全部落进同一个桶,扰动函数用很小的代价把高位的信息也混进了低位,真实改善了哈希分布。

分步演示:HashMap 为什么能用位运算代替取模

每一行都是真实运行这段代码得到的结果。

hash & (n-1) 真实能替代 hash % n,前提是 n 必须是2的幂——HashMap 真实靠强制容量为2的幂来保证这个替代成立
这一步新验证的对比

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17(--add-opens java.base/java.util=ALL-UNNAMED)得到,tableSizeFor 计算结果和反射拿到的真实 table 长度完全对应。
    • 和HashMap 基础、HashMap 树化两篇一起,拼出了 HashMap 从容量规整、哈希扰动、到桶内冲突处理的完整真实图景。作为这一批的收尾,系列 Java 内部原理现在共 60 篇。
    • 没有涉及:HashMap 扩容时(resize)如何利用"是2的幂"这个性质,把旧桶里的元素无需重新计算哈希就能真实拆分成两个新桶——这个话题值得单独写一篇。
    京ICP备2026067502号-1京公网安备11010502063487号