04、String 类的 hashCode() 方法

一、String 类的 hashCode() 方法


1、源码

  • 一个好的 hash 函数应该是这样的:为不相同的对象产生不相等的 hashCode 。
/**
 * s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
 */
public int hashCode() {
    // 获取缓存的 hash 值。
    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 字段,避免重复计算。
        hash = h;
    }
    return h;
}

分析:

  • hash 值**默认**为 0 。以三个字母 “ABC” 来分析一下:
    • 第一次循环完成 h=A (因为, hash 默认为 0)。
    • 第二次循环完成 h= 31h+B= 31A+B = A*311 + B*310
    • 第三次循环完成 h= 31h+C=31(31A+B)+C = A*312 + B*311 + C*310

2、哈希码 的 计算公式:

  • String 的 hashCode() 通过多项式哈希算法,结合质数 31 的优化,高效生成唯一性较高哈希码
    • 缓存机制不可变性设计提升了性能
    • 允许出现的哈希冲突通过 equals() 方法解决

3、哈希函数 中的 乘数(Multiplier)

  • 定义
    • 哈希函数(如:多项式哈希)中,用于逐字符 迭代计算固定数值
      • 如:Java String.hashCode() 使用 31 作为乘数
  • 作用
    • 字符的贡献混合到哈希值中。
    • 避免哈希冲突(通过质数特性减少周期性模式)。

4、哈希表 的 负载因子(Load Factor)

  • 定义
    • 哈希表中已存储元素数量哈希表容量比值负载因子 = 元素数量 / 容量)。
  • 作用
    • 衡量哈希表的填充程度。
    • 触发扩容的阈值(如:Java HashMap 默认负载因子0.75)。
  • 示例
    • 哈希表 容量为 16,负载因子为 0.75。
    • 元素数量达到 12(16 * 0.75)时,哈希表扩容

二、选 31 为 hashCode() 的乘数,是以下因素的综合结果


  • 质数又称素数
  • 一个大于 1 的自然数,除了 1 和它自身外,不能被其它自然数整除叫做质数
    • 否则称为合数
  • 规定 1 既不是质数也不是合数
  • 《Effective Java》中文版,原书第 3 版的 46 页中的原话:
    • 之所以选择 31 ,是因为它是一个奇素数
      • 质数特性:可减少 哈希冲突
    • 如果乘数偶数,并且乘法溢出的话,信息就会丢失。因为,与 2 相乘等价于移位运算
    • 使用素数的好处并不很明显。但是,习惯上都使用素数来计算散列结果
      • 历史经验:实验验证其在常见场景中的有效性。
      • 平衡设计:在哈希质量计算成本之间取得最佳平衡
    • 数字 31 有个很好的特性,即:用移位减法代替 乘法,可以得到更好的 性能
      • 即:31 * i = (32-1) * i = 25 * i - i = (i << 5) - i。现在的 JVM 可以自动完成这种优化
      • 现代 CPU 的乘法指令高度优化,直接使用 31 * x位运算差异不大。
  • 尽管存在冲突的可能性,但 31 在绝大多数实际应用表现优异
    • 因此,成为 Java 字符串哈希计算经典选择

1、详解 – 数学基础:质数的优势

1)、质数的特性
  • 减少冲突
    • 质数(如:31)与其它数相乘时,结果更容易均匀分布,减少哈希碰撞的概率。
  • 避免因子的干扰
    • 若选择非质数(如:32),其因子(如:2、4、8)可能导致某些字符哈希贡献被“稀释”。

  • 因子干扰 示例:
    • 因子干扰:32 是 2 的幂(25),其因子为 2、4、8、16 等。
    • 低位信息丢失:乘以 32 相当于左移 5 位,低位被清零。

  • 这导致低 5 位始终为 0,不同字符的低位差异 被掩盖
  • 周期性模式
    • 若字符值存在偶奇规律,乘数 32 会放大偶数的贡献。
    • 导致哈希值分布偏向偶数倍数,增加冲突概率。

  • 无因子干扰 示例:
    • 无公共因子:31 是质数,没有除 1 和自身外的因子,避免周期性抵消。
    • 位混合效果:31 的二进制为11111,乘法操作混合高、低位信息,减少信息丢失:

  • 这种操作保留字符值的更多位信息
    - 均匀分布:质数的不可分性确保不同字符贡献独立叠加,哈希值 更均匀

2)、为何不是更大的质数?
  • 计算溢出
    • Java 的 int 类型为 32 位,较大的乘数(如: 101)会更快导致整数溢出,可能加剧哈希冲突
  • 性能折衷
    • 更大的质数需要更复杂的 乘法运算,而 31 的乘法可通过位运算优化。

2、详解 – 性能优化:位运算替代乘法

31 的乘法,可以进行位运算优化

  • 31 可以分解为 25 - 1,因此乘法 31 * x 可转换为:

  • 其中 << 是左移操作符。这种优化在早期处理器中比直接乘法更快。
// 传统乘法
int a = 31 * x;
// 优化为位运算
int a = (x << 5) - x; // 等效于 x * 31

3、详解 – 经验验证:哈希分布的实际效果

  • 早期研究
    • 《Effective Java》和《Java 编程思想》等经典书籍中,31 被推荐为哈希计算的合理默认值
  • 实验数据
    • 通过对常见词汇和字符串的哈希分布测试,31 在冲突率计算效率之间表现出较好的平衡

4、详解 – 设计权衡:哈希质量与计算成本

5、详解 – 替代方案的局限性

1)、更大的质数(如 101、131)
  • 优点:哈希分布更均匀
  • 缺点
    • 计算成本高(无法通过位运算优化)。
    • 整数溢出更频繁,可能反向增加冲突

2)、非质数(如 32、64)
  • 优点:位运算优化更直接(如: 32 = 25 )。
  • 缺点
    • 哈希分布不均匀(因子导致信息丢失)。
    • 对特定字符模式敏感(如:全偶数 ASCII 码)。

三、计算哈希算法的冲突率 的 试验 及 数据分析


1、实验数据 以及 实验代码

  • 数据是 Unix/Linux 平台中的**英文字典文件**,文件中有 235886 个单词。
    • 文件路径为 /usr/share/dict/words。
  • 将使用不同的数字作为乘子,对超过23万个英文单词进行哈希运算,并计算哈希算法的冲突率
/**
 *
 * @author zhangxw
 * @since 1.0.0
 */
public class StringHashCodeTest {
    public static int hashCode(char[] value, int prim) {
        int h = 0;
        for (int i = 0; i < value.length; i++) {
            h = prim * h + value[i];
        }
        return h;
    }

    /**
     * 计算 hash code 冲突率,顺便分析一下 hash code 最大值和最小值,并输出
     */
    public static void calculateConflictRate(Integer multiplier, List<Integer> hashes) {
        Comparator<Integer> cp = (x, y) -> x > y ? 1 : (x < y ? -1 : 0);
        int maxHash = hashes.stream().max(cp).get();
        int minHash = hashes.stream().min(cp).get();

        // 计算冲突数及冲突率
        int uniqueHashNum = (int) hashes.stream().distinct().count();
        int conflictNum = hashes.size() - uniqueHashNum;
        double conflictRate = (conflictNum * 1.0) / hashes.size();

        System.out.println(String.format("multiplier=%4d, minHash=%11d, maxHash=%10d, conflictNum=%6d, conflictRate=%.4f%%",
                multiplier, minHash, maxHash, conflictNum, conflictRate * 100));
    }


    /**
     * 将整个哈希空间等分成64份,统计每个空间内的哈希值数量
     *
     * @param hashs
     */
    public static Map<Integer, Integer> partition(List<Integer> hashs) {
        // step = 2^32 / 64 = 2^26
        final int step = 67108864;
        List<Integer> nums = new ArrayList<>();
        Map<Integer, Integer> statistics = new LinkedHashMap<>();
        int start = 0;
        for (long i = Integer.MIN_VALUE; i <= Integer.MAX_VALUE; i += step) {
            final long min = i;
            final long max = min + step;
            int num = (int) hashs.parallelStream()
                    .filter(x -> x >= min && x < max).count();

            statistics.put(start++, num);
            nums.add(num);
        }

        // 为了防止计算出错,这里验证一下
        int hashNum = nums.stream().reduce((x, y) -> x + y).get();
        assert hashNum == hashs.size();

        return statistics;
    }
    

    public static void main(String[] args) throws IOException {
        List<String> list = new ArrayList<>(235886);

        URL url = StringHashCodeTest.class.getClassLoader().getResource("words.txt");
        Reader reader = new FileReader(url.getFile());

        BufferedReader bufferedReader = new BufferedReader(reader);
        String string;
        while ((string = bufferedReader.readLine()) != null) {
            list.add(string);
        }
        bufferedReader.close();
        reader.close();

        args = new String[]{"2","3","5","7","17","31","32","33","36","37","41","47","101","199"};

        for (String arg : args) {
            int prim = Integer.parseInt(arg);
            List<Integer> result = new ArrayList<>();
            for (String s : list) {
                result.add(hashCode(s.toCharArray(), prim));
            }
            calculateConflictRate(prim, result);
            System.out.println();
            // 输出分布区域
            Map<Integer, Integer> map = partition(result);
            for (Integer key : map.keySet()){
                System.out.println(/*key + "\t" + */map.get(key));
            }

            System.out.println();
        }
    }
}

结果:

结论分析:

  • 使用较小的质数做为乘子时,冲突率会很高。尤其是质数 2,冲突率达到了 55.14%。
  • 使用 31、33、37、39、41、101、199 做为乘子时,算出的哈希值冲突数都小于 7 个

2、哈希值分布可视化

  • int 类型 的数值区间是 [-2147483648, 2147483647],区间大小为 232
    • 所以,将整个 哈希空间 等分成 64 份,每个自子区间大小为 226
  • 统计 每个空间 内的哈希值数量,详细的分区对照表如下:
分区编号分区下限分区上限分区编号分区下限分区上限
0-2147483648-208037478432067108864
1-2080374784-20132659203367108864134217728
2-2013265920-194615705634134217728201326592
3-1946157056-187904819235201326592268435456
4-1879048192-181193932836268435456335544320
5-1811939328-174483046437335544320402653184
6-1744830464-167772160038402653184469762048
7-1677721600-161061273639469762048536870912
8-1610612736-154350387240536870912603979776
9-1543503872-147639500841603979776671088640
10-1476395008-140928614442671088640738197504
11-1409286144-134217728043738197504805306368
12-1342177280-127506841644805306368872415232
13-1275068416-120795955245872415232939524096
14-1207959552-1140850688469395240961006632960
15-1140850688-10737418244710066329601073741824
16-1073741824-10066329604810737418241140850688
17-1006632960-9395240964911408506881207959552
18-939524096-8724152325012079595521275068416
19-872415232-8053063685112750684161342177280
20-805306368-7381975045213421772801409286144
21-738197504-6710886405314092861441476395008
22-671088640-6039797765414763950081543503872
23-603979776-5368709125515435038721610612736
24-536870912-4697620485616106127361677721600
25-469762048-4026531845716777216001744830464
26-402653184-3355443205817448304641811939328
27-335544320-2684354565918119393281879048192
28-268435456-2013265926018790481921946157056
29-201326592-1342177286119461570562013265920
30-134217728-671088646220132659202080374784
31-6710886406320803747842147483648
  • 对数字 2、3、17、31、101 的散点曲线图进行简单的分析。先从数字 2 开始,数字 2 对于的散点曲线图如下:

  • 上面的图还是很一幕了然的,乘子 2 算出的哈希值几乎全部落在第32分区,也就是 [0, 67108864)数值区间内,落在其他区间内的哈希值数量几乎可以忽略不计。
  • 这也就不难解释为什么数字2作为乘子时,算出哈希值的冲突率如此之高的原因了

  • 3 作为乘子时,算出的哈希值分布情况和 2 很像,只不过稍微好了那么一点点。从图中可以看出绝大部分的哈希值最终都落在了第32分区里,哈希值的分布性很差

  • 数字 17 作为乘子时的表现,明显比上面两个数字好点了。
  • 虽然哈希值在第32分区和第34分区有一定的聚集,但是相比较上面2和3,情况明显好好了很多。除此之外,17作为乘子算出的哈希值在其他区也均有分布,且较为均匀,还算是一个不错的乘子吧。

  • 31 作为乘子算出的哈希值在第 33 分区有一定的小聚集。不过相比于数字 17,数字 31 的表现又好了一些。
  • 首先,是哈希值的聚集程度没有 17 那么严重,其次哈希值在其他区分布的情况也要好于 17。
  • 总之,选 31,准没错啊。

  • 再看看大质数 101 的表现,不难看出,质数 101 作为乘子时,算出的哈希值分布情况要好于主角 31,有点喧宾夺主的意思。不过不可否认的是,质数 101 的作为乘子时,哈希值的分布性确实更加均匀
  • 所以,如果不在意质数 101 容易导致数据信息丢失问题,或许其是一个更好的选择。

  • 乘数是 199 是不能用的散列结果,但是它的数据是更加分散的,从图上能看到有两个小山包
  • 由于 hashcode 会超出 int 的表示范围,会导致一部分位数据丢失问题,所以,不能选择。

String中不同乘数下hashcode的分布图.xlsx

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值