Java-取模操作中的&和(length-1)、HashMap取模、HashMap扩容
1.背景
在哈希表相关的操作中,有一个典型的问题。
将n个元素放置到长度为k的数组中
现在,我们假定数组的长度为8,元素个数为10个。
1、2、3、4、5、6、7、8、9、10
如果我们拥有一个理想的哈希函数,可以将其中8个元素均匀的放置到数组的8个位置上。
不过由于有10个元素,所以必然会出现冲突的可能。
2.演示
2.1 取模:直接%
现在,我们模拟下上面的操作,选择怎么设计一个哈希函数。
由于值是已知的,很显然能想到一个办法,直接取模。
@Test
void name2() {
for (int i = 1; i < 11; i++) {
log.info("k为: {},取模[8]: {}", i, i % 8);
}
}
运行结果
2023-06-17 16:56:34.381 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 1,取模[8]: 1
2023-06-17 16:56:34.382 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 2,取模[8]: 2
2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 3,取模[8]: 3
2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 4,取模[8]: 4
2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 5,取模[8]: 5
2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 6,取模[8]: 6
2023-06-17 16:56:34.383 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 7,取模[8]: 7
2023-06-17 16:56:34.384 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 8,取模[8]: 0
2023-06-17 16:56:34.384 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 9,取模[8]: 1
2023-06-17 16:56:34.384 -- [main] INFO cn.yang37.basic.HashCodeTest.name2 - k为: 10,取模[8]: 2
现在,给定的元素都能有序的放置到数组中。
2.2 hashCode()函数
上面的例子比较简单,我们换一下,假定给出的元素是。
sads、sadsa、wewq、sadd、hfhg、erter、tytr、cxfcx、dkfpdfs、dsfds
嗯,现在思路就乱了,没有任何规律呀?
所以,我们可以想到使用jdk自带的哈希函数,先来打印一下哈希值。
@Test
void name4() {
String[] array = {"sads", "sadsa", "wewq", "sadd", "hfhg", "erter", "tytr", "cxfcx", "dkfpdfs", "dsfds"};
Arrays.stream(array).forEach(e-> System.out.println(e.hashCode()));
}
运行结果
3522397
109194404
3645992
3522382
3199613
96786516
3575747
95104710
1717009152
95879302
为了放置进去开始的8个数组,我们容易想到,还是取模这个老办法。
@Test
void name4() {
String[] array = {"sads", "sadsa", "wewq", "sadd", "hfhg", "erter", "tytr", "cxfcx", "dkfpdfs", "dsfds"};
Arrays.stream(array).forEach(e -> {
int hashCode = e.hashCode();
log.info("k为: {},hashCode: {},取模[8]: {}", e, hashCode, hashCode % 8);
});
}
运行结果
2023-06-17 17:06:12.389 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sads,hashCode: 3522397,取模[8]: 5
2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sadsa,hashCode: 109194404,取模[8]: 4
2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: wewq,hashCode: 3645992,取模[8]: 0
2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sadd,hashCode: 3522382,取模[8]: 6
2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: hfhg,hashCode: 3199613,取模[8]: 5
2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: erter,hashCode: 96786516,取模[8]: 4
2023-06-17 17:06:12.391 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: tytr,hashCode: 3575747,取模[8]: 3
2023-06-17 17:06:12.392 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: cxfcx,hashCode: 95104710,取模[8]: 6
2023-06-17 17:06:12.392 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: dkfpdfs,hashCode: 1717009152,取模[8]: 0
2023-06-17 17:06:12.392 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: dsfds,hashCode: 95879302,取模[8]: 6
2.3 取模:& (arr.length - 1)
取模操作是一个常用的例子,翻看各种工程代码,我们会发现,取模,它并不是这样写的。
int mod = k % (arr.length);
而是
int mod = k & (arr.length - 1);
嗯,一个奇怪的写法,不妨先试试。
@Test
void name4() {
String[] array = {"sads", "sadsa", "wewq", "sadd", "hfhg", "erter", "tytr", "cxfcx", "dkfpdfs", "dsfds"};
Arrays.stream(array).forEach(e -> {
int hashCode = e.hashCode();
log.info("k为: {},hashCode: {},取模[8]: {}", e, hashCode, hashCode & (8 - 1));
});
}
注意,我将hashCode % 8
换成了hashCode & (8 - 1)
,再看下运行结果。
2023-06-17 17:10:16.230 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sads,hashCode: 3522397,取模[8]: 5
2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sadsa,hashCode: 109194404,取模[8]: 4
2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: wewq,hashCode: 3645992,取模[8]: 0
2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: sadd,hashCode: 3522382,取模[8]: 6
2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: hfhg,hashCode: 3199613,取模[8]: 5
2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: erter,hashCode: 96786516,取模[8]: 4
2023-06-17 17:10:16.232 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: tytr,hashCode: 3575747,取模[8]: 3
2023-06-17 17:10:16.233 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: cxfcx,hashCode: 95104710,取模[8]: 6
2023-06-17 17:10:16.233 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: dkfpdfs,hashCode: 1717009152,取模[8]: 0
2023-06-17 17:10:16.233 -- [main] INFO cn.yang37.basic.HashCodeTest.lambda$name4$0 - k为: dsfds,hashCode: 95879302,取模[8]: 6
嗯,结果还是一样。
通过位运算进行快速取模
2.分析
2.1 原理
通过位运算进行快速取模,使用的前提是数组长度是2的幂次方
。
当数组的长度是2的幂次方时,arr.length - 1
的二进制表示中所有位都是1,例如:
- 数组长度为4时,二进制表示为100,
arr.length - 1
的二进制表示为011
。 - 数组长度为8时,二进制表示为1000,
arr.length - 1
的二进制表示为0111
。 - 数组长度为16时,二进制表示为10000,
arr.length - 1
的二进制表示为01111
。 - 以此类推。
现在,看一下&
操作的原理。位运算&是对元素进行且的操作,即串联
。
有假则假,全真为真。
0 & 0 = 0
0 & 1 = 0
1 & 0 = 0
1 & 1 = 1
我们再来看下上面的arr.length - 1
,结果固定是0和n个1。
x & 0 = 0
x & 1 = x
取模是在干什么,就是找到那个余数。
当取余的时候,不管参与运算的数有多大,(arr.length-1)
的前面都是0。
# 10的二进制
1010
# 7(arr.leng-1)的二进制
0111
# 10 & 7
(10) 1010
(7) 0111
= 0010
# 再用150(10010110)做个例子,结果为6
(150) 10010110
(7) 00000111
= 00000110
所以,当参与&运算时,前面的x位数字都可以忽略掉了(结果必定是0),我们实际要关心的只有(arr.length-1)
的这个窗口。
由于(arr.length-1)
后面几位都是111...
,所以结果只会受输入的影响,值是什么结果就是什么即,前面提到的。
x & 1 = x
拿150举例,相当于把某倍数的arr.length-1
(8)扔掉,即10010(144),扔掉,取出余数6。
2.2 总结
当数组(槽)长度是2n时,元素k的取模操作可替换为位运算以提高效率。
# 取模
m = k % arr.length
# 可替换为
m = k & (arr.length-1)
简单抽取一个方法.
/**
* 根据hashCode()计算数组中索引位置
*
* @param key str
* @param arr arr
* @return idx
*/
private static int calculateIdx(String key, String[] arr) {
// 2^n
if (arr.length % 2 == 0) {
return key.hashCode() & (arr.length - 1);
}
// !(2^n)
return key.hashCode() % arr.length;
}
3.扩展
3.1 HashMap的取模运算
HashMap 的取模公式为e.hash & (capacity - 1)
。
这里 capacity 是 HashMap 数组结构的大小,约定为 2 的 n 次幂,记为 capacity = 2n。
对于节点 e,它的哈希值用 e.hash 表示。
3.2 HashMap扩容时高低位链表迁移
为啥低位链表要用(e.hash & oldCap) == 0
判断啊?
当链表节点被迁移到新的哈希桶时,核心逻辑是根据哈希值重新分配这些节点。
我们拆成3部分看。
- 部分1:定义两个链表,低位、高位。
- 部分2:填充高低位链表的具体元素
- 部分3:放置高低位链表到哈希桶的具体位置,低位不变还是原来的位置
j
,高位放到新位置j+oldCap
。
首先,咱们为啥要区分高低位这么2个链表出来?
不妨看看我们的填充条件。
(e.hash & oldCap) == 0
我们要有几个基本的知识(还是先建议你去看下我的那篇文章)
-
&是串联:有0则0,全1为1。
-
&(length-1)
实际上就是在比较最后的length-1
,因为length-1
前面的都是0,直接扔掉即可。
然后呢,我们看下面这个,以16到32为例。
16: 0001 0000
xx: xxxx xxxx
&: 0000 0000
我们现在知道旧的16是0001 0000
,也知道if中&的判断结果是0000 0000
。
那么xx的第5位(1那里)是不是必定是0?其他位我们都可以不管,反正这里肯定是0对不?
16: 0001 0000
xx: xxx0 xxxx
那我们再和新的32取模试下,32的二进制是0010 0000
,length-1
是0001 1111
。
来,我们做&运算。
xx: xxx0 xxxx
31: 0001 1111
哎,关键点来了,后面四个xxxx咱们也不知道是不是1,但是咱们肯定知道结果是这样了。
xx: xxx0 xxxx
31: 0001 1111
&: 0000 xxxx
那既然我们都知道这一位必然为0了,是不是可以再看下15。
xx: xxx0 xxxx
15: 0000 1111
&: 0000 xxxx
悟到了没有,只要能&出0来,实际上就是跟旧的15没区别,它的哈希桶位置不会变。