重写 equals 方法时同时也要重写 hashcode 方法

在每个类中,在重写 equals 方法的时侯,一定要重写 hashcode 方法。

如果不这样做,你的类违反了 hashCode 的通用约定,这会阻止它在 HashMap 和 HashSet 这样的集合中正常工作。

根据 Object 规范,以下时具体约定。

  1. 当在一个应用程序执行过程中,如果在 equals 方法比较中没有修改任何信息,在一个对象上重复调用 hashCode 方法时,它必须始终返回相同的值。从一个应用程序到另一个应用程序的每一次执行返回的值可以是不一致的。
  2. 如果两个对象根据 equals(Object) 方法比较是相等的,那么在两个对象上调用 hashCode 就必须产生的结果是相同的整数。
  3. 如果两个对象根据 equals(Object) 方法比较并不相等,则不要求在每个对象上调用 hashCode 都必须产生不同的结果。 但是,程序员应该意识到,为不相等的对象生成不同的结果可能会提高散列表(hash tables)的性能。

  当无法重写 hashCode 时,所违反第二个关键条款是:相等的对象必须具有相等的哈希码( hash codes)。 根据类的 equals 方法,两个不同的实例可能在逻辑上是相同的,但是对于 Object 类的 hashCode 方法,它们只是两个没有什么共同之处的对象。因此, Object 类的 hashCode 方法返回两个看似随机的数字,而不是按约定要求的两个相等的数字。

示例代码Item11Example01.java:将对象作为 HashMap 的键。

Map<PhoneNumber1, String> m = new HashMap<>();
m.put(new PhoneNumber1(707, 867, 5309), "Jenny");
System.out.println(m.get(new PhoneNumber1(707, 867, 5309))); // null

你可能期望 m.get(new PhoneNumber(707, 867, 5309)) 方法返回 Jenny 字符串,但实际上,返回了 null。

注意,这里涉及到两个 PhoneNumber 实例:一个实例插入到 HashMap 中,另一个作为判断相等的实例用来检索。

PhoneNumber 类没有重写 hashCode 方法导致两个相等的实例返回了不同的哈希码,违反了 hashCode 约定。

put 方法把 PhoneNumber 实例保存在了一个哈希桶( hash bucket)中,但 get 方法却是从不同的哈希桶中去查找,即使恰好两个实例放在同一个哈希桶中,get 方法几乎肯定也会返回 null。

因为 HashMap 做了优化,缓存了与每一项(entry)相关的哈希码,如果哈希码不匹配,则不会检查对象是否相等了。

解决这个问题很简单,只需要为 PhoneNumber 类重写一个合适的 hashCode 方法。

示例代码Item11Example02.java:将对象作为 HashMap 的键并重写 hashCode 方法。

一个好的 hash 方法趋向于为不相等的实例生成不相等的哈希码。这也正是 hashCode 约定中第三条的表达。理想情况下,hash 方法为集合中不相等的实例均匀地分配 int 范围内的哈希码。实现这种理想情况可能是困难的。 幸运的是,要获得一个合理的近似的方式并不难。 以下是一个简单的配方:

  1. 声明一个 int 类型的变量 result,并将其初始化为对象中第一个重要属性 c 的哈希码,如下面步骤 2.a 中所计算的那样。(回顾条目 10,重要的属性是影响比较相等的领域。)

  2. 对于对象中剩余的重要属性 f,请执行以下操作: a. 比较属性 f 与属性 c 的 int 类型的哈希码:

    ​ – i. 如果这个属性是基本类型的,使用 Type.hashCode(f) 方法计算,其中 Type 类是对应属性 f 基本类型的包装类。 ​

    ​ – ii. 如果该属性是一个对象引用,并且该类的 equals 方法通过递归调用 equals 来比较该属性,并递归地调用 hashCode 方法。 如果需要更复杂的比较,则计算此字段的“范式(“canonical representation)”,并在范式上调用 hashCode。 如果该字段的值为空,则使用 0(也可以使用其他常数,但通常来使用 0 表示)。 ​

    ​ – iii. 如果属性 f 是一个数组,把它看作每个重要的元素都是一个独立的属性。 也就是说,通过递归地应用这些规则计算每个重要元素的哈希码,并且将每个步骤 2.b 的值合并。 如果数组没有重要的元素,则使用一个常量,最好不要为 0。如果所有元素都很重要,则使用 Arrays.hashCode 方法。

    b. 将步骤 2.a 中属性 c 计算出的哈希码合并为如下结果:result = 31 * result + c;

  3. 返回 result 值。

之所以选择 31,因为它是一个奇数的素数。 如果它是偶数,并且乘法溢出,信息将会丢失,因为乘以 2 相当于移位。 使用素数的好处不太明显,但习惯上都是这么做的。 31 的一个很好的特性,是在一些体系结构中乘法可以被替换为移位和减法以获得更好的性能:31 * i ==(i << 5) - i。 现代 JVM 可以自动进行这种优化。

Objects 类有一个静态方法,它接受任意数量的对象并为它们返回一个哈希码。 这个名为 hash 的方法可以让你编写一行 hashCode 方法,其质量与根据这个项目中的上面编写的方法相当。 不幸的是,它们的运行速度更慢,因为它们需要创建数组以传递可变数量的参数,以及如果任何参数是基本类型,则进行装箱和取消装箱。 这种哈希函数的风格建议仅在性能不重要的情况下使用。

示例代码Item11Example03.java:使用Objects 类静态方法编写的 hashCode 。

如果一个类是不可变的,并且计算哈希码的代价很大,那么可以考虑在对象中缓存哈希码,而不是在每次请求时重新计算哈希码。 如果你认为这种类型的大多数对象将被用作哈希键,那么应该在创建实例时计算哈希码。

// hashCode method with lazily initialized cached hash code
private int hashCode; // Automatically initialized to 0

@Override 
public int hashCode() {
    int result = hashCode;
    if (result == 0) {
        result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        hashCode = result;
    }
    return result;
}

总之,每次重写 equals 方法时都必须重写 hashCode 方法,否则程序将无法正常运行。你的 hashCode 方法必须遵从 Object 类指定的常规约定,并且必须执行合理的工作,将不相等的哈希码分配给不相等的实例。