一个永远返回0的哈希函数,当然也能用。放进HashMap,数据照样能存,也能取。
但带来的问题是:所有键都会落到同一个桶里。查数据时,只能一个个往后找。原本应该很快的查询,最后退化成线性遍历。JDK 8之后,HashMap还增加了链表树化机制,极端情况下可以把查询复杂度降到对数级,后面再详细说。
哈希函数其实离我们程序员很近的。HashMap、数据库索引、缓存、文件校验,都能看到它的身影。
但哈希函数为什么能做到快速定位?一个好的哈希函数又应该满足什么条件?这些问题平时写代码很少需要自己考虑。
这篇内容就从哈希函数本身开始,逐步看看它是怎么工作的,以及它背后几个容易被忽略的问题。文中的代码统一使用Java 17。
哈希函数是什么
哈希函数做的事情可以概括成一句话:接收任意输入,算出一个固定范围内的数字。
它有一条铁律:同一个输入,永远算出同一个数字。调用一百次,一百次的结果都一样。输出的范围由具体的哈希函数决定,有的返回32位整数,也就是0到42亿之间,有的范围更大。
用Java 17写一个最简单的哈希函数:
// 逐字符累加,并混入位移运算
int hash(String input) {
int h = 0;
for (char c : input.toCharArray()) h = 31 * h + c;
return h;
}
这也是String类 hashCode方法的算法:逐个字符遍历,每次将累积值乘以31再加上当前字符的编码值。
开头那个永远返回0的函数同样满足哈希函数的定义,输入什么都是0,结果稳定,范围也固定。合法,但没法用。问题出在哪,往下看。

碰撞是躲不掉的
输入是无限的,任意字符串都可以当输入。输出却被限定在一个固定范围内。用无限的输入去映射有限的输出,不同的输入算出相同的数字,这件事迟早会发生。这种情况叫碰撞。
碰撞不可能完全消除,这是数学决定的。假设一个哈希函数只输出0到7这8个数字,现在给它9个不同的输入,根据抽屉原理,至少有两个输入会算出同一个数字。9个输入分进8个格子,无论怎么分,总有一个格子里挤了两个。
目标不是消除碰撞,而是让碰撞尽量少,让输入尽可能均匀地散落在整个输出范围里。

怎么判断一个哈希函数好不好
判断一个哈希函数好不好,最重要的一点是看它的分布是否均匀
可以把哈希结果想象成一张网格,每个格子代表一个哈希值。每来一个字符串,就给对应的格子加一点颜色。
如果哈希函数分布得好,放进去大量数据后,整张网格的颜色应该比较均匀。反过来,如果某些区域特别深,或者出现明显的条纹、成团的图案,就说明哈希结果比较集中,分布不够好。
我们用java 17写一个简单的Hash函数,来验证一下这个观点。
// 字符编码求和,再取模
int stringSum(String input) {
int sum = input.chars().sum();
return Math.floorMod(sum, 1000000);
}
上面的哈希函数要做的事情很简单:把字符串中每个字符的编码值加起来,再取模,让结果落在指定范围内。
我们拿1000个随机字符串,调用上面的函数进行哈希,挺均匀的。当如果换成有规律的数字呢? 比如:
把1到1000转成字符串,再逐个进行哈希。字符求和这个哈希函数很快就出现了明显的斜纹。因为这些数字本身有规律,相邻数字的字符编码也比较接近,最后得到的哈希值自然容易集中在一些区域。
再换成1000个常见英文单词呢? 也是会概率性的出现一些斜纹。
但是有一个哈希算法,无论是什么样子的输入,它都能保证结果尽量的均匀。那就是鼎鼎大名的MurmurHash3,Redis/ES/Kafka/Guava都在用这个。
用MurmurHash3来应付刚才提到的几种输入,输出都是很均匀的。
这其实说明了一个很重要的问题:
判断哈希函数不能只看随机数据,还要看它面对有规律的数据时,能不能把结果打散。
毕竟真实业务里的数据,很少是真正随机的。订单号、用户ID、IP地址、英文单词,甚至数据库里的自增ID,都有自己的规律。
所以,一个好的哈希函数,不是随机数据测出来分布均匀就够了。更重要的是,面对这些有规律的输入,它依然能够把结果尽可能均匀地分散开。

还有一个重要指标,叫雪崩效应。
简单说,就是输入只改一点,输出最好发生明显变化。
比如:如果输入只翻转1个比特,那么一个32位的哈希值,平均应该有一半左右的比特跟着变化。不是每次都正好16个,有时多一点,有时少一点,只要长期平均接近50%就可以。
MurmurHash3 在这方面表现不错。输入只改1个比特,输出的32个比特通常会有大约一半发生变化。
再看前面的字符求和哈希,就差得多。输入变了一点,输出也只变了一点,输入和输出之间的规律还保留得很明显。
这也就解释了前面网格里的条纹:输入有什么规律,输出就很容易把这种规律带出来。
好的哈希函数要做的,恰恰是把这种关联尽可能打散。输入只发生一点变化,输出就应该发生比较大的变化。
如果你的工程里刚好用到了Guava,那可以用Hashing.murmur3_32_fixed()或者murmur3_128,就可以直接使用这个算法了。

哈希表:哈希函数最重要的用武之地
分布要均匀,雪崩效应要好,这些要求最终是为了什么?
先看最常见的使用场景:哈希表。
哈希表存的是键值对,底层可以简单理解成一个数组。数组里的每个位置就是一个桶,一个桶里可以放多个键值对。
存数据时,先对键计算哈希值,再根据桶的数量确定它应该放在哪个桶里。取数据时,再对同一个键计算一次哈希值,就能找到对应的桶,然后在桶里比较键,找到目标数据。
所以,哈希函数干的事情就是:把一个键尽快定位到某个桶。
真正的问题在于,能不能把不同的键尽量均匀地分到各个桶里。
如果大量键都跑到了同一个桶,其他桶却没什么数据,那么查找时就得在这个桶里一个个比较,哈希表原本的优势也就没了。
这也是前面为什么一直强调分布均匀和雪崩效应。
用Java 17写一个最简版本,桶的数量定为3:
// 键值对和桶下标定位
record Entry(String key, String value) {}
List<List<Entry>> buckets = new ArrayList<>(List.of(
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>()
));
int index = Math.floorMod(murmur3(key), buckets.size());
buckets.get(index).add(new Entry(key, value));
整个哈希表里,哈希函数只在定位桶下标这一个地方出现,但这一个地方决定了全部的性能。
对比一下两种极端情况。哈希函数分布均匀,1000个键散进100个桶,每个桶平均10个键,查一个键最多比较十几次。如果用了那个永远返回0的函数,所有键挤进第一个桶,查任何一个键都可能要比较1000次。哈希表退化成了链表,这就是开头那个反例的完整后果。
哈希函数每减少一次碰撞,哈希表就少做一次无谓的键比较。

Java的HashMap做了两层处理
Java里最常用的哈希表就是HashMap。为了减少哈希碰撞,HashMap在这里做了两件事。
第一件事是扰动哈希值。
键的 hashCode() 并不会直接拿来计算桶下标,而是先做一次位运算:
// HashMap 定位桶之前的扰动
int h = key.hashCode();
h = h ^ (h >>> 16);
为什么要这么做?
因为计算桶下标时,如果数组容量不大,真正参与计算的主要是哈希值的低位。
假设有一批键,它们的低位都差不多,只是高位不同。直接计算桶下标时,这些高位的差异根本用不上,最后很可能有很多键落到同一个桶里。
h ^ (h >>> 16) 就是把高16位的信息混到低16位里,让高位的差异也能影响桶下标。这样一来,即使键的低位比较相似,也有机会被分到不同的桶里。
第二件事是链表树化。
JDK 8之后,如果一个桶里的节点达到8个,同时数组容量>=64,HashMap就会把这个桶里的链表转成红黑树。这样查找时,就不用在线性链表里一个个比较,最坏情况下可以降到对数级。
如果数组容量还不到64,即使某个桶已经比较拥挤,HashMap也不会急着树化,而是优先扩容。因为很多时候桶里的节点变多,并不是哈希函数出了问题,而是数组本身太小了。扩容以后,节点重新分布,碰撞自然会减少。
所以,HashMap的这两层设计其实很好理解:
先想办法把键分散开,真的出现严重碰撞,再用红黑树兜底。

小结
日常开发里,自己写哈希函数的机会并不多,但有两个地方需要注意。
首先,HashMap的性能不只是HashMap自己的事,键的hashCode()同样重要。
比如自定义一个键类型,如果所有实例的hashCode()都返回同一个值,那么所有键都会落到同一个桶里。HashMap本身设计得再好,也只能在这个桶里一个个找。
其次,哈希函数还分为密码学哈希和非密码学哈希。
HashMap、缓存定位这类场景,需要的是速度和均匀分布,MurmurHash3就属于这一类。文件校验、密码存储等场景,则需要更强的抗碰撞能力和单向性,常见的是SHA-256。
两类哈希解决的问题不同,不能混着用。拿MurmurHash3存密码,或者为了HashMap定位使用SHA-256,都不是合适的选择。
所以,遇到哈希相关的性能问题,可以先从两个地方查:先看**hashCode()的分布,再看HashMap**的容量和负载因子。
很多时候,问题并不在HashMap,而在键自己的hashCode()。
2509

被折叠的 条评论
为什么被折叠?



