深度理解哈希hash

一个永远返回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()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SamDeepThinking

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值