一文弄懂 HashMap 中的位运算

前言

我们平时在写代码过程中用的位运算操作比较少,因为我们更关注于可读性而不是性能,如果为了性能而使用较多的位运算,我想我们的同事会疯掉。但在框架里位运算却非常常见,因为框架的性能是我们关注的点。

Java 8 中 HashMap 的实现使用了很多位操作来进行优化。本文将详细介绍每种位操作优化的原理及作用。

Java 中的位运算

首先,什么是位运算?

程序中的所有数在计算机内存中都是以二进制的形式储存的。位运算就是直接对整数在内存中的二进制位进行操作。

那么有哪些种类的位运算呢?

常见的运算符有与(&)或(|)异或(^)取反(~)左移(<<)、右移(>>是带符号右移>>>无符号右移动)。

下面来细看看每一种位运算的规则。

需要注意的是,下面测试用的数据都是 int 类型,int 类型是 4 个字节长度,但是为了方便说明示例中用的数值我都用 1 个字节表示。希望不会给大家造成困扰。

位运算 &(与)

规则:二进制对应位两两进行逻辑 AND 运算,只有对应位的值都是 1 时结果才为 1,否则即为 0。

即:1 & 1 = 10 & 1 = 01 & 0 = 00 & 0 = 0

例如:3 & 5 = 1

0000 0011
&
0000 0101
=
0000 0001

位运算 | (或)

规则:二进制对应位两两进行逻辑或运算,对应位中有一个为 1 则为 1。

即:0 | 0 = 00 | 1 = 11 | 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^nX >> 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 的运行效率。​

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值