《Effective Java》读书笔记08-覆盖hashCode
一、覆盖hashCode
在每一个覆盖equals方法的类中必须覆盖hashCode方法,如果你想让该类能够用于基于散列的集合中,如HashMap,HashSet和Hashtable。
Object.hashCode通用约定
1、在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这个同一对象调用多次,hashCode方法必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致。
2、如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果。反之,如果两个对象hashCode方法返回整数结果一样,则不代表两个对象相等,因为equals方法可以被重载。
3、如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但,如果能让不同的对象产生不同的整数结果,则有可能提高散列表的性能。
二、没覆盖hashCode的糟糕实践
下面是一个PhoneNumber类
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; }}该类的equals方法是根据读书笔记07里的建议构造出来的,但我们如果把它与HashMap一起使用,你就会发现问题
测试数据:
public static void main(String[] args) { Map<PhoneNumber, String> m = new HashMap<PhoneNumber, String>(); m.put(new PhoneNumber(707, 867, 5309), "Jenny"); //由于没有覆盖hashCode,导致两个实例hashCode值不一样 System.out.println(m.get(new PhoneNumber(707, 867, 5309))); }原本期望返回的是"Jenny",实际获取到的是null。原因是,该类没有覆盖hashCode方法,而是使用Object.hashCode方法来产生散列码,Object.hashCode产生的散列码是随机数(Object是最底层的,它不保证产生的散列码正是你想要的),所以两个实例即使相等,但产生的散列码却是不同的。这就违反了hashCode的第二个约定,相同的对象必须具有相等的散列码。
此处,put方法把电话号码对象存储在一个散列桶(hash bucket)中,get方法却在另一个散列桶中查找。即使这两个实例刚好位于同一个散列桶中,get方法必定会返回null,因为hashMap有一项优化,可以将与每个项相关联的散列码缓存起来,如果散列码不匹配,就直接不再检验对象的等同性。
三、编写一个好的散列函数
解决二中问题的方法是,为PhoneNum提供一个适当的hashCode方法。
@Override public int hashCode(){return 42;}上面的hashCode方法是合法的,因为它确保了相等的对象总是具有相同的散列码。但它使得每个对象都具有同样的散列码,这是极端恶劣的。每个对象都被映射到同一个散列桶中,使散列表退化为链表。这样的话,本该线性时间运行的程序将需要平方级时间来运行,是个极差的实践。一个好的散列函数通常倾向于"为不相同的对象产生不相等的散列码"。
理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能地散列值上。
以下是该实践的简单解决方法:
1、把某个非零的常数值,比如17,保存在一个名为result的int类型的变量中。
2、对于对象中每个关键域f(指equals方法中涉及的每个域),完成以下步骤:
a、为该域计算int类型的散列码c:
i、如果该域是boolean类型,则计算(f?1:0)。
ii、如果该域是byte,char,short或者int类型,则计算(int)f。
iii、如果该域是long类型,则计算(int)(f^(f>>>32))。
iv、如果该域是float类型,则计算Float.floatToIntBits(f)。
v、如果该域是double类型,则计算Double.doubleToLongBits(f),然后按照步骤2.a.iii,为得到的long类型值计算散列值。
vi、如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals的方式来比较这个域,则同样为这个域递归地调用hashCode。如果需要更复杂的比较,则为这个域计算一个范式(canonical representation),然后针对这个范式调用hashCode。如果这个域的值为null,则返回0(其他常数也行)。
vii、如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组域中的每个元素都很重要,可以利用发行版本1.5中增加的其中一个Arrays.hashCode方法。
b、按照下面的公式,把步骤2.a中计算得到的散列码c合并到result中:
result = 31 * result + c; //此处31是个奇素数,并且有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31*i == (i<<5) - i,现代JVM能自动完成此优化。
3、返回result
4、检验并测试该hashCode实现是否符合通用约定。
注意:在计算过程中,冗余项要排除在外。必须排除可以通过其他域值计算出来或equals比较计算中没用的的任何域,否则有可能违反hashCode第二条约定。
采用此方法修改PhoneNum类如下:
//在原有实现中添加一个hashCode方法 @Override public int hashCode() { int result = 17; result = 31 * result + areaCode; result = 31 * result + prefix; result = 31 * result + lineNumber; return result; }四、散列码的延迟初始化
上面的hashCode方法很好的解决了二中的问题。如果一个类是不可变的,并且计算散列码的开销比较大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果你觉得这种类型的大多数对象会被用做散列键(hash keys),就应该在创建实例的时候计算散列码。否则,可以选择延迟初始化散列码,一直到hashCode被第一次调用的时候才初始化。
下面是PhoneNum延迟初始化hashCode的版本:
//延迟初始化hashCode private volatile int hashCode; // (See Item 71) @Override public int hashCode() { int result = hashCode; if (result == 0) { result = 17; result = 31 * result + areaCode; result = 31 * result + prefix; result = 31 * result + lineNumber; hashCode = result; } return result; }
1、覆盖equals方法是必须覆盖hashCode方法
2、覆盖hashCode方法要遵守通用约定,实现时可以参考三种方法
3、当hashCode计算开销大时,可以考虑延迟初始化hashCode
4、不要试图从散列码计算中排除掉一个对象的关键部分来提供性能