天天看点

Effective Java笔记第二章对所有对象都通用的方法第二节覆盖equals时总要覆盖hashCode

Effective Java笔记第二章对所有对象都通用的方法

第二节覆盖equals时总要覆盖hashCode

本文提到了volatile关键字,如果不是很清楚的话建议看一下这篇文章volatile关键字的作用,相信会对你有些许帮助。

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

2.Object.hashCode的通用约定:

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

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

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

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

下面我们举个例子:

public 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);
        }
    }

    //覆写equals方法
    @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.areaCode == areaCode && pn.prefix == prefix;
    }

    public static void main(String[] args) {
        Map<PhoneNumber,String> map=new HashMap<>();
        PhoneNumber pn1 = new PhoneNumber(707, 867, 5309);
        System.out.println(pn1.hashCode());
        PhoneNumber pn2 = new PhoneNumber(707, 867, 5309);
        System.out.println(pn2.hashCode());
        map.put(pn1,"Jenny");
        String name = map.get(pn2);
        System.out.println(name);
    }

}
           

输出:

1956725890

356573597

null

我们希望返回的"Jenny",但是返回了null。这里我们有两个PhoneNumber 实例,第一个被插入到了HashMap中,第二个实例和第一个相等,被用来获取HashMap中的值。由于PhoneNumber 类没有覆写hashCode方法,从而导致两个相等的实例具有不相等的散列码,违反了hashCode约定。因此,put方法把PhoneNumber 对象存放在了一个散列桶中,get方法却在另一个散列桶中查找这个PhoneNumber 。即使这两个实例正好被放在一个散列桶中,get方法也必定会返回null,因为HashMap有一项优化,可以将每个项相关联的散列码缓存起来,如果散列码不匹配,也不必检验对象的等同性。

3.一个好的散列函数通常倾向于"为不相等的对象产生不相等的散列码"。理想情况下,散列函数应该把集合中不相等的实例均匀的分布到所有可能的散列值上。要达到理想状态非常困难,但是接近理想情形并不太困难,下面给出一种简单的解决方法:

1)把某个非零的常数值(最好是素数,也就是质数,只能被1和本身整除),比如说17,保存在一个 名为result的int类型的变量中。

2)对于对象中每个关键域(指equals方法中涉及的每个域),完成以下步骤:

a)为该域计算int类型的散列码:

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),然后再按照long类型来计算散列值。

vi)如果该域是一个对象引用,并且该类的equals方法通过递归的调用equals方式来比较这个域,则同样为这个域递归的调用hashCode。如果需要更复杂的比较,则为这个域计算一个"范式",然后针对这个范式调用hashCode。如果这个域的值为null,则返回0。

vii)如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归的应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来,如果数组域中的每个元素都很重要,可以利用发行版本1.5中增加的其中一个Arrays.hashCode方法。

b)按照下面的公式,把步骤2.a中计算得到的散列码c合并到result中:

result = 31*result + c ;

3)返回result。

下面我们演示一下:

public class Person {

    public boolean Aboolean;
    public short Ashort;
    public long Along;
    public float Afloat;
    public double Adouble;
    public Man Aman;
    public int[] ints;

    public Person(boolean aboolean, short ashort, long along, float afloat, double adouble, Man aman, int[] ints) {
        Aboolean = aboolean;
        Ashort = ashort;
        Along = along;
        Afloat = afloat;
        Adouble = adouble;
        Aman = aman;
        this.ints = ints;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (!(o instanceof Person)) {
            return false;
        }
        Person p = (Person) o;
        return p.Aboolean == Aboolean &&
                p.Ashort == Ashort &&
                p.Along == Along &&
                p.Afloat == Afloat &&
                p.Adouble == Adouble &&
                //调用对象类的equals方法比较
                p.Aman.equals(Aman) &&
                p.ints == ints;
    }

    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + (Aboolean ? 1 : 0);
        result = 31 * result + Ashort;
        result = 31 * result + (int) (Along ^ (Along >>> 32));
        result = 31 * result + Float.floatToIntBits(Along);
        result = 31 * result + Float.floatToIntBits(Double.doubleToLongBits(Adouble));
        //调用对象类的hashCode
        int i = Aman.hashCode();
        result = 31 * result + i;
        //数组的hashCode判断
        for (int anInt : ints) {
            result=31*result+anInt;
        }
        return result;
    }

    public static void main(String[] args) {
        short s = 11;
        Man man = new Man(11);
        int[] ints = {1, 2,3};
        Person p = new Person(true, s, 22L, 22.2f, 22.1, man, ints);
        System.out.println(p.hashCode());
    }

}

class Man {

    public int Aint;

    public Man(int aint) {
        Aint = aint;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (!(o instanceof Man)) {
            return false;
        }
        Man man = (Man) o;
        return man.Aint == Aint;
    }

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

}
           

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

步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。加入String散列函数省略了这个乘法部分,那么只是字母顺序不同的所有字符串都会有相同的散列码。之所以选择31,因为他是一个奇素数,我们习惯上使用素数来计算散列结果。如果乘数是偶数,并且乘法溢出,信息就会丢失,因为与2相乘等价于移位运算。31有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31*i==(i<<5)-i。现代的VM可以自动完成这种优化。

4.现在再看2的问题就简单很多了,PhoneNumber 有三个关键域,都是short类型,我们可以这样写:

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

           

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

也就是说可以用下面这种写法:

public class Demo {

    private final short areaCode;
    private final short prefix;
    private final short lineNumber;
    private volatile int hashCode;

    public Demo(int areaCode, int prefix, int lineNumber, int hashCode) {
        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;
        this.hashCode = hashCode;
    }

    //设置数字的范围
    private static void rangeCheck(int arg, int max, String name) {
        if (arg < 0 || arg > max) {
            throw new IllegalArgumentException(name + ": " + arg);
        }
    }

    //覆写equals方法
    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (!(o instanceof Demo)) {
            return false;
        }
        Demo pn = (Demo) o;
        return pn.lineNumber == lineNumber && pn.areaCode == areaCode && pn.prefix == prefix;
    }
    
    @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;
    }

    public static void main(String[] args) {
        Map<Demo, String> map = new HashMap<>();
        Demo pn1 = new Demo(707, 867, 5309, 0);
        System.out.println(pn1.hashCode);
        System.out.println(pn1.hashCode());
        System.out.println(pn1.hashCode);
        Demo pn2 = new Demo(707, 867, 5309, 0);
        System.out.println(pn2.hashCode());
        map.put(pn1, "Jenny");
        String name = map.get(pn2);
        System.out.println(name);
    }

}
           

输出为:

1218060

1218060

1218060

Jenny

5.注意:不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,虽然这样得到的散列函数运行可能更快,但是可能会导致散列表慢到根本无法使用。