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

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

转自《Java 面经手册》PDF,全书5章29节,417页11.5万字,完稿&发版

1、HashCode 源码

// 例如:获取'abc'.hashCode();
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

源码上附有 hashCode 公式:
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

举例解释公式算法:

"abc".hashCode();
计算hashCode要将字段转换成ASCII码值 a = 97, b = 98, c = 99 
hash = 97 * 31 ^ (3 - 1) + 98 * 31 * (3 - 2) + 99 = 96354

那么问题来了,为甚么源码中要将这个乘数固定为 31 呢?

2、来自 stackoverflow 的回答

stackoverflow 关于为什么选择 31 作为固定乘积值,有一篇讨论文章,Why does Java’s hashCode() in String use 31 as a multiplier? 这是一个时间比较久的问题了,摘取两个回答点赞最多的;

413 个赞👍的回答

最多的这个回答是来自《Effective Java》的内容;

The value 31 was chosen because it is an odd prime. If it were even and the multipl
ication overflowed, information would be lost, as multiplication by 2 is equivalent
to shifting. The advantage of using a prime is less clear, butit is traditional. 
A nice property of 31 is that the multiplication can be replaced by a shift and a 
subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort 
of optimization automatically.

这段内容主要阐述的观点包括:

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

80 个赞👍的回答

As Goodrich and Tamassia point out, If you take over 50,000 English words (formed 
as the union of the word lists provided in two variants of Unix), using the constants 
31, 33, 37, 39, and 41 will produce less than 7 collisions in each case. Knowing 
this, it should come as no surprise that many Java implementations choose one of 
these constants. 
  1. 这个回答就很有实战意义了,告诉你用超过 5 千个单词计算 hashCode, 这个 hashCode 的运算使用 31、33、37、39 和 41 作为乘积,得到的碰撞结果,31 被使用就很正常了。
  2. 以这种说法用不同的乘数测试碰撞概率。

3、Hash 碰撞概率计算

接下来要做的事情就是根据 stackoverflow 的回答,统计出不同的乘积数对 10 万个单词的 hash 计算结果。

3.1 读取单词字典表
/**
 * 读取本地文件,单词表
 * @param url 单词表.txt文件
 * @return 单词集合(去重)
 */
public static Set<String> readWordList(String url) {
    Set<String> list = new HashSet<>();
    try {
        InputStreamReader isr = new InputStreamReader(new FileInputStream(url), StandardCharsets.UTF_8);
        BufferedReader br = new BufferedReader(isr);
        String line = "";
        while ((line = br.readLine()) != null) {
            String[] ss = line.split("\t");
            list.add(ss[1]);
        }
        br.close();
        isr.close();
    } catch (Exception ignore) {
        return null;
    }
    return list;
}
3.2 Hash 计算函数
/**
 * 根据不同乘数计算hashCode
 * @param str 字符串
 * @param multiplier 乘数
 * @return 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;
}

这个过程与原 HashCode 函数相比,只是使用可变参数,用于统计不同的乘数计算的 hash 值。

3.3 Hash 碰撞概率计算

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

/**
 * 计算Hash碰撞概率
 */
private static RateInfo hashCollisionRate(Integer multiplier, List<Integer> 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);
}

public static List<RateInfo> collisionRateList(Set<String> strList, Integer... multipliers) {
    List<RateInfo> rateInfoList = new ArrayList<>();
    for (Integer multiplier : multipliers) {
        List<Integer> hashCodeList = new ArrayList<>();
        for (String str : strList) {
            Integer hashCode = hashCode(str, multiplier);
            hashCodeList.add(hashCode);
        }
        rateInfoList.add(hashCollisionRate(multiplier, hashCodeList));
    }
    return rateInfoList;
}

这里记录了 hash 最大值、hash 最小值、碰撞数、碰撞率

3.4 单元测试
private Set<String> words;

    @Before
    public void before() {
        System.out.println("abc".hashCode());
        // 读取文件,103976个英语单词库.txt
        words = FileUtil.readWordList("E:\\workspace\\interview-master\\interview-master\\interview-03\\103976个英语单词库.txt");
    }

    @Test
    public void test_collisionRate() {
        System.out.println("单词数量:" + words.size());
        List<RateInfo> 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));
        }
    }
  1. 先读取英文单词表中的 10万 个单词,之后做 hash 计算。
  2. 在 hash 计算中把单词表传递进去,同时还有乘积数;2, 3, 5, 7, 17, 31, 32, 33, 39, 41, 199,最终返回一个 list 结果并输出。
  3. 这里主要验证同一批单词,对于不同乘积数会有怎么样的 hash 碰撞结 果。

测试结果:

单词数量:103976
乘数 =    2, 最小Hash =          97, 最大Hash = 1842581979, 碰撞数量 = 60382, 碰撞概率 = 58.0730%
乘数 =    3, 最小Hash = -2147308825, 最大Hash = 2146995420, 碰撞数量 = 24300, 碰撞概率 = 23.3708%
乘数 =    5, 最小Hash = -2147091606, 最大Hash = 2147227581, 碰撞数量 =  7994, 碰撞概率 = 7.6883%
乘数 =    7, 最小Hash = -2147431389, 最大Hash = 2147226363, 碰撞数量 =  3826, 碰撞概率 = 3.6797%
乘数 =   17, 最小Hash = -2147238638, 最大Hash = 2147101452, 碰撞数量 =   576, 碰撞概率 = 0.5540%
乘数 =   31, 最小Hash = -2147461248, 最大Hash = 2147444544, 碰撞数量 =     2, 碰撞概率 = 0.0019%
乘数 =   32, 最小Hash = -2007883634, 最大Hash = 2074238226, 碰撞数量 = 34947, 碰撞概率 = 33.6106%
乘数 =   33, 最小Hash = -2147469046, 最大Hash = 2147378587, 碰撞数量 =     1, 碰撞概率 = 0.0010%
乘数 =   39, 最小Hash = -2147463635, 最大Hash = 2147443239, 碰撞数量 =     0, 碰撞概率 = 0.0000%
乘数 =   41, 最小Hash = -2147423916, 最大Hash = 2147441721, 碰撞数量 =     1, 碰撞概率 = 0.0010%
乘数 =  199, 最小Hash = -2147459902, 最大Hash = 2147480320, 碰撞数量 =     0, 碰撞概率 = 0.0000%

碰撞概率统计图

以上就是不同的乘数下的 hash 碰撞结果图标展示,从这里可以看出如下信息;

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

4、Hash 值散列分布

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

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

4.1 哈希值分段存放
public static Map<Integer, Integer> hashArea(List<Integer> hashCodeList) {
    Map<Integer, Integer> statistics = new LinkedHashMap<>();
    int start = 0;
    for (long i = 0x80000000; i <= 0x7fffffff; i += 67108864) {
        long min = i;
        long max = min + 67108864;
        // 筛选出每个格子里的哈希值数量,java8流统计
        int num = (int) hashCodeList.parallelStream().filter(x -> x >= min && x < max).count();
        statistics.put(start++, num);
    }
    return statistics;
}

public static Map<Integer, Integer> hashArea(Set<String> strList, Integer multiplier) {
    List<Integer> hashCodeList = new ArrayList<>();
    for (String str : strList) {
        Integer hashCode = hashCode(str, multiplier);
        hashCodeList.add(hashCode);
    }
    return hashArea(hashCodeList);
}
  1. 这个过程是为了统计 int 取值范围内,每个 Hash 值存放到不同格子的数量。
4.2 单元测试
@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());
}

统计报表

4.2.1 乘数 2 的散列

乘数为 2 时 散列结果

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

乘数为 7 时 散列结果

  • 乘数是 7 的时候,散列的结果虽然有分散开,但是不是很好的散列
4.2.2 乘数 31 的散列

乘数为 31 时 散列结果

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

乘数为 199 时 散列结果

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

5、总结

  • 以上主要介绍了 hashCode 选择 31 作为乘数的主要原因和实验数据验证,算是一个散列的数据结构的案例讲解
  • 选择使用 31 作为乘数是基于大量数据验证的,可以保证数据的 hash 值尽可能散列,降低碰撞概率,尽可能减少 Hash 冲突。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: HashCode是一个Java方法,用于返回对象的哈希码。它通常用于确定一个对象的唯一标识符,以便在需要比较对象时快速查找和识别该对象。在使用集合框架时,哈希码在查找元素时非常有用,可以大大提高查找效率。 ### 回答2: hashcodeJavaObject类的一个方法,用于计算对象的哈希码。 哈希码是一个用于标识对象的整数。它是根据对象的内部状态(即对象的属性)计算得出的。同样的输入会得到相同的哈希码,不同的输入会得到不同的哈希码。 hashcode方法的作用主要有两点: 1. 在集合框架,如HashMap、HashSet等类,使用哈希码来确定对象的存储位置。哈希码作为一个索引,能够快速地定位对象所在的存储位置,提高了集合类的存取效率。 2. 在对象比较时,equals方法通常与hashcode方法一起使用。equals被用来判断两个对象是否相等,而hashcode则被用来判断两个对象是否“可能相等”。在重写equals方法时,一般也需要同时重写hashcode方法,以保证对象相等时它们的哈希码也相等。这样可以确保对象作为Map的键或者放入HashSet等集合类时能够正确地进行查找和去重等操作。 需要注意的是,哈希码并不是唯一的,即不同的对象可能会生成相同的哈希码。因此,在编写代码时不能依赖于哈希码的唯一性,而应当保证equals方法的正确性,以确保判断对象是否相等时的准确性。 ### 回答3: hashcode是对象在内存的唯一标识符,它是通过哈希函数计算出来的一个整数。在Javahashcode是由Object类的hashCode()方法生成的。 hashcode的作用有两个方面: 1. 在哈希表用作对象的存储索引。哈希表是一种常用的数据结构,它可以快速存储和查询数据。哈希函数将对象映射到一个特定的索引位置,不同的对象会得到不同的hashcode,从而实现快速的存储和查找。 2. 在集合类用来快速定位元素。集合类如HashMap、HashSet等都是基于哈希表实现的,它们通过对象的hashcode来确定元素的存储位置。当需要查找或删除元素时,只需要计算待查找对象的hashcode,就可以快速定位到对应的存储位置,从而提高了操作的效率。 需要注意的是,hashcode并不是唯一的,不同的对象可能会得到相同的hashcode,这就是所谓的哈希冲突。为了解决冲突,Java提供了equals()方法来判断两个对象是否相等。当两个对象的hashcode相等且equals方法返回true时,这两个对象被认为是相等的。因此,对于自定义的类,我们需要重写hashCode()和equals()方法,保证其正确性和一致性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值