ITEM 11:ALWAYS OVERRIDE HASHCODE WHEN YOU OVERRIDE EQUALS
必须在每个重写了 equals 方法的类中重写 hashCode 方法,如果不这样做,您的类将违反 hashCode 的通用契约,这可能导致它在 HashMap 和HashSet 这类集合中运行异常。根据 Object 规范,下面是具体的条款:
- 当在程序执行过程中反复调用对象的 hashCode 方法时,它必须始终返回相同的值(前提是在equals方法中不修改任何信息),此值不需要在程序的一次执行与另一次执行之间保持一致。
- 如果两个对象 调用equals(Object) 方法,结果是相等的,那么对这两个对象调用hashCode 也必须返回相同的结果。
- 如果两个对象 调用equals(Object) 方法,结果不相等,则不需要对每个对象调用hashCode 都必须产生不同的结果。但是,程序员应该意识到,为不同的对象生成不同的 hashcode 可以提高哈希表的性能。
当您无法重写 hashCode 方法时,违反的关键条款是第二个:相等的对象必须具有相等的 hashcode。类的equals方法的语义是,判断两个不同的实例在逻辑上是否是相等的,但是对于对象的 hashCode 方法来说,它们只是两个没有太多共同点的对象。因此,Object 的 hashCode 方法可以返回两个看似随机的数字,而不是契约要求的两个相等的数字。
例如,假设您尝试使用 item 10 中的 PhoneNumber 类的实例作为 HashMap 中的键:
Map<PhoneNumber, String> m = new HashMap<>(); m.put(new PhoneNumber(707, 867, 5309), "Jenny");
此时,你也许希望 m.get(new PhoneNumber(707, 867, 5309)) 返回 "Jenny",但实际上代码将返回null。注意,涉及两个 PhoneNumber 实例:一个用于插入HashMap,另一个实例是我们检索时新生成的。PhoneNumber 类未能覆盖hashCode 导致两个相等的实例具有不同的散列码,这违反了 hashCode 契约。因此,get方法可能会在与 put 方法命中的散列桶中查找。
即使这两个实例碰巧散列到同一个 bucket,get 方法也几乎肯定会返回 null,因为HashMap 有一个优化,可以缓存与每个条目关联的散列码,如果散列代码不匹配,也不需要检查对象是否相等。
解决这个问题很简单,只要为 PhoneNumber 编写一个合适的 hashCode 方法就可以了。那么hashCode 方法应该是什么样的呢?写一个不符合规范的例子很简单,例如下面这条是合法的,但我们不应该使用:
// The worst possible legal hashCode implementation - never use!
@Override public int hashCode() { return 42; }
这是合法的,因为它确保相等的对象具有相同的哈希码。但它很糟糕,因为它让所有对象都有相同的哈希码。因此,每个对象都散列到同一个桶中,散列表退化为链表。程序应该在 O(n) 时间运行,而不是在 O(n2) 运行。对于大型哈希表,可能导致程序无法工作。
一个好的哈希函数往往会为不相等的实例生成不同的哈希码。这正是 hashCode 契约的第三部分的含义。理想情况下,哈希函数应该在所有 int 值上均匀地分布任何合理的不相等实例集合。实现这一理想可能很困难。幸运的是,得到一个合理的近似并不难。这里有一个简单的方法:
- 声明一个名为 result 的 int 变量,并将其初始化为对象中第一个重要字段的哈希码c,如步骤2.a中计算的那样。(回顾 itme 10,一个重要字段是一个影响等号比较的字段。)
- 对于对象中剩余的重要字段f,执行以下操作:
a.计算字段的int哈希码c:
i. 如果字段是基本类型,计算 Type.hashCode(f),其中type是与f的类型对应的装箱基元类。
ii. 如果字段是对象引用,并且该类的 equals 方法通过递归调用 equals 来比较字段,则递归调用字段上的 hashCode。如果需要更复杂的比较,请为该字段计算一个“规范表示”,并在规范表示上调用hashCode。如果字段的值为null,则使用0 (或其他常量,但0是传统的)。
iii. 如果字段是一个数组,则将其中每个重要元素视为一个单独的字段。也就是说,通过递归地应用这些规则,为每个重要元素计算一个哈希码,并在每个步骤2.b中组合这些值。如果数组没有重要元素,则使用常量,最好不是0。如果所有的元素都是重要的,使用Arrays.hashCode。
b.结合步骤2中计算的哈希码c。a转化结果如下:
result = 31 * result + c;
- 对于对象中剩余的重要字段f,执行以下操作:
- Return result.
当您完成 hashCode 方法的编写时,请自问 equal 实例是否具有相同的散列代码。编写单元测试来验证您的直觉(除非您使用AutoValue来生成equals和hashCode方法,在这种情况下,您可以安全地忽略这些测试)。如果相等的实例有不相等的哈希码,找出原因并解决问题。
可以在哈希码计算中排除派生字段。换句话说,您可以忽略任何字段,其值可以从计算中包含的字段计算出来。
您必须排除在equals比较中没有使用的任何字段,否则您可能会违反 hashCode 契约的第二项规定。
2.b 中的乘法使结果依赖于字段的顺序,如果类有多个类似的字段,则生成更好的散列函数。例如,如果从字符串哈希函数中省略乘法,所有的字谜都将具有相同的哈希代码。之所以选择31,是因为它是一个质数。如果它是偶数,并且乘法溢出,信息就会丢失,因为乘以2等于移位。使用质数的优势不太明显,但它是传统的。31的一个很好的特性是,可以用移位和减法替换乘法,从而在某些架构上获得更好的性能:
31 * i == (i << 5) - i
让我们将之前的方法应用到PhoneNumber类:
- Return result.
// Typical hashCode method
@Override public int hashCode() {
int result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
return result;
}
由于该方法返回一个简单确定性计算的结果,该计算的唯一输入是PhoneNumber实例中的三个重要字段。显然,相同的PhoneNumber实例具有相同的哈希码。实际上,这个方法是 PhoneNumber 的一个非常好的 hashCode 实现,可以与Java平台库中的实现相媲美。它很简单,速度也相当快,并且能够将不相等的电话号码分散到不同的散列桶中。
虽然上面的例子是一个相当好的散列函数,但它们并不是最棒的。它们在质量上可以与Java平台库的值类型中的散列函数相媲美,并且对于大多数用途都是足够的。如果您确实需要不太可能产生冲突的散列函数,请参阅Guava的com.google.common.hash.Hashing [Guava]。
Objects类有一个静态方法,它接受任意数量的对象并为它们返回一个散列代码。这个名为 hash 的方法允许您编写单行 hashCode 方法,其质量可与根据该项目中的配方编写的方法相媲美。不幸的是,它们运行得更慢,因为它们需要创建数组来传递可变数量的参数,如果其中任何参数是原始类型,则需要装箱和解箱。建议只在性能不重要的情况下使用这种类型的哈希函数。下面是一个使用这种技术编写的PhoneNumber 函数:
// One-line hashCode method - mediocre performance
@Override public int hashCode() {
return Objects.hash(lineNum, prefix, areaCode);
}
如果一个类是不可变的,并且计算散列代码的成本很高,那么您可以考虑将散列代码缓存到对象中,而不是每次请求时重新计算它。如果您认为这种类型的大多数对象都将用作散列键,那么您应该在创建实例时计算散列代码。否则,您可能选择在第一次调用散列代码时惰性地初始化散列代码。需要注意,确保类在存在延迟初始化的字段时仍然是线程安全的。我们的 PhoneNumber 类不需要这种处理,但只是向您展示它是如何完成的,在这里。注意,hashCode字段的初始值(在本例中为0)不应该是通常创建的实例的哈希码:
// 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;
}
不要试图从哈希代码计算中排除重要字段来提高性能。虽然生成的哈希函数可能运行得更快,但其质量差可能会降低哈希表的性能,使其无法使用。特别是,哈希函数可能会遇到大量实例,这些实例主要在您选择忽略的区域中不同。如果发生这种情况,哈希函数将把所有这些实例映射到几个哈希代码,应该在线性时间内运行的程序将在二次时间内运行。
这不仅仅是一个理论问题。在Java 2之前,字符串哈希函数最多使用16个字符,以第一个字符开始,在整个字符串中平均间隔。对于层次结构名称的大集合(如url),此函数完全显示了前面描述的病态行为。
不要为 hashCode 返回的值提供详细的规范,因此客户端不能合理地依赖它;这使您可以灵活地更改它。Java库中的许多类,如 String 和 Integer,都指定它们的hashCode 方法返回的确切值作为实例值的函数。这不是一个好主意,而是我们不得不面对的一个错误:它阻碍了在未来版本中改进 hash 函数的能力。如果您没有指定细节,并且在散列函数中发现了一个缺陷,或者发现了一个更好的散列函数,您可以在后续版本中修改它。
总之,每次重写 equals 时都必须重写 hashCode,否则程序将无法正确运行。您的hashCode 方法必须遵守对象中指定的通用契约,并且必须合理地为不相等的实例分配不相等的哈希码。使用本文提供的规范,这很容易实现,尽管有点乏味。正如item 10 中提到的,AutoValue 框架提供了一个很好的替代方法,可以替代手工编写 equals 和hashCode 方法,IDE也提供了一些功能。
网友评论