HashCode 为什么使用 31 作为乘数?

参考文章: 小傅哥,公众号:bugstack 虫洞栈 《java面经手册》

先来了解一下什么是hashCode?

hashCode在Java中所代表的是一种方法,是获得hash值的一种方法。

hash 翻译做“散列”,也可直译为“哈希”,就是把任意长度的值输入,通过hash()函数输出固定长度的消息摘要。 hash函数也有很多种,包括:直接取余法,乘法取整法,平方取中法,暂时先了解有这些方法即可。

hash表是啥? hash表是由hash值组成的。

HashCode的存在主要是为了查找的快捷性,HashCode是用来在散列存储结构中确定对象的存储地址的(用HashCode来代表对象就是在hash表中的位置)。

阅读源码

关于获得haashCode的函数非常之多,我们我截取java.util包中的Arrays类给大家瞅一瞅:

public static int hashCode(long a[]) {if (a == null)return 0;int result = 1;for (long element : a) {int elementHash = (int)(element ^ (element >>> 32));result = 31 * result + elementHash;}return result;
}

在获取 hashCode 的源码中可以看到,有一个固定值 31,在 for 循环每次执行时 进行乘积计算,循环后的公式如下; s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

那么这里为什么选择 31 作为乘积值呢?

收集到的资料主要有两个观点:

第一种观点:

  1. 31 是一个奇质数,如果选择偶数会导致乘积运算时数据溢出。
  2. 另外在二进制中,2 个 5 次方是 32,那么也就是 31 * i == (i << 5) - i。这主要是说乘积运算可以使用位移提升性能,同时目前的 JVM 虚拟机 也会自动支持此类的优化。

第二种观点:

经过大量测试,如果用超过 5 千个单词计算 hashCode, 这个 hashCode 的运算使用 31、33、37、39 和 41 作为乘积,得到的碰撞结果,31 被使用就很正常了。

虫洞栈做了一下实验: 统计出不同的乘积数对 10 万个单词的 hash 计算结果。

Hash 计算函数

public static Integer hashCode(String str, Integer multiplier) {int hash = 0;for (int i = 0; i < str.length(); i++) {hash = multiplier * hash + str.charAt(i);}return hash;
}

这个过程比较简单,与原 hash 函数对比只是替换了可变参数,用于我们统计不同乘积数的计算结果。

Hash 碰撞概率计算 :

想计算碰撞很简单,也就是计算那些出现相同哈希值的数量,计算出碰撞总量即可。这里的实现方式有很多,可以使用 set、map 也可以使用 java8 的 stream 流 统计distinct。

private static RateInfo hashCollisionRate(Integer multiplier, List hashCodeList) {int maxHash = hashCodeList.stream().max(Integer::compareTo).get();int minHash = hashCodeList.stream().min(Integer::compareTo).get();int collisionCount = (int) (hashCodeList.size() - hashCodeList.stream().distinct().count());double collisionRate = (collisionCount * 1.0) / hashCodeList.size();return new RateInfo(maxHash, minHash, multiplier, collisionCount, collisionRate);
}

这里记录了最大 hash 和最小 hash 值,以及最终返回碰撞数量的统计结果。

单元测试 :

@Before
public void before() {"abc".hashCode();// 读取文件,103976 个英语单词库.txtwords = FileUtil.readWordList("E:/itstack/git/github.com/interview/interview01/103976 个英语单词库.txt");
}@Test
public void test_collisionRate() {List rateInfoList = HashCode.collisionRateList(words, 2, 3, 5, 7, 17,31, 32, 33, 39, 41, 199);for (RateInfo rate : rateInfoList) {System.out.println(String.format("乘数 = %4d, 最小 Hash = %11d, 最大Hash = %10d, 碰撞数量 =%6d, 碰撞概率 = %.4f%%", rate.getMultiplier(), rate.getMinHash(), rate.getMaxHash(), rate.getCollisionCount(), rate.getCollisionRate() * 100));}
}
  • 以上先设定读取英文单词表中的 10 个单词,之后做 hash 计算。
  • 在 hash 计算中把单词表传递进去,同时还有乘积数;2, 3, 5, 7, 17, 31, 32, 33, 39, 41, 199,最终返回一个 list 结果并输出。
  • 这里主要验证同一批单词,对于不同乘积数会有怎么样的 hash 碰撞结果。

最终得到结果:

  1. 以上就是不同的乘数下的 hash 碰撞结果图标展示,从这里可以看出如下信息;
  2. 乘数是 2 时,hash 的取值范围比较小,基本是堆积到一个范围内了,后 面内容会看到这块的展示。
  3. 乘数是 3、5、7、17 等,都有较大的碰撞概率
  4. 乘数是 31 的时候,碰撞的概率已经很小了,基本稳定。
  5. 顺着往下看,你会发现 199 的碰撞概率更小,这就相当于一排奇数的茅坑量多,自然会减少碰撞。但这个范围值已经远超过 int 的取值范围 了,如果用此数作为乘数,又返回 int 值,就会丢失数据信息。

Hash 值散列分布

除了以上看到哈希值在不同乘数的一个碰撞概率后,关于散列表也就是 hash, 还有一个非常重要的点,那就是要尽可能的让数据散列分布。只有这样才能减少 hash 碰撞次数 。

那么怎么看散列分布呢?如果我们能把 10 万个 hash 值铺到图表上,形成的一张 图,就可以看出整个散列分布。但是这样的图会比较大,当我们缩小看后,就成 一个了大黑点。所以这里我们采取分段统计,把 2 ^ 32 方分 64 个格子进行存 放,每个格子都会有对应的数量的 hash 值,最终把这些数据展示在图表上。

哈希值分段存放

public static Map hashArea(List hashCodeList) {Map statistics = new LinkedHashMap<>();int start = 0;for (long i = 0x80000000; i <= 0x7fffffff; i += 67108864) {long min = i;long max = min + 67108864;// 筛选出每个格子里的哈希值数量,java8 流统计;https://bugstack.cn/itstack-demoany/2019/12/10/%E6%9C%89%E7%82%B9%E5%B9%B2%E8%B4%A7-Jdk1.8%E6%96%B0%E7%89%B9%E6%80%A7%E5%AE%9E%E6%88%98%E7%AF%87(41%E4%B8%AA%E6%A1%88%E4%BE%8B).htmlint num = (int) hashCodeList.parallelStream().filter(x -> x >= min && x < max).count();statistics.put(start++, num);
}return statistics;
  • 这个过程主要统计 int 取值范围内,每个哈希值存放到不同格子里的数 量。
  • 这里也是使用了 java8 的新特性语法,统计起来还是比较方便的。

单元测试

@Test
public void test_hashArea() {System.out.println(HashCode.hashArea(words, 2).values());System.out.println(HashCode.hashArea(words, 7).values());System.out.println(HashCode.hashArea(words, 31).values());System.out.println(HashCode.hashArea(words, 32).values());System.out.println(HashCode.hashArea(words, 199).values());
}
  • 这里列出我们要统计的乘数值,每一个乘数下都会有对应的哈希值数量汇总,也就是 64 个格子里的数量。
  • 最终把这些统计值放入到 excel 中进行图表化展示。

统计图表

  • 以上是一个堆积百分比统计图,可以看到下方是不同乘数下的,每个格子里的数据统计。
  • 除了 199 不能用以外,31 的散列结果相对来说比较均匀。

乘数是 2 的时候,散列的结果基本都堆积在中间,没有很好的散列。

乘数是 31 的时候,散列的效果就非常明显了,基本在每个范围都有数据存放。

乘数是 199 是不能用的散列结果,但是它的数据是更加分散的,从图上能看到有两个小山包。但因为数据区间问题会有数据丢失问题,所以不能选择。

总结

  • 以上主要介绍了 hashCode 选择 31 作为乘数的主要原因和实验数据验 证,算是一个散列的数据结构的案例讲解,在后续的类似技术中,就可以解释其他的源码设计思路了。
  • 看过本文至少应该让你可以从根本上解释了 hashCode 的设计,关于他的 所有问题也就不需要死记硬背了,学习编程内容除了最开始的模仿到深 入以后就需要不断的研究数学逻辑和数据结构。
  • 文中参考了优秀的 hashCode 资料和 stackoverflow,并亲自做实验验证结果,大家也可以下载本文中资源内容;英文字典、源码、excel 图表 等内容


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部