常见类型的hashCode都是如何计算的啊?

常见类型的hashCode都是如何计算的啊?

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第13天,点击查看活动详情

哈希函数

写在前面

  • 建议阅读时间:5 ~ 10 分钟
  • 本文提到的哈希表,推荐阅读:《哈希表是什么?》,里面介绍了哈希表以及哈希冲突相关的问题

文章摘要

  1. 哈希函数相关问题
  2. 常见类型的哈希函数

关于哈希函数

  • 哈希表为什么叫做哈希表呢?因为中间有一个哈希函数hash(key),需要利用这个哈希函数计算key的索引

  • 如果不同的key利用哈希函数计算出了相同的索引,叫做哈希冲突

  • 若哈希函数设计不好,会使哈希冲突的次数变多,我们来谈谈一个良好的哈希函数长什么样

    • 哈希值更加均匀分布,尽量符合泊松分布
    • 也就是要尽量减少哈希冲突,也就能提升哈希表的性能
  • 哈希表中哈希函数的实现步骤大概如下:

    • 1、先生成key的哈希值(必须是整数)
    • 2、再让哈希值跟数组的大小进行相关运算,生成一个索引值
  • 用代码实现就是:

public int hash(Object key) {
    int hashCode = hash_code(key);  // 1、计算出key的哈希值
    return hashCode % table.length; // 2、哈希值与数组长度进行相关运算 (取模)
}
复制代码
  • 上面的代码不难理解,就是利用key的哈希值取模于数组的长度,就能算出key在数组中的索引
  • 而取模运算比较慢,为了提高效率,可以使用 位运算 & 取代取模运算 %,但是有一个前提是数组的长度必须为2的幂次方 2^n
  • 用代码实现一下再来分析:
public int hash(Object key) {
    int hashCode = hash_code(key);        // 1、计算出key的哈希值
    return hashCode & (table.length - 1); // 2、哈希值与(数组长度 - 1)进行相关运算(位运算)
}
复制代码
  • 看完了上面的代码,我们可以得出的结论是:在数组长度是2的幂次方时,index = hashCode % 数组长度 = hashCode & (数组长度 - 1)
  • 而计算机处理位运算的效率肯定比取模运算快,为什么可以这样改进呢?先来复习一下位运算:

image-20221220205637975

  • 应该不难,两个二进制数,进行位运算,同真才为真,其余为假
  • 这样优化有一个前提是:数组长度必须是2的幂次方,我们来观察一下2^n的规律:

image-20221220210511330

  • 上图左边是2的幂次方以及它对应的二进制,右边是2的幂次方 - 1以及它的二进制
  • 可以发现,只要是2的幂次方减去1后,它对应的二进制全部是1(【2^n - 1】,它的二进制就会有n个1)
  • 那将位运算与此规律结合一下:如果一个数与二进制全为1的数进行位运算,会有什么规律呢?

image-20221220211510373

  • 从图中可以发现,它们有这样的规律:

    • 0 ≤ 结果 ≤ 二进制全为1的数
    • 也就是0 ≤ 结果 ≤ 2的幂次方 - 1
    • 也就是0 ≤ index ≤ 数组长度 - 1
  • 最小小不过0,最大大不过那个二进制全为1的数。这让我想到看到过的一个很形象的说法:”妈妈 & 爸爸 = 儿子。儿子像妈妈,却没有爸爸高“

  • 正是因为这个规律,你才能看到这样的代码:

image-20221220212721276

  • 了解完这个基本知识,也优化了取模运算,求出了数组索引。可是我们还不知道该如何生成key的哈希值勒
  • 最终计算索引的方法是一样的,那哈希函数好不好,就只由key的哈希值来决定了

key的哈希值

  • 要生成key的哈希值,我们得先了解到:
    • 什么类型都可以做HashMap的key,但是常见的种类有:整数、浮点数、字符串、自定义对象
    • 不同种类的key,生成哈希值的方式不一样,但是他们的目标是一致的:减少哈希冲突次数
    • 既然都希望在之后能减少哈希冲突,提升性能,那我们生成哈希值的时候,定下两个约定
      • 哈希值一样,再使用一样的算法生成index,肯定会产生哈希冲突嘛,所以我们的约定一:要尽量让每个key的哈希值是唯一的
      • key的信息不完整,计算出相同的值的概率更高,(比如说key1 = “yyds” key2 = "yybc",如果key1 和 key2 都只取前半部分信息来运算,那么它们都是用 “yy” 来运算,算出的结果肯定也就相同了),所以我们的约定二:要尽量让key的所有信息参与运算
  • 好了,当你了解了这些,我们分别来看看该如何生成几种常见类型key的哈希值

(1)整数

  • 要求整数的哈希值很简单:可以直接将整数值当做哈希值即可(如hashCode(100) = 100)
  • 为什么可以呢?来看看,有没有达成我们的约定?
    • 每一个整数都是唯一的
    • 也将整数的所有信息参与了运算
  • 好像确实有做到哎,那么用代码实现:
public static int hashCode(int value) {
    return value; // 直接将整数值当做哈希值即可
}
复制代码

(2)浮点数

  • 浮点数比较特殊,既然最终想要一个整数,我们想方设法让其浮点数变成整数即可
  • 浮点数也是通过二进制存储在计算机中的,将其二进制转换为十进制的整数,再将其整数值当做哈希值就可以了,如:

image-20221227155758161

  • 每个浮点数的二进制都是唯一的,转换出来的整数也是唯一的
  • 也将浮点数的所有信息都运用到了
  • 用代码实现的话,JDK已经提供了API可直接使用
public static int hashCode(float value) {
    return Float.floatToIntBits(value);
}
复制代码

(3)Long和Double的哈希值

  • 看到这个标题,你可能会有些疑惑:Long不也是整数吗?Double不也是浮点数吗?上面都说了如何计算了,还写一遍干嘛呢?
  • 是的,其实思路是一样的,只不过呢?在Java平台哈希值的返回值类型必须是int,也就是占4个字节,32位大小的整数(因为数组的索引是int)
  • Long和Double都是占8个字节,64位的内存大小,我们如果直接按上面的操作,会不满足我们的两个约定。所以需要这样处理:
    public static int hashCode(long value) {
        return (int) (value ^ (value >>> 32));
    }

    public static int hashCode(double value) {
        long l = Double.doubleToLongBits(value);
        return (int) (l ^ (l >>> 32));
    }
复制代码
  • 为什么需要这样处理呢?我们来探讨一下
  • 它们的处理方式其实和上面是一样的,只是运用一定的算法,将大整数转换为小整数,双精度转为小整数罢了
  • 而这其中的计算方法,很值得来探讨一下,先用一个数字来举个例子,顺便复习下计算机的基础知识:

image-20221227173216405

  • 可以看到,为了达成约定,我们通过将此数字无符号右移32位后,得到了高32位,再与原先的值进行异或运算
  • 如果将最后计算所得的数值略去32位,进行窄化操作,那么得到的数值就是0101 1001 1000 0111 0010 0110 0101 1111,将此数值转换为整数,用此作为哈希值即可
  • 因为此数值是通过原来大整数的高32位异或于大整数的低32位得到的,满足了尽量使用所有信息
  • 又因为进行的是异或运算,不论大整数的高低32位值特不特殊,都最大程度的保证了不唯一,也保证了不会只利用高32bit或只用低32bit

(4)字符串的哈希值

  • 大家都知道,字符的本质就是一个数字,我们可不可以就用字符串中的每个字符对应的整数作为哈希值呢?
  • 其实是可以的,但是不是很好,因为很容易计算出相同的数值,也就是不满足我们的约定一
  • 既然要算一个整数,我们看看整数是如何构成的。如1314是如何构成的
    • 1314 = 1 * 10^3 + 3 * 10^2 + 1 * 10^1 + 4 * 10^0
  • 如果利用同样的方式来对字符串就行运算,是不是也可以得到一个整数呢?如计算“ciusyan”
    • ciusyan = c * n^6 + i * n^5 + u * n^4 + s * n^3 + y * n^2 + a * n^1 + n * n^0
  • 如上所示,通过整数的构造方式,我们将字符串也构造了出来
  • 当然用于取幂的数值用什么,这个可以随意定,JDK中是 n = 31
    • 因为31是一个奇素数,素数乘以其它数值,更容易产生唯一性。而且可以将31 * i优化成(i << 5) - 1,而且Java的虚拟机碰到31*i,会自行优化
    • 这个关系这么神奇?怎么推出来的啊:31 * i = (2^5 - 1) * i = i * 2^5 - i = (i << 5) - i
  • 那为什么字符串计算哈希值可以用此优化呢?因为上面的字符串式子可以化简成:
    • ciusyan = (((((c * 31 + i) * 31 + u) * 31 + s) * 31 + y) * 31 + a) * 31 + n
    • 也就是一直用 31 * i,所以可以这样转换,用代码实现一下就是:
    public static int hashCode(String s) {
        if (s == null) return 0;
        int hash = 0;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            hash = hash * 31 + c; // <==> hash = (hash << 5) - hash + c
        }
        return hash;
    }
复制代码

(5)小结

  • 至此,我们已经知道如何计算整数、浮点数、字符串的哈希值了
  • 只不过其实不需要我们自己写代码了,JDK已经提供了对应包装类的hashCode()方法,不需要我们再次实现了
  • 那我们为什么还要说呢?当然是为了了解常见类型key的哈希值都是如何算出来的呐~

image-20221227220931122

  • 我们再来简单看看,如果是一个自定义对象,比较推荐的哈希值的计算方法

(6)自定义对象的哈希值

  • 有如下一个自定义对象,该如何计算它的哈希值呢?
public class Person {
    private String name;
    private int age;
    private float height;
}
复制代码
  • 别管如何计算,先来思考一个问题:下图 打印出 p1 和 p2 的哈希值相同吗?

image-20221227221618889

  • 哎,先不说这个问题,我也没看到你在Person中写了hashCode方法啊,它从哪来的勒?
    • 此方法是在Object里的,Java的所有类最终都继承自Object,那么就算是自定义对象不实现这两个方法,也会有一个默认的实现了。而默认的实现,有好几种实现方式,也可能和内存地址有关
  • 当你知道了这些,你应该可以知道上面问题的答案了:其实是不相等的,所以如果将这两个对象放入哈希表,大概率会被放入两个桶中,当然也可能放在一个桶中(因为不同的哈希值也可能计算出相同的索引嘛)
  • 可是在大多数场景中,如果想要用此作为HashMap的key,我们会认为这是一个对象,计算出来的哈希值应该要相同,那我们怎么来实现呢?
@Override
public int hashCode() {
    int hash = Integer.hashCode(age);
    hash = hash * 31 + Float.hashCode(height);
    hash = hash * 31 + (name == null ? 0 : name.hashCode());
    return hash;
}
复制代码
  • 可以看到,我们只需要重写hashCode方法即可,而里面的实现思路,你看看和上面字符串的思路是不是有些类似
  • 我们既用到了Person对象的所有信息,也满足了使其值尽量唯一
  • 实现完成后,如果现在再来看上面的问题,答案就会是相同了,Person对象的姓名、年龄、身高相同,那么算出来的哈希值就相同,如果放在哈希表中,就只会放在一个桶中了