一文弄懂 HashMap 中的位运算
前言
我们平时在写代码过程中用的位运算操作比较少,因为我们更关注于可读性而不是性能,如果为了性能而使用较多的位运算,我想我们的同事会疯掉。但在框架里位运算却非常常见,因为框架的性能是我们关注的点。
Java 8 中 HashMap 的实现使用了很多位操作来进行优化。本文将详细介绍每种位操作优化的原理及作用。
Java 中的位运算
首先,什么是位运算?
程序中的所有数在计算机内存中都是以二进制的形式储存的。位运算就是直接对整数在内存中的二进制位进行操作。
那么有哪些种类的位运算呢?
常见的运算符有与(&)
、或(|)
、异或(^)
、取反(~)
、左移(<<)
、右移(>>是带符号右移
, >>>无符号右移动
)。
下面来细看看每一种位运算的规则。
需要注意的是,下面测试用的数据都是 int 类型,int 类型是 4 个字节长度,但是为了方便说明示例中用的数值我都用 1 个字节表示。希望不会给大家造成困扰。
位运算 &(与)
规则:二进制对应位两两进行逻辑 AND 运算,只有对应位的值都是 1 时结果才为 1,否则即为 0。
即:1 & 1 = 1
,0 & 1 = 0
,1 & 0 = 0
,0 & 0 = 0
。
例如:3 & 5 = 1
。
0000 0011
&
0000 0101
=
0000 0001
位运算 | (或)
规则:二进制对应位两两进行逻辑或运算,对应位中有一个为 1 则为 1。
即:0 | 0 = 0
,0 | 1 = 1
,1 | 1 = 1
。
例如:3 | 5 = 7
。
0000 0011
|
0000 0101
=
0000 0111
位运算 ^ (异或)
规则:二进制对应位两两进行逻辑 XOR (异或) 的运算,当对应位的值不同时为 1,否则为 0。
即:0 ^ 0 = 0
, 0 ^ 1 = 1
, 1 ^ 1 = 0
。
例如:3 ^ 5 = 6
0000 0011
^
0000 0101
=
0000 0110
按位取反~
规则:二进制的 0 变成 1,1 变成 0。
即:~1 = 0
,~0 = 1
。
左移运算符
规则:向左进行移位操作,高位丢弃,低位补 0。
5 << 1 ===> 0000 0101 << 1 = 0000 1010 = 10
7 << 2 ===> 0000 0111 << 2 = 0001 1100 = 28
9 << 3 ===> 0000 1001 << 3 = 0100 1000 = 72
11 << 2 ===> 0000 1011 << 2 = 0010 1100 = 44
很明显就可以看出 a << b
= a * (2 ^ b)
右移运算符
规则:向右进行移位操作,对无符号数,高位补 0,有符号位,高位补 1。
5 >> 1 ===> 0000 0101 >> 1 = 0000 0010 = 2
7 >> 2 ===> 0000 0111 >> 2 = 0000 0001 = 1
9 >> 3 ===> 0000 1001 >> 3 = 0000 0001 = 1
11 >> 2 ===> 0000 1011 >> 2 = 0000 0010 = 2
大家发现什么规律没有?
5 >> 1
= 5 / 2
= 2;11 >> 2
= 11 / 4
= 2。
综合上面可以看到,如果某个数值右移 n 位,就相当于拿这个数值去除以 2 的 n 次幂。如果某个数值左移 n 位,就相当于这个数值乘以 2 ^ n
。
无符号右移运算符
计算机中数字以补码存储,首位为符号位;无符号右移,忽略符号位,左侧空位补 0。即,若该数为正,则高位补 0,而若该数为负数,则右移后高位同样补 0。
HashMap 中的位运算
我们在前文深入剖析 HashMap,中提到过关于位运算的使用,下面将分别介绍 HashMap 中的几种位运算的实现原理以及它们的作用、优点。
计算哈希桶索引
HashMap 的 put(key, value)
操作和 get(key)
操作,会根据 key 计算出该 key 对应的值存放的桶的索引。计算过程如下:
- 计算 key 值的哈希值得到一个正整数,
hash(key)
; - 使用
hash(key)
得到的正整数,除以桶的长度取余,结果即为 key 值对应 value 所在桶的索引,index = hash(key) % length
。
put 操作,计算 key 值对应 value 所在哈希桶的索引的主要代码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...
//n = tab.length
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
...
关键部分就是:tab[i = (n - 1) & hash]
。
上述代码中,使用了与操作来代替取余,我们先来看结论:当 length 为 2 的次幂时,num & (length - 1) = num % length
等式成立。
使用位运算(&)代替取模运算(%)
具体的效率对比这里不赘述,简单说一下为什么 & 可以代替 % :
X % 2^n = X & (2^n - 1)
2^n
表示 2 的 n 次方,也就是说,一个数对 2^n
取模相当于一个数和 (2^n - 1)
做按位与运算 。
假设 n 为 3,则 2^3 = 8
,表示成 2 进制就是 1000。2^3 - 1 = 7
,即 0111。
此时 X & (2^3 - 1)
就相当于取 X 的 2 进制的最后三位数。
从 2 进制角度来看,X / 8
相当于 X >> 3
,即把 X 右移 3 位,此时得到了 X / 8 的商,而被移掉的部分(后三位),则是 X % 8
,也就是余数。
推广到一般:
对于所有 2^n
的数,二进制表示为:
1000…000,1 后面跟 n 个 0
而 2^n - 1
的二进制为:
0111…111,0 后面跟 n 个 1
X / 2^n
是 X >> n
,那么 X & (2^n - 1)
就是取被移掉的后 n 位,也就是 X % 2^n
。
所以,当 length 为 2 的 n 次幂时,转换为二进制,最高位为 1,其余位为 0;length-1
则所有位均为 1。1 和另一个数进行与操作时,结果为另一个数本身。
所以 length-1
与另一个数进行与操作时,另一个数的高位被截取,低位为另一个数对应位的本身。结果范围为 0 ~ length-1
,和取余操作结果相等。
那么桶数为什么必须是 2 的次幂?
比如当 length = 15 时,转换为二进制为 1111,length - 1 = 1110。length - 1 的二进制数最后一位为 0,因此它与任何数进行与操作的结果,最后一位也必然是0,也即结果只能是偶数,不可能是单数,这样的话单数桶的空间就浪费掉了。
同理:length = 12,二进制为1100,length - 1 的二进制则为 1011,那么它与任何数进行与操作的结果,右边第 3 位必然是0,这样同样会浪费一些桶空间。
综上所述,当 length 为 2 的次幂时,num & (length - 1) = num % length
等式成立,并且它有如下特点:
- 位运算快于取余运算;
- length 为 2 的次幂时,
0 ~ length-1
范围内的数都有机会成为结果,不会造成桶空间浪费。
hash 方法优化
计算哈希值方法中有一个无符号右移和异或操作:^ (h >>> 16)
,它的作用是什么?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从上面的代码可以看到 key 的 hash 值的计算方法。key 的 hash 值高16位不变,低 16 位与高 16 位异或作为 key 的最终 hash 值。(h >>> 16
,表示无符号右移 16 位,高位补 0,任何数跟0 异或都是其本身,因此 key 的 hash 值高 16 位不变。)
为什么要这么干呢?
这个与 HashMap 中 table 下标的计算有关。
n = table.length;
index = (n-1) & hash;
因为,table 的长度都是 2 的幂,因此 index 仅与 hash 值的低 n 位有关,hash 值的高位都被与操作置为 0 了。
假设 table.length=2^4=16
。
由上图可以看到,只有 hash 值的低 4 位参与了运算。 这样做很容易产生碰撞。
设计者权衡了速度、功效、质量,将高 16 位与低 16 位异或来减少这种影响。
为什么使用异或操作?
与 & 操作和或 | 操作的结果更偏向于 0 或者 1,而异或的结果 0 和 1 有均等的机会。
指定初始化容量
我们知道,在构造 HashMap 时,可以指定 HashMap 的初始容量,即桶数。而桶数必须是 2 的次幂,因此当我们传了一个非 2 的次幂的参数时,计算离传入参数最近的 2 的次幂作为桶数。(注:2 的次幂指的是 2 的整数次幂)。
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
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 >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
HashMap 是通过 tableSizeFor
方法来计算离输入参数最近的 2 的次幂。tableSizeFor
方法中使用了 5 次无符号右移和或操作。
假如现在我们有一个二进制数 1xxx xxxx xxxx xxxx
,x 可能是 0 或者 1。我们来按照上述代码进行无符号右移和 或操作:
(1)1xxx xxxx xxxx xxxx |= 1xxx xxxx xxxx xxxx >>> 1
//1xxx xxxx xxxx xxxx无符号右移1位
1xxx xxxx xxxx xxxx >>> 1 ===> 01xx xxxx xxxx xxxx
//然后和原数进行 或操作
1xxx xxxx xxxx xxxx | 01xx xxxx xxxx xxxx ===> 11xx xxxx xxxx xxxx
从上述结果看出,无符号右移 1 位然后和原数进行 或操作,所得结果将最高 2 位变成 1。
我们再将结果 11xx xxxx xxxx xxxx
继续进行操作。
(2)11xx xxxx xxxx xxxx |= 11xx xxxx xxxx xxxx >>> 2
//11xx xxxx xxxx xxxx无符号右移2位
11xx xxxx xxxx xxxx >>> 2 ===> 0011 xxxx xxxx xxxx
//然后和原数进行 或操作
11xx xxxx xxxx xxxx | 0011 xxxx xxxx xxxx ===> 1111 xxxx xxxx xxxx
再进行 无符号右移 2 位然后和原数进行 或操作,所得结果将最高 4 位变成 1。
我们再将结果 1111 xxxx xxxx xxxx
继续进行操作。
(3)1111 xxxx xxxx xxxx |= 1111 xxxx xxxx xxxx >>> 4
//1111 xxxx xxxx xxxx无符号右移4位
1111 xxxx xxxx xxxx >>> 4 ===> 0000 1111 xxxx xxxx
//然后和原数进行 或操作
1111 xxxx xxxx xxxx | 0000 1111 xxxx xxxx ===> 1111 1111 xxxx xxxx
再进行 无符号右移 4 位然后和原数进行 或操作,所得结果将最高 8 位变成 1。
从上述移位和或操作过程,我们看出,每次无符号右移然后再和原数进行或操作,所得结果保证了最高 n * 2
位都为1,其中 n 是无符号右移的位数。
为什么无符号右移 1、 2、4、 8、16位并进行 或操作后就结束了?因为 int 为 32 位数。这样反复操作后,就保证了原数最高位后面都变成了 1。
二进制数,全部位都为 1,再加 1 后,就变成了最高位为 1,其余位都是 0,这样的数就是 2 的次幂。
因此 tableSizeFor
方法返回:当 n 小于最大容量 MAXIMUM_CAPACITY
时返回 n + 1
。
tableSizeFor
方法中,int n = cap - 1
,为什么要将 cap 减 1?
如果不减 1 的话,当 cap 已经是 2 的次幂时,无符号右移和或操作后,所得结果正好是 cap 的 2 倍。
最后
HashMap 当中运用了很多精巧的位运算操作,比如:扩容方法里的位运算,这对于提高性能有很大帮助。更多的,很多的优化点,最终目的还是为了让哈希后的结果更均匀的分部,减少哈希碰撞,提升 HashMap 的运行效率。