HashMap 中的取模和扩容公式推导

Sumkor
为什么 HashMap 容量 capacity 大小是 2 的 n 次幂?
为什么使用 e.hash & (capacity - 1) 位运算作取模公式?
为什么扩容时使用 e.hash & oldCap 来计算扩容后的数组索引?
本文通过推导 HashMap 中的取模和扩容公式以回答上述问题。

1. 按位与(&)运算的理解

位运算的运算规则如下:

符号描述运算规则
&两个位都为1时,结果才为1
|两个位都为0时,结果才为0
^异或两个位相同为0,相异为1
~取反0变1,1变0
<<左移各二进位全部左移若干位,高位丢弃,低位补0
>>右移各二进位全部右移若干位,对无符号数,高位补0,有符号数,各编译器处理方法不一样,有的补符号位(算术右移),有的补0(逻辑右移)

两个二进制的数按位与,如 A & B,当 B 中某一位为 1,则保留 A 上对应位上的数。
假设 B = 1000(二进制),第四位为 1。
当 A = 1001(二进制),A & B 取得第四位为 1,得到 A & B = 1000(二进制);
当 A = 0110(二进制),A & B 取得第四位为 0,得到 A & B = 0000(二进制);

理解了按位与的这一层含义之后,再来看 HashMap 中的取模和扩容算法。

2. 取模运算

HashMap 的取模公式为 e.hash & (capacity - 1) 。

这里 capacity 是 HashMap 数组结构的大小,约定为 2 的 n 次幂,记为 capacity = 2n
对于节点 e,它的哈希值用 e.hash 表示。

正常来说,取模公式为 e.hash % capacity,为什么 HashMap 中可以用位运算来替代呢?

2.1 当 e.hash 为正数

从二进制角度来看,e.hash / capacity = e.hash / 2n = e.hash >> n,即把 e.hash 右移 n 位,此时得到了 e.hash / 2n 的商。
而被移掉的部分(低 n 位),则是 e.hash % 2n,也就是余数

如何取得 e.hash 的低 n 位呢?

已知 2n 的二进制形式为 1 后面跟着 n 个 0,则 2n - 1 的二进制形式为 n 个 1。
如 8 = 23,其二进制形式为 1000,7 = 23 - 1,其二进制形式为 111。

根据对按位与(&)操作的理解,e.hash & (2n - 1) 就是取得 e.hash 的低 n 位,同样是余数

因此我们可以推导出 e.hash & (capacity - 1) = e.hash % capacity。

验证:

// e.hash = 7,n = 2,capacity = 4
System.out.println(7 % 4);// 3
System.out.println(7 & 3);// 3

2.2 当 e.hash 为负数

当 e.hash 为负数时,如 -7,若 n = 2,此时 e.hash & (capacity - 1) 得到的结果与 e.hash % capacity 不同,是不是翻车了呢?

// e.hash = -7,n = 2,capacity = 4
System.out.println(-7 % 4);// -3
System.out.println(-7 & 3);// 1

其实不然,我们来看下百度对于 -7 mod 4 的计算结果:

-7 mod 4

其实 -7 mod 4 得到 -3 或 1 都是正确的结果。取决于计算机采用的运算规则。

来看下负数取模的问题。以下内容来自 《负数取模怎么算》

负数取模怎么算

整数除法取整

考虑这样一个计算题:18 除以 5,要得到一个整数结果,究竟应该是 3 还是 4?这就是一个问题了。计算机上有几种对于结果取整的方法:

  • 向上取整,向+∞方向取最接近精确值的整数,也就是取比实际结果稍大的最小整数,>也叫 Ceiling 取整。这种取整方式下,17 / 10 == 2,5 / 2 == 3, -9 / 4 == -2。
  • 向下取整,向-∞方向取最接近精确值的整数,也就是取比实际结果稍小的最大整数,也叫 Floor 取整。这种取整方式下,17 / 10 == 1,5 / 2 == 2, -9 / 4 == -3。
  • 向零取整,向0方向取最接近精确值的整数,换言之就是舍去小数部分,因此又称截断取整(Truncate)。这种取整方式下,17 / 10 == 1,5 / 2 == 2, -9 / 4 == -2。

取模怎么算

取模运算实际上是计算两数相除以后的余数。假设 q 是 a、b 相除产生的商(quotient),r 是相应的余数(remainder),那么在几乎所有的计算系统中,都满足:
a = b x q + r,其中 |r|<|a|。
因此 r 有两个选择,一个为正,一个为负;相应的,q 也有两个选择。如果a、b 都是正数的话,那么一般的编程语言中,r 为正数;如果 a、b 都是负数的话,一般 r 为负数。但是如果 a、b 一正一负的话,不同的语言则会根据除法的不同结果而使得 r 的结果也不同,但是一般 r 的计算方法都会满足:
r = a - (a / b) x b

回到我们的例子,可知:

/**
 * Truncate 法:
 * r = (-7)-(-7/4)x4 = (-7)-(-1)x4 = -3
 * Ceiling 法:
 * r = (-7)-(-7/4)x4 = (-7)-(-1)x4 = -3
 * Floor 法:
 * r = (-7)-(-7/4)x4 = (-7)-(-2)x4 = 1
 */
System.out.println(-7 % 4);// -3
System.out.println(-7 & 3);// 1

Java 语言中的取模运算采用了 truncate 法得到的 -3,与 Floor 法得到的 1 同样都是 -7 mod 4 的结果。
从广义上来说,当 e.hash 为负数,e.hash & (capacity - 1) = e.hash % capacity 依旧是成立的。

补充一下,ConcurrentHashMap 中的 hash 算法进行了取绝对值操作,避免了 e.hash 为负数的情况。
java.util.concurrent.ConcurrentHashMap#spread

static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS;
}

如上,HASH_BITS 的作用是使 hash 值为正数,hash 值为负数在 ConcurrentHashMap 有特殊的含义。

3. 扩容运算

定义 HashMap 中扩容之前的旧数组容量为 oldCap,其中 oldCap = 2n,扩容之后的新数组容量为 2oldCap。

在扩容的时候,对于节点 e,计算 e.hash & oldCap。
当 e.hash & oldCap == 0,则节点在新数组中的索引值与旧索引值相同。
当 e.hash & oldCap != 0,则节点在新数组中的索引值为旧索引值+旧数组容量。

设:扩容前,节点 e 在旧数组索引值为 x;扩容后,节点 e 在新数组的索引值为 y.

3.1 推导 e.hash & oldCap == 0 时,y = x

在旧数组中,取模公式为 e.hash & (oldCap - 1) = x ,由于 oldCap = 2n,可知 oldCap - 1 的二进制形式为 n 个 1。
根据对按位与(&)操作的理解,e.hash & (oldCap - 1) 相当于取 e.hash 的低 n 位的值,该值为 x。

在新数组中,取模公式为 e.hash & (2oldCap - 1) = y,由于 oldCap = 2n,可知 2oldCap - 1 的二进制形式为 n + 1 个 1。
根据对按位与(&)操作的理解,e.hash & (2oldCap - 1) 相当于取 e.hash 的低 n + 1 位的值,该值为 y。

同理当 e.hash & oldCap = e.hash & 2n = 0 时,说明 e.hash 的第 n + 1 位为 0,
此时低 n + 1 位的值与低 n 位的值是相等的,即 y = x,命题得证。

3.1 推导 e.hash & oldCap != 0 时,y = x + oldCap

当 e.hash & oldCap != 0 时,由于 oldCap = 2n,说明 e.hash 的第 n + 1 位不是 0 ,而是 1。
对于一个二进制数,其第 n + 1 位为 1,其余 n 位为 0,该二进制数的值为 2 的 n 次幂,也就是 oldCap。

当 e.hash 的第 n + 1 位为 1,且低 n + 1 位的值为 y,低 n 位的值为 x,此时 y = x + oldCap,命题得证。

4.总结

推导可知,当 HashMap 容量 capacity 大小是 2 的 n 次幂时,取模公式和扩容公式都可以用按位与运算来替换取模运算,极大地提升了运算效率。

但是,按位与运算实际是取 hash 值的低位,舍弃了高位会使得 hash 运算结果不均匀。HashMap 通过引入扰动运算,让高位充分参与 hash 运算来避免这个问题。


作者:Sumkor
链接:https://segmentfault.com/a/11...

阅读 1.7k

Sumkor's Blog
Java and Everything.

会写点代码

122 声望
1.3k 粉丝
0 条评论
你知道吗?

会写点代码

122 声望
1.3k 粉丝
宣传栏