java代码优化——覆盖equals时总要覆盖hashCode

HashCode规范

一个很常见的错误根源在于没有覆盖hashCode方法。在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。如果不这样的话,就会违反Object.hashCode的通用约定,从而导致该类无法结合所有基于散列的集合一起愉快的正常运作,这样的集合包括HashMap,HashSet以及Hashtable。

下面是约定的内容,Object规范[JavaSE6]:

1.在应用程序的执行期间,只要对象的equals方法的比较操作所用的信息没有被修改,那么对这个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致。

2.如果两个对象根据equals(Object)方法比较结果相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果。

3.如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法不一定要产生不一样的整数结果。但是我们应该知道,给不相等的对象产生不相同的整数结果,可以提高散列表(hashtable)的性能。

未覆盖hashCode()方法的后果

因为没有覆盖hashCode而违反的关键约定是第二条:相等的对象必须具有相等的散列码(hash code)。
根据类的equals方法,两个截然不同的实例在逻辑上有可能是相等的,但是根据Object类的hashCode方法,它们仅仅是两个没有任何共同之处的对象。因此,对象的hashCode方法返回两个看起来是随机的整数,而不是根据第二个约定那样,返回两个相等的整数。
例如下面这个例子:

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, "lineNumber");

        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 (this == o)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;
        PhoneNumber pn = (PhoneNumber) o;
        return pn.lineNumber == lineNumber
                && pn.prefix == prefix
                && pn.areaCode == areaCode;
    }
}

如果和HashMap一起使用,则会出现问题。

Map<PhoneNumber, String> m = new HashMap<PhoneNumber, String>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");

System.out.println(m.get(new PhoneNumber(707, 867, 5309)));

输出结果为:

null

由于PhoneNumber类中没有重写hashCode方法,即使两个PhoneNumber实例是相等的(通过equals()方法比较相等),但是两个相等的类不具有相同的散列码,违反了hashCode的约定。因此,put方法把电话号码对象存放在一个散列桶(hash bucket)中,get方法却在另外一个散列桶里面查找。即使这两个实例正好被放到了同一个散列桶里面,get方法也一定会返回null,因为HashMap做了一项优化,将每个项相关联的散列码存放起来,如果hash码不匹配,则不会去检验对象的等同性,充分利用了逻辑与运算的惰性(&&)。

如何覆盖hashCode()方法

修正这个问题也非常的简单,只需要为PhoneNumber类提供了一个适当的hashCode方法即可。编写一个合法但是并不好用的hashCode方法并没有任何的价值,例如下面的这个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. 如果该域是一个数组,则要把每个元素当作单独的域来处理。

b. 按照下面的公式,将2.a计算得到的散列码c合并到result中:
result = 31 * result + c;

3.返回result。

4.写完了之后,检查是否符合上述的三条规定。

在散列码的计算过程中,可以把冗余域(redundant field)排除在外。换句话说,如果一个域的值可以根据参与计算的其他域计算出来,则可以把这样的域排除在外。必须排除equals比较计算中没有用到的域,否则很有可能违反hashCode约定中的第二条了。

在计算过程中选择31的原因,是因为31有个很好的性能,现代的JVM可以自动的优化计算过程,将31 * i优化成为(i << 5) - i。用位运算和减法替代了乘法。

下面开始为PhoneNumber类编写hashCode()方法。其中有三个关键域,都是short类型的,根据上面的规则2.a.ii,则hashCode代码如下:

@Override
public int hashCode() {
    int result = 17;
    result = 31 * result + areaCode;
    result = 31 * result + prefix;
    result = 31 * result + lineNumber;
    return result;
}

如果一个类是不可变的,并且计算散列码的开销比较大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果你就得这种类型的大多数对象会被用作散列键,就应该在创建实例的时候计算散列码。否则,可以选择“延迟初始化”散列码,一直到hashCode被第一次调用的时候才初始化。

//Lzzily initialized, cached hashCode
private volatile int hashCode;
@Override public int hashCode(){
    int result = hashCode;
    if(result == 0){
        int result = 17;
        result = 31 * result + areaCode;
        result = 31 * result + prefix;
        result = 31 * result + lineNumber;
    }
    return result;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,922评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,591评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,546评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,467评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,553评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,580评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,588评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,334评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,780评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,092评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,270评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,925评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,573评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,194评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,437评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,154评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容