零、前言
最近在写博客时,突然又想起来哪个经常出现在面试题里的问题:
HashMap扩容为什么是原来的2倍?
因为看过源码,我觉得这个问题并不难。在我之前的通俗解释equals和hashCode的关系和作用里也说过这个原因。但为了博客的严谨性,所以还是查了一下,验证一下自己的观点。
结果发现网上各种类似的文章,都是稀里糊涂的解释这个问题,逻辑完全禁不起推敲。
所以我就单独写了这一篇博客,谈一下这个问题。
先说我的结论:
这只是为了适应一种快速散列方法做出的适当妥协和经验值。
[看完本文如果还有不理解,或者不认同的,请留言交流]
一、网上的各种奇怪解释
类似逻辑不清晰的解释,网上很多,我就精选一些被大家广泛认同的观点:
扩容可以减少hash冲突(我精简了大量车轱辘话)
点评:这不废话吗,容量不够,hash冲突就会加剧,当然是为了减少hash冲突。这是在拿问题当结论了?人家问的是为什么是2倍的扩容。
2的次幂的容量可以让散列更均匀,减少hash冲突
点评:这显然是背八股文记混了,完全没有因果关系。属于发动了ChatGPT的胡说八道技能(大概率也能唬住面试官,反正面试要的就是个理直气壮的气势)。刨除车轱辘话,还是等于没讲
点评:这个说法很奇怪,脑洞也很大,是一个经过思考说法,但显然不太对
还有很多博客指出了关键点:hash % (n-1), 甚至还画了 &运算的图。
然后说:看,这样做hash运算之后可以让结果更均匀
点评:找到了关键点位置,但理解的稀里糊涂。
二、解释
我们先想一个问题:假如让你设计一个散列算法,要怎么设计?
2-1. 散列
所谓散列算法,就是把输入的值尽量均匀的分布在一段固定长度上面。在HashMap里就是散列到一段固定长度的数组里。
这里有两个关键点:
- 尽量均匀,也就是要充分利用空间
- 固定长度,也就是不能超过这个空间
如果没学过计算机,我觉得大家的一个朴素想法可能是:让输入的这个值经历一通复杂的计算过程(就像加密一样,加减乘除一通操作),这样输出的值就“足够乱”了,也就足够“均匀”了。
但我们学过计算机,就一眼就发现这种朴素想法的问题:这样是足够乱,但你怎么让它满足条件2呢,边界控制不住呀。
我们稍作思考,就很容易想到“取余运算”。也就是:xxx % n。 这也是我们开发中常用的分散数据+控边界的算法(比如负载均衡)。
我们先不管这个xxx怎么来的,单就对n取余余数 性能就不怎么稳定(随着容量变大,性能会越来越差)。作为一个设计良好的API,怎么会允许出现o(n)复杂度的计算呢,况且是使用频次如此高的类。
接着一开始那个朴素的想法,稍作完善,其实就是目前采用的散列算法:
用 “&运算”来控制这个足够乱的数的边界。
假如要控制的长度是16以内,就用二进制数: 0000 1111和这个值做&运算
因为&的计算特性,相当于把这个值截取了低4位。这四位数无论如何变化,也超不过16(最大15)
我们再观察就会发现,这个看起来很有规律的二进制,不就是 16 - 1吗。 这不巧了吗,16正好就是我们要控制的数据范围。我们的数据范围(16)居然还能变成一个二进制工具数
,把任意数截取之后,都限制在数据范围(16)之内(妙哇~)。
再分析就会发现:并不是所有数 - 1都有这个特性。要形成 ...0001111...
这样的数,必须由二进制...00000100000...
这样的数减去1 得到。
而...00000100000...
这样的数就是
2
i
2^i
2i
2-2. 很乱的数
最后来再看前面被略过的“很乱的数”,想必大家都已经知道了,也就是对象的hash值。
如果不看前文,你是否会想用“随机数”充当这个很乱的数。
别搞混了,hash长得确实很随机,但不是随机数。随机数的意思是每次得到的值都毫无规律。而我们要的是输入一个值,返回值是个确定的数,只不过这个数看起来很乱,很随机。
和MD5或者加密算法类似
2-3. 为什么截取低位
直观的看一下hashcode长什么样就明白了
public static void testHashCode() {
System.out.println("abcde".hashCode());
System.out.println("abcdf".hashCode());
System.out.println("abcdg".hashCode());
}
输出
92599395
92599396
92599397
这几个字符串hashcode的高位(前面几位)都是重复的,只有最后一位不同。只有截取低位时才能区分开来,如果大部分hash截取之后都一样,就会产生大量的hash冲突。
注意:上面展示的是这几个字符串对象的十进制hashcode,他们的二进制同样是高位完全一样。
细心的读者可能会发现,HashMap里并不是直接使用object.hashCode(),而是用的下面这个方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
但其实这并不影响效果,结果依然是高位重复,大家可以自己试试。
所以我不认为和( 2 i 2^i 2i-1)做&运算是HashMap散列的一部分。在我看来这就是简单的对hash的截取操作,取对象的hash是jvm已经做好了的,而用&去“截取”则是一个极为快速的位运算操作。
三、思考
- 散列算法只有截取hash和使用%这两种思路吗?
我觉得未必。只是目前科研技术能想到的只有这两种方式。
所以很多博客义正言辞的说:因为可以避免使用&运算,所以采用&运算。
搞得好像已经数学证明散列算法只有这两种思路似的。 - 如果不考虑散列问题。简单乘以2的扩容真的是一个好方案吗?
肯定不是。简单想想就会明白,扩容的速度在呈指数级增长,只是从长期的经验值上看,这种方案还可以。并没有因此产生严重的问题。毕竟重新散列也是很费时间的,所以一次性多扩容也有道理。但这种粗犷的增长方式,空间浪费是必然的。所以我说:这是一种为了一种巧妙快速的散列算法做出的适当妥协。如果不考虑散列问题,我相信JDK开发团队能做出更精细化的扩容方案。
四、总结
- 以目前的算法技术,当HashMap的容量为2的幂次方时,可以简单巧妙的实现快速截取hash值作为散列值的效果,从而避免了使用%运算做散列 这样大量的时间消耗。
- 而散列的均匀不均匀,是对象hashcode本身的功能(功劳),这里只是对这个hashcode截取了一段有效(能区分对象)且能控制范围的值。
五、附录
附上ChatGPT给出的答案,我觉得还是比较准的:
在Java中,HashMap类使用2的幂扩容,以在调整大小操作期间实现更好的性能。当HashMap中的元素数量超过一定阈值时,底层数组(桶数组)会被调整大小以容纳更多元素。此调整大小操作是为了保持较低的负载因子,即元素数量与桶数量的比率。
2的幂扩容简化了调整大小期间重新散列的过程。当数组大小是2的幂时,哈希函数可以使用位与操作(hash & (length - 1))有效地计算,而不是使用非2的幂大小时的模运算(hash % length)。这个位与操作利用了数组大小是2的幂这一事实,确保了哈希码的低位被使用,从而实现更均匀地将键分布在桶之间。
以下是2的幂扩容有益的一些原因:
高效计算桶索引:
使用2的幂大小时,模运算被位与操作取代,位与操作的计算成本更低。
例如,如果数组大小是16(2的4次方),表达式 hash & 15 可以高效地计算索引,等同于 hash % 16。
改善哈希码分布:
在位与操作中使用哈希码的低位有助于实现更均匀的键分布。
简化代码:
使用2的幂大小简化了计算桶索引的代码,使其更为简洁,潜在地更快速。
需要注意的是,虽然2的幂扩容提供了这些优势,但在某些情况下,它可能会导致哈希码的模式与2的幂大小对齐,从而产生更多的碰撞。然而,在实践中,简单性和性能之间的权衡通常倾向于使用2的幂扩容。此外,Java中HashMap的现代实现可能使用其他技术,例如树结构进行冲突解决,以处理碰撞成为问题的情况。