mirror of
https://github.com/Snailclimb/JavaGuide
synced 2025-06-16 18:10:13 +08:00
[docs update]完善回答HashMap 的长度为什么是 2 的幂次方
This commit is contained in:
parent
ee1e7a5140
commit
af341e3b05
@ -185,7 +185,7 @@ final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
|
|||||||
|
|
||||||
JDK1.8 之前 `HashMap` 底层是 **数组和链表** 结合在一起使用也就是 **链表散列**。HashMap 通过 key 的 `hashcode` 经过扰动函数处理过后得到 hash 值,然后通过 `(n - 1) & hash` 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
|
JDK1.8 之前 `HashMap` 底层是 **数组和链表** 结合在一起使用也就是 **链表散列**。HashMap 通过 key 的 `hashcode` 经过扰动函数处理过后得到 hash 值,然后通过 `(n - 1) & hash` 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
|
||||||
|
|
||||||
所谓扰动函数指的就是 HashMap 的 `hash` 方法。使用 `hash` 方法也就是扰动函数是为了防止一些实现比较差的 `hashCode()` 方法 换句话说使用扰动函数之后可以减少碰撞。
|
`HashMap` 中的扰动函数(`hash` 方法)是用来优化哈希值的分布。通过对原始的 `hashCode()` 进行额外处理,扰动函数可以减小由于糟糕的 `hashCode()` 实现导致的碰撞,从而提高数据的分布均匀性。
|
||||||
|
|
||||||
**JDK 1.8 HashMap 的 hash 方法源码:**
|
**JDK 1.8 HashMap 的 hash 方法源码:**
|
||||||
|
|
||||||
@ -286,11 +286,55 @@ final void treeifyBin(Node<K,V>[] tab, int hash) {
|
|||||||
|
|
||||||
### HashMap 的长度为什么是 2 的幂次方
|
### HashMap 的长度为什么是 2 的幂次方
|
||||||
|
|
||||||
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。
|
为了让 `HashMap` 存取高效并减少碰撞,我们需要确保数据尽量均匀分布。哈希值在 Java 中通常使用 `int` 表示,其范围是 `-2147483648 ~ 2147483647`前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但是,问题是一个 40 亿长度的数组,内存是放不下的。所以,这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。
|
||||||
|
|
||||||
**这个算法应该如何设计呢?**
|
**这个算法应该如何设计呢?**
|
||||||
|
|
||||||
我们首先可能会想到采用 % 取余的操作来实现。但是,重点来了:“**取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作**(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。” 并且 **采用二进制位操作 & 相对于 % 能够提高运算效率**,这就解释了 HashMap 的长度为什么是 2 的幂次方。
|
我们首先可能会想到采用 % 取余的操作来实现。但是,重点来了:“**取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作**(也就是说 `hash%length==hash&(length-1)` 的前提是 length 是 2 的 n 次方)。” 并且,**采用二进制位操作 & 相对于 % 能够提高运算效率**。
|
||||||
|
|
||||||
|
除了上面所说的位运算比取余效率高之外,我觉得更重要的一个原因是:**长度是 2 的幂次方,可以让 `HashMap` 在扩容的时候更均匀**。例如:
|
||||||
|
|
||||||
|
- length = 8 时,length - 1 = 7 的二进制位`0111`
|
||||||
|
- length = 16 时,length - 1 = 15 的二进制位`1111`
|
||||||
|
|
||||||
|
这时候原本存在 `HashMap` 中的元素计算新的数组位置时 `hash&(length-1)`,取决 hash 的第四个二进制位(从右数),会出现两种情况:
|
||||||
|
|
||||||
|
1. 第四个二进制位为 0,数组位置不变,也就是说当前元素在新数组和旧数组的位置相同。
|
||||||
|
2. 第四个二进制位为 1,数组位置在新数组扩容之后的那一部分。
|
||||||
|
|
||||||
|
这里列举一个例子:
|
||||||
|
|
||||||
|
```
|
||||||
|
假设有一个元素的哈希值为 10101100
|
||||||
|
|
||||||
|
旧数组元素位置计算:
|
||||||
|
hash = 10101100
|
||||||
|
length - 1 = 00000111
|
||||||
|
& -----------------
|
||||||
|
index = 00000100 (4)
|
||||||
|
|
||||||
|
新数组元素位置计算:
|
||||||
|
hash = 10101100
|
||||||
|
length - 1 = 00001111
|
||||||
|
& -----------------
|
||||||
|
index = 00001100 (12)
|
||||||
|
|
||||||
|
看第四位(从右数):
|
||||||
|
1.高位为 0:位置不变。
|
||||||
|
2.高位为 1:移动到新位置(原索引位置+原容量)。
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️注意:这里列举的场景看的是第四个二进制位,更准确点来说看的是高位(从右数),例如 `length = 32` 时,`length - 1 = 31`,二进制为 `11111`,这里看的就是第五个二进制位。
|
||||||
|
|
||||||
|
也就是说扩容之后,在旧数组元素 hash 值比较均匀(至于 hash 值均不均匀,取决于前面讲的对象的 `hashcode()` 方法和扰动函数)的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。
|
||||||
|
|
||||||
|
这样也使得扩容机制变得简单和高效,扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。
|
||||||
|
|
||||||
|
最后,简单总结一下 `HashMap` 的长度是 2 的幂次方的原因:
|
||||||
|
|
||||||
|
1. 位运算效率更高:位运算(&)比取余运算(%)更高效。当长度为 2 的幂次方时,`hash % length` 等价于 `hash & (length - 1)`。
|
||||||
|
2. 可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。
|
||||||
|
3. 扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。
|
||||||
|
|
||||||
### HashMap 多线程操作导致死循环问题
|
### HashMap 多线程操作导致死循环问题
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user