一个常见的bug原因是没有覆盖hashCode方法。在每个覆盖了equals的类中,都必须覆盖hashCode。如果不这样,则会导致违反Object.hashCode()的通用约定,导致在与所有基于哈希码的集合无法一起正常工作,包括HashMap、HashSet、Hashtable。
如下是Object规范中的通用约定:
- 在程序的一次执行中只要equals方法所用到的信息没有改变,则多次调用同一个对象的hashCode(),都能始终返回相同的整数。在同一程序的多次执行中,每次的返回结果可以不同。
- 如果equals方法判定两个对象相同,则这两个对象的hashCode必须产生相同的整数。
- 如果equals方法判定两个对象不同,则这两个对象的hashCode可以不用;然而,程序员必须要清楚,给不相等的对象生成不同的hashCode可以提供哈希表的性能。
关键的约定是第二条:相等的对象必须有相同的hashCode。如果未覆盖hashCode,则两个相等对象返回的哈希码是Object.hashCode()产生的两个随机数。
【例】下面这个PhoneNumber类,其equals方法是根据Item8的诀窍构造出来的:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;
public PhoneNumber(int areaCode, int prefix, int lineNumber) {
rangeCheck(areaCode, 999, "area code");
rangeCheck(prefix, 999, "prefix");
rangeCheck(lineNumber, 9999, "line number");
this.areaCode = (short) areaCode;
this.prefix = (short) prefix;
this.lineNumber = (short) lineNumber;
}
private static void rangeCheck(int arg, int max, String name) {
if (arg < 0 || arg > max)
throw new IllegalArgumentException(name +": " + arg);
}
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber)o;
return pn.lineNumber == lineNumber
&& pn.prefix == prefix
&& pn.areaCode == areaCode;
}
// Broken - no hashCode method!
... // Remainder omitted
}
假设你打算在HashMap中使用这个类:
Map<PhoneNumber, String> m
= HashMap<PhoneNumber, String>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");
这时候你可能期望m.get(new PhoneNumber(707, 867, 5309))返回"Jenny",可它实际上却返回null。本例中两个PhoneNumber对象是equals的,但hashCode不同;导致在put的时候将对象A放到一个哈希桶A中,但get的时候却根据对象B的哈希码 到哈希桶B中却查找对象。
即使碰巧对象AB都指向相同的哈希桶,get方法也会返回null;因为HashMap有一项优化,它会将每个项关联的哈希码缓存起来,当哈希码不同时,就不再去检查对象等同性了。
修正这个问题非常简单,只要给PhoneNumber类提供一个合适的hashCode方法即可。那么hashCode()应该怎么写呢?编写一个合法但是不好用的hashCode是没有价值的,【例】如下hashCode()是合法的,但永远不应该这么写:
@Override
public int hashCode() {
return 42;
}
它是合法的,因为保证了相等的对象拥有相同的哈希码。但他也是很恶劣的,因为所有对象都拥有相同的哈希码。因此,每个对象都被放到相同的哈希桶中,使得哈希表(hash table)退化为链表(linked list)。本来应该线性时间运行的程序变成平方时间运行了,对于大型的哈希表,这会关系到能否正常工作。
一个好的哈希函数会为不等的对象产生不等的哈希码。理想情况下,哈希函数能够将不相等的实例均匀分不到所有可能的哈希值上。达到这个理想情况是很难的。幸运的是,达到近似理想情况并不十分困难,下面 是简单的秘诀:
1、用一个int类型的变量result 保存一个非零的常数,例如17。
2、针对对象中的每个关键字段f (即equals方法中涉及的每个字段),执行以下操作:
a. 计算该字段的哈希码:
- 如果字段是boolean类型,计算 (f ? 1 : 0)
- 如果字段是byte, char, short, int,计算 (int) f
- 如果字段是long,计算 (int) ( f ^ (f >>> 32))
- 如果字段是float,计算Float.floatToIntBits(f)
- 如果字段是double,计算Double.doubleToLongBits(f),然后对返回的long值进行上一步操作
- 如果字段是对象引用,并且该类的equals通过递归调用该对象引用的equals来比较这个字段,则递归调用该字段的hashCode;如果需要更复杂的比较,则为该字段计算一个范式(canonical representation),在该范式上调用hashCode;如果该字段为null,则返回0(也可以返回其他常数,但一般用0)
- 如果字段是数组,则将其每个元素当成一个单独字段来处理。如果数组中的每个元素都很重要,可以用Arrays.hashCode来处理。
b. 将Step2.a中得到的值c,合并到result中
result = 31 * result + c;
3、返回result
4、当你写完hashCode方法后,问问自己相同的实例是否返回相同的哈希码。编写单元测试来验证你的推论。
在哈希码的计算过程中,你可以排除掉冗余字段。换言之,如果一个字段的值 可以通过hashCode计算过程中的其他字段计算出来,则可以忽略掉这个字段。你必须排除掉在equals比较中没有用到的字段,否则就有可能违反hashCode约定的第二条。
在Step1中用了一个非零的初始值,所以Step2.a中计算得到哈希码为0的那些初始字段会影响到哈希值。如果Step1中的初始值为0,则最终的哈希值不会被这些初始字段影响,这样会增加冲突。值17是任选的。
Step2.b中的乘积使得计算结果依赖于字段顺序,如果类包含多个相似的字段,这样就会产生更好的哈希函数。【例】如果String类的hash函数忽略了乘积,则所有字符都具有相同的哈希码。
其中乘数选择31,是因为它是一个奇素数。如果选用偶数,并且乘法溢出,则信息会丢失,因为与2相乘等价于移位操作。使用素数的好处并不明显,不过习惯上这么用。31的一个很好的特性是,乘积可以用移位和减法来替代,以达到更好的性能:
31 * i == (i << 5) - i
现代VM会自动进行这种优化。
我们将上述诀窍应用于PhoneNumber类,它有三个重要字段,都是short类型:
@Override
public int hashCode() {
int result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumver;
return result;
}
如果想哈希函数达到艺术级别,最好留给科学家去研究……
不要试图在哈希码计算过程中排除掉任何重要字段以便提高性能。虽然这样哈希函数可能跑得更快,但是由于其质量不好,可能会降低哈希表的性能,以致哈希表慢到无法使用。特别是在实践中,哈希函数会面对大量的实例集合,这些实例恰恰在被你忽略的字段上有很大差异。如果是这样,那么哈希函数将把所有实例映射到少数几个哈希码上,以致基于哈希的集合显示出平方级的性能指标。
这可不仅仅是理论问题,JDK1.2之前版本中String类的哈希函数最多只检查16个字符,从第一个字符开始在整个字符串中均匀选取。对于像URL这种层级结构的字符串集合来说,这种哈希函数正好体现了这种病态行为。
Java平台包中的许多类,例如String、Integer、Date,都指定将其hashCode()方法返回的确切值作为实例值得一个函数。这通常不是个好主意,因为这限制了在后续版本中对哈希函数做改进的能力。而如果不指定hashCode()方法的细节,则如果以后发现了缺陷,或者找到了更好的哈希函数,那么就可以在后续版本中更改哈希函数,并确信没有客户端依赖于哈希函数返回的确切值。