《Effective Java》学习笔记11 Always override hashCode when you override equals

本栏是博主根据如题教材进行Java进阶时所记的笔记,包括对原著的概括、理解,教材代码的报错和运行情况。十分建议看过原著遇到费解地方再来参考或与博主讨论。致敬作者Joshua Bloch和各路翻译者们,以及为我提供可参考博文的博主们。

重写equals()方法的同时也要重写hashCode()方法

必须在每个重写equals()的类中重写Object.hashCode(),否则将违反hashCode()的通用规约,然后在比如HashMap,HashSet这种类里面使用时,出现各种奇怪的异常。hashCode的使用规约大致如下:

hashCode通用规约

  1. 不改变在equals()中比较所涉及到的相关参数前提下,对某一对象多次执行hashCode, 它必须返回相同值,而该应用本次执行结束后,下一次执行跟本次执行时的返回值是否相同就没啥要求了。
  2.  如果x.equals(y) == true,那么x和y的hashCode()返回值也应相同。
  3.  如果x.equals(y) == false,x和y的hashCode()返回值不要求绝对不同。但程序员应清楚,存在不同对象有相同hashCode的情况。可以考虑改用更好的映射函数尽量减少这种情况,以提供更好性能。

问题案例

最关键的是第二条。根据equals方法,先后创建的两个实例在逻辑上相等是有可能的,但对于hashCode方法来讲,两者只是两个不同的对象,于是返回值也会不同。比如这个例子PhoneBookTest:

/**
 * 电话簿,用于说明为什么重写equals一定要同时重写hashCode
 */
public class PhoneBookTest {
    private static Map<PhoneNumber , String> phoneBook= new HashMap<>();

    public static void main(String[] args) {
        phoneBook.put(new PhoneNumber("403,404,8080") , "lightDance");

        String targetName = phoneBook.get(new PhoneNumber("403,404,8080"));

        System.out.println(targetName);
    }
}

/**
 * 跟电话簿配合使用的电话号码
 */
public class PhoneNumber {

    private String number;

    public String get() {
        return number;
    }

    public void set(String number) {
        this.number = number;
    }

    public PhoneNumber(String number) {
        this.number = number;
    }
}

我们希望只要把相同的电话号码当作参数传进去,就可以得到相同的联系人姓名,但是实际上,由于没有重写equals方法,两个new出来的PhoneNumber是两个不同的对象,于是get方法当然查不到对应的联系人姓名

就算两对象哈希返回值恰好一样也不会将两实体弄混,因为HashMap在存储的时候,会同时存储key的哈希值和key的实例,而根据key去取对应value时HashMap.get(Object),也会在算出hashCode值之后,在具有相同hashCode的元素链表中逐一对比,看key是否相同(x.equals(y) || x == y),只有这些条件全部满足才会确认是该元素。

修正措施

要想修正这个问题,应该重写hashCode()方法的逻辑。首先如果想让其满足预期要求,那么有很简单很暴力的方式,比如这个{@link BadPhoneNumber},直接返回个常数24出来。这样的确能令相同对象拥有相同hashCode,但如此一来,HashMap中所有的元素就全部被放到了同一根链表里面,复杂度平了个方。

优秀的hashCode方法倾向于为不同的对象提供尽可能不同的hashCode值,而且各个相同hashCode值相同的链表中,元素个数也尽可能相同。不过这只是理想状态。下面给出一种较为理想的修改方式BestPhoneNumber:

/**较为理想的hashCode重写方式及一般步骤,这里不妨将所存的数据搞多一些,以方便说明问题。*/
public class BestPhoneNumber {
    /**
     * 性别
     */
    private boolean sex;
    /**
     * 亲密度
     */
    private float intimacy;
    /**
     * 电话号码
     */
    private String number;

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof BestPhoneNumber)) {
            return false;
        }
        BestPhoneNumber phoneNumber = (BestPhoneNumber) obj;

        return phoneNumber.number.equals(this.number) &&
                phoneNumber.sex == this.sex &&
                phoneNumber.intimacy == this.intimacy;
    }


    @Override
    public int hashCode() {
        int result = 23;
        result = 31 * result + number.hashCode();
        result = 31 * result + Float.hashCode(intimacy);
        result = 31 * result + Boolean.hashCode(sex);
        return result;
    }
}

hashCode计算方案设计

1.随便搞个非零的常数,作为hashCode的乘数,这里用了23,放在hashCode()方法里面

2.对equals方法所设计需要比较的每一个参数f,按如下方式计算其局部哈希值c:

  • 对boolean, byte, char, short, int, long, float, double这些基本数据类型,使用Type.hashCode(f),其中Type是对应基本类型的装箱类型
  • 对引用类型并且其equals也使用递归比较字段的,那么也递归调用其hashCode()以计算c;如果要实现更复杂的比较,那么就为它设计一个“范式”,然后在范式中调用hashCode.另外,若该字段为null则c = 0(其实也可以是其他常数,但一般都用0)
  • 对于数组字段,将其中所有有效字段单独计算局部哈希值然后求和;如果没有有效字段,则使用常量,但这时最好就不要使用0了。另外,如果数组中全部元素都有效,考虑使用Array.hashCode()

3.result = 31result + c,并将这个值返回

     *.选择31这个数字是因为它是奇素数,素数可以有效减少哈希值相同的情景,而奇数可以防止因位移而丢失信息(偶数为2的倍数,*2等价于向左移位)
     **.result有一个非0的初始值,这样第二步中的0默认值就不会对它产生影响导致冲突增加
     ***.有时需要基于参数顺序调整计算哈希值的参数,例如当计算String类型hashCode时,"add"与"dda"有不同返回值更有利于减少冲突。
     ****.另外,数字31有个比较好的特性,就是可以通过位移和减法完成乘法操作(31i = i << 5 -i),这种优化很多虚拟机可以自动完成

将它们运用在hashCode中得到这样的代码:

    @Override
    public int hashCode() {
        int result = 23;
        result = 31 * result + number.hashCode();
        result = 31 * result + Float.hashCode(intimacy);
        result = 31 * result + Boolean.hashCode(sex);
        return result;
    }

它是hashCode近乎完美的一个实现,媲美Java类库中的其他hashCode实现。但是这并不是最先进的,如果的确需要减少hashCode值的冲突,可以考虑使用Guava的com.google.common.hash.Hashing

重写hashCode后,需编码验证相等的实例是否有相同hashCode值(除非使用了AutoValue这样的工具,能保证无误),如果不能则找出问题并改正它。

Objects.hash()

其实,还有一种更偷懒的方式创建hashCode,即使用Objects.hash(Object...),但可惜由于用到了varargs新特性,以及基本类型的自动装箱和拆箱,速度较慢,只建议在对性能不作要求的条件下使用。比如:

    @Override
    public int hashCode() {
        return Objects.hash(arg1 , arg2);
    }

注意事项

对于不可变且hashCode计算成本高的类,考虑在对象中缓存hashCode,仅仅当参数发生改变时才重新计算。如果能确认hashCode会作为key用在HashMap等类中,那么最好在实例创建的时候就把hashCode计算出来,否则就使用延迟加载。

    /**延迟加载的hashCode代码*/
    private int hashCode;

    @Override
    public int hashCode() {
        int result = hashCode;
        if(result == 0){
            result = Integer.hashCode(arg1);
            result = 31 * result + Double.hashCode(arg2);
            result = 31 * result + Short.hashCode(arg3);
            hashCode = result;
        }
        return result;
    }

不可以为了加快hashCode运行的速度就随随便便把什么参数(equals中涉及的)排除在计算范围之外,否则很可能影响HashMap的性能。举个例子,Java1.2中的String.hashCode()仅仅取前16个字符进行hash计算,于是它遇到了URL...

不要对返回值做些什么规定,这样会降低其灵活性而且不利于以后的扩展和修改,虽然Java类库中有不少类已经这么干了,但是小朋友们千万不要模仿他们哦

全代码git地址:点我点我

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值