从 hash碰撞说到重写hashCode

4,295 阅读5分钟

哈希碰撞是什么?

所谓哈希(hash),就是将不同的输入映射成独一无二的、固定长度的值(又称"哈希值")。它是最常见的软件运算之一。

如果不同的输入得到了同一个哈希值,就发生了"哈希碰撞"(collision)。

举例来说,很多网络服务会使用哈希函数,产生一个 token,标识用户的身份和权限。

AFGG2piXh0ht6dmXUxqv4nA1PU120r0yMAQhuc13i8

上面这个字符串就是一个哈希值。如果两个不同的用户,得到了同样的 token,就发生了哈希碰撞。服务器将把这两个用户视为同一个人,这意味着,用户 B 可以读取和更改用户 A 的信息,这无疑带来了很大的安全隐患。

黑客攻击的一种方法,就是设法制造"哈希碰撞",然后入侵系统,窃取信息。

如何防止哈希碰撞?

防止哈希碰撞的最有效方法,就是扩大哈希值的取值空间。

16个二进制位的哈希值,产生碰撞的可能性是 65536 分之一。也就是说,如果有65537个用户,就一定会产生碰撞。哈希值的长度扩大到32个二进制位,碰撞的可能性就会下降到 4,294,967,296 分之一。

更长的哈希值意味着更大的存储空间、更多的计算,将影响性能和成本。开发者必须做出抉择,在安全与成本之间找到平衡。

从String的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;
}

大概就是

假设 n=3
i=0 -> h = 31 * 0 + val[0]
i=1 -> h = 31 * (31 * 0 + val[0]) + val[1]
i=2 -> h = 31 * (31 * (31 * 0 + val[0]) + val[1]) + val[2]
       h = 31*31*31*0 + 31*31*val[0] + 31*val[1] + val[2]
       h = 31^(n-1)*val[0] + 31^(n-2)*val[1] + val[2]

为什么是质数

选择质数是为了在哈希桶中最佳地分布数据,跟其他数相乘的结果唯一的可能性也很大。

那么为什么是31呢?

  1. 31是一个不大不小的质数,是作为 hashCode 乘子的优选质数之一
  2. 31可以被 JVM 优化,31 * i = (i << 5) - 1

那为什么不是2、7、101 这么大的质数呢?

以String str = “abcde”为例子,大概计算大致

  • 2的时候 -> 2^5 = 32
  • 101的时候 -> 101^5 = 10,510,100,501

我们从上面一二知道,哈希值越大产生哈希碰撞的几率越小,但占用空间相应地也会变大

最后,我们再来看看质数31的计算结果: 31^5 = 28629151,结果值相对于3210,510,100,501来说。是不是很nice,不大不小

另外 effective java里面有这么一句话
选择数字31是因为它是一个奇质数,如果选择一个偶数会在乘法运算中产生溢出,导致数值信息丢失,因为乘二相当于移位运算。选择质数的优势并不是特别的明显,但这是一个传统。同时,数字31有一个很好的特性,即乘法运算可以被移位和减法运算取代,来获取更好的性能:31 * i == (i << 5) - i,现代的 Java 虚拟机可以自动的完成这个优化。

重写hashCode()

《Effective Java》中提出了一种简单通用的hashCode算法

A、初始化一个整形变量,为此变量赋予一个非零的常数值,比如int result = 17;//31

B、选取equals方法中用于比较的所有域(之所以只选择equals()中使用的域,是为了保证上述原则的第1条),然后针对每个域的属性进行计算:

  1. 如果是boolean值,则计算 f ? 1:0
  2. 如果是byte\char\short\int,则计算 (int)f
  3. 如果是long值,则计算 (int)(f ^ (f >>> 32))
  4. 如果是float值,则计算 Float.floatToIntBits(f)
  5. 如果是double值,则计算 Double.doubleToLongBits(f),然后返回的结果是long,再用规则(3)去处理long,得到int;
  6. 如果是对象应用,如果equals方法中采取递归调用的比较方式,那么hashCode中同样采取递归调用hashCode的方式。否则需要为这个域计算一个范式,比如当这个域的值为null的时候,那么hashCode 值为0;
  7. 如果是数组,那么需要为每个元素当做单独的域来处理。java.util.Arrays.hashCode 方法包含了8种基本类型数组和引用数组的hashCode计算,算法同上。

C、最后,把每个域的散列码合并到对象的哈希码中。

public class Person {
    private String name;
    private int age;
    private boolean gender;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public boolean isGender() {
        return gender;
    }

    public void setGender(boolean gender) {
        this.gender = gender;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age &&
                gender == person.gender &&
                Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        int hash = 17;
        hash = hash * 31 + getName().hashCode();
        hash = hash * 31 + isGender() ? 1:0;        hash = hash * 31 + getAge();
        return hash;
    }

参考: 

www.ruanyifeng.com/blog/2018/0…

segmentfault.com/a/119000001…