• 老生常谈,equals和hashCode的暗操作


    老生常谈,equals和hashCode的暗操作

    最近在审查小伙伴的代码时,发现一个不起眼但是会引起大问题的事情。再加上,很久以前,写过一篇博文 实体类为什么要写.toString()方法?,本次的问题,跟这一类,也差不多。

    早在 Effective Java 第三版中所提到的每个覆盖了equals方法的类中,必须覆盖hashCode。如果不这么做,就违背了hashCode的通用约定,后来在翻开阿里巴巴规范手册时,发现也被指出来了:只要重写 equals,就必须重写 hashCode。
    关于重写
    估计很多人都会问为什么?今天就具体来说一说,这个小细节,最后还有一个关于Java8中stream 使用 distinct去重的问题。

    关于equals和hashCode方法

    在java语言中,hashCode 、equals 是Object类的方法,因此,所有用户编写的类都默认拥有这2个方法。

    也就是说,如果你新建的类,不重写这两个方法,当做比较时,它就会自动调用父类Object的hashCode 、equals 方法。

    hashCode方法

    hashCode() 方法的作用是: 获取当前对象的内存地址。返回的是一个int整数(哈希码)。不同对象调用hashCode返回的值往往是不同的。

    在这里插入图片描述
    还记得我们说,对于引用类型的对象,比较的是两者的内存地址吗?

    equals方法

    equals()方法的作用主要是:用来判断两个对象是否相等。
    在这里插入图片描述
    可以看到,Object类的equals方法是用 “ == ” 号来进行比较。

    而在java中,因为 “==” 号比较的是两个对象的内存地址而不是实际的值,这就意味着当两个对象地址值不一致时,就不是同一个对象,就返回为false。

    这种判断方式本质上没错,但可能是不符合实际场景需求。

    不重写equals()方法有何影响?

    上面我们说了,单纯用Object类的equals方法来比较两个对象是否相等,是不符合实际需求的,为什么呢?

    举个例子,在两家不同的商店里都卖同款矿泉水,但是因为地址值不同,在使用equals做判断时,这两个商店的矿泉水就会返回不相同false的认定结果,这显示是不符合预期的,因此在实际开发中我们往往需要重写实体类的equals方法。

    假设有一个用户实体类,我们不重写其equals()方法。

    import lombok.AllArgsConstructor;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import lombok.Setter;
    
    /**
     * 用户实体
     *
     * @author zoutao
     * @version 1.0
     * @date 2022/9/20
     */
    @NoArgsConstructor
    @AllArgsConstructor
    @Setter
    @Getter
    public class CurrentUserDto {
    
        @ApiModelProperty(value = "用户Id")
        private long userId;
    
        @ApiModelProperty(value = "账号")
        private String account;
    
        @ApiModelProperty(value = "姓名")
        private String userName;
    
        @ApiModelProperty(value = "性别,0:未知;1:男;2:女")
        private String sex;
    
        @ApiModelProperty(value = "电话号码")
        private String phone;
    }
    

    现在,构建3个对象,来比较,看看会怎样?

    public static void main(String[] args) {
    	
       CurrentUserDto user1 = new CurrentUserDto(10070,"lt3","李婷3","男","18174183333");
       CurrentUserDto user2 = new CurrentUserDto(10071,"lt4","李婷4","女","18174183333");
       CurrentUserDto user3 = new CurrentUserDto(10070,"lt3","李婷3","男","18174183333");
    
    	//对比user1 和user2 
       System.out.println("判断两个对象是否相等:"+user1.equals(user2));
       System.out.println("user1对象的hash值:"+user1.hashCode());
       System.out.println("user2对象的hash值:"+user2.hashCode());
       System.out.println("判断两个对象的hash值是否相等:"+ (user1.hashCode() == user2.hashCode()));
    
    	//对比user1 和user3
       System.out.println("判断两个对象是否相等:"+user1.equals(user3));
       System.out.println("user1对象的hash值:"+user1.hashCode());
       System.out.println("user2对象的hash值:"+user3.hashCode());
       System.out.println("判断两个对象的hash值是否相等:"+ (user1.hashCode() == user3.hashCode()));
    }
    

    运行测试,所得结果:

    判断两个对象是否相等:false
    user1对象的hash值:548246552
    user2对象的hash值:835648992
    判断两个对象的hash值是否相等:false
    
    判断两个对象是否相等:false
    user1对象的hash值:548246552
    user2对象的hash值:1134517053
    判断两个对象的hash值是否相等:false
    

    user1和user2不相等可以理解,user1和user3 明明参数都一样,为何也说不相等呢?

    原因是在自定义的类CurrentUserDto中,不重写equals方法则会调用Object类的equals方法。

    而Object类的equals方法是用“==”号进行比较,比的是两个对象的内存地址而不是实际的值,user1 和user3 都是我们new 出来的,他们拥有不同的地址,所以被判断为不相等!

    (所以,这显然,已经不符合正常的人为预期了,同样的成员属性了,还不相等)。

    结论:
    • 自建类不重写equals,比较时就会默认采用父类Object类的equals方法,比地址。
    • 自建类不重写equals,相同成员的对象,也会被误判为不相等!

    重写equals()方法就可以比较两个对象是否相等?

    那么,我重写equals()方法是不是就可以了?

    在CurrentUserDto类中,重写equals()方法。

    import java.util.Objects;
    
    public class CurrentUserDto {
    
       //...其他不变
    
          @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            CurrentUserDto that = (CurrentUserDto) o;
            return userId == that.userId && Objects.equals(account, that.account) && Objects.equals(userName, that.userName) && Objects.equals(sex, that.sex) && Objects.equals(phone, that.phone);
        }
    }
    

    再次运行,测试方法:

    判断两个对象是否相等:false
    user1对象的hash值:548246552
    user2对象的hash值:835648992
    判断两个对象的hash值是否相等:false
    
    判断两个对象是否相等:true
    user1对象的hash值:548246552
    user2对象的hash值:1134517053
    判断两个对象的hash值是否相等:false
    

    可以看到,user1 和 user3 已相等(虽然hash值不相等,但是好像也没啥影响了?)

    结论:
    • 自建类重写equals,则相同成员的对象,会正确判定为相等!但hash值不相同。

    重写equals不重写hashCode会怎样?

    从上面来看,貌似只重写equals()方法就可以比较两个对象是否相等了,为什么诸多权威手册,规范约束,都还要求重写hashcode() 方法呢?

    首先要知道hashCode的意义?

    学过数据结构的还记得吗?——哈希码的作用是确定对象在哈希表的索引下标。
    比如HashSet、HashMap这种,就是使用了hashCode() 来确定索引下标,这也是为什么HashMap效率高的部分原因。

    所以,hashCode用于散列数据的快速存取,当利用HashSet/HashMap/Hashtable类来存储数据时,利用下标来快速定位,不需要遍历。

    接下来我们就看看,在CurrentUserDto 类中,只重写了equals方法,没有重写hashCode方法,然后放入HashSet中去重测试。

        public static void main(String[] args) {
        
            CurrentUserDto user1 = new CurrentUserDto(10070,"lt3","李婷3","男","18174183333");
            CurrentUserDto user2 = new CurrentUserDto(10071,"lt4","李婷4","女","18174183333");
            CurrentUserDto user3 = new CurrentUserDto(10070,"lt3","李婷3","男","18174183333");
            
            HashSet<CurrentUserDto> userSet = new HashSet<>();
            userSet.add(user1);
            userSet.add(user2);
            userSet.add(user3);
            System.out.println("set中对象数量:"+userSet.size());
            userSet.forEach(System.out::println);
        }
    

    输出:

    set中对象数量:3
    user1对象的hash值:2073595499
    user2对象的hash值:2074329424
    user3对象的hash值:2071474127
    com.zoutao.usercenter.dto.CurrentUserDto@439f5b3d
    com.zoutao.usercenter.dto.CurrentUserDto@20ad9418
    com.zoutao.usercenter.dto.CurrentUserDto@31cefde0
    

    采用HashSet存储user对象时,发现相同对象user1 和user3 并没有被去重掉。(显然,这种操作放到工作环境中,就会出现莫名多出数据的情况),而且就算是相同对象的hash值也不同。

    为什么,不重写hashCode方法,HashSet就会无法正确去重呢?

    因为HashSet、HashMap 等,它们底层在添加元素时,会先判断对象的hashCode是否相等,如果hashCode相等才会用equals()方法比较是否相等。

    (也就是说,HashSet和HashMap在判断两个元素是否相等时,会先判断hashCode,如果两个对象的hashCode不同则必定不相等,就不会再往下判定。)

    源码分析:

    put源码

    而 我们 new 出来的这两个属性相同的对象user1 和user3 , (哈希码)地址肯定是不同的,因此,HashSet误判它们都是不同的数据,进而不处理!

    现在,我们在CurrentUserDto 中新增重写hashCode方法,再做去重测试:

    public class CurrentUserDto {
    
        //...其他不变
        
        @Override
        public int hashCode() {
            return Objects.hash(userId, account, userName, sex, phone);
        }
    }
    

    运行测试,输出结果

    set中对象数量:2
    user1对象的hash值:2073595499
    user3对象的hash值:2073595499
    com.zoutao.usercenter.dto.CurrentUserDto@7b988e6b
    com.zoutao.usercenter.dto.CurrentUserDto@7ba3c150
    

    发现结果正常了,HashSet只会存储user1和user3中的一个,加上user2。(还会发现hash值一样了)。

    究其原因在于HashSet会先判断hashCode是否相等,如果hashCode不相等就直接认为两个对象不相等,不会再用equals()比较了。

    结论:
    • 1.重写equals方法时,不重写hashCode方法,当遇到集合类操作时,会出现错误!
    • 2.不重写hashCode方法的类,就算成员变量都相同的对象的hashCode也会不同。
    • 3.重写了hashCode方法的类,成员变量都相同的对象的hashCode都相同。

    那么有些人看到这里,就会问,如果两个对象返回的哈希码都是一样的话,是不是就一定相等?答案是不一定的。

    重写hashCode 不重写equals又会怎样?

    上面说了,不重写equals也不好,只重写equals也不好,那么反过来,只重写hashCode方法,那么相同属性的对象是不是就相等了?

    直接注释掉,CurrentUserDto中的equals方法,只保留hashCode方法,执行测试方法,输出结果如下:

    判断两个对象是否相等:false
    user1对象的hash值:2073595499
    user3对象的hash值:2073595499
    判断两个对象的hash值是否相等:true
    
    set中对象数量:3
    com.zoutao.usercenter.dto.CurrentUserDto@7b988e6b
    com.zoutao.usercenter.dto.CurrentUserDto@7ba3c150
    com.zoutao.usercenter.dto.CurrentUserDto@7b988e6b
    
    结论

    只写hashCode 不重写equals下。

    • 1.两个成员属性一样的对象,hash值会相同,但equals比较会不同(明明user1和user3写得一模一样)。
    • 2.利用HashSet存储,也会无法去重,引发异常。

    所以,看上面源码,HashSet、HashMap判断哈希码相等后还会再用equals()方法来判断,这样就大大的提升了对象比较的效率,这也是为什么 Java 设计使用 hashCode 和 equals 协同的方式,来确认两个对象是否相等的原因。

    都可用来判断对象相等,为何这样设计?

    equals 和 hashCode 都可以用来判断两个对象相不相等的,那么问题来了,为什么要设计两个呢?

    原因是 “ 性能 ”。

    • equals - 保证比较的对象是否是绝对相等的,当遇到集合类对象时要遍历,效率会低。
    • hashCode - 保证在最快的时间内判断两个对象是否相等,但可能有误差。效率高。

    也就说,一个是保证了可靠,一个是保证了性能

    因此,在Java关键约定中,要求:两个相等的对象必须具有相等的散列码(哈希码)。
    因为java中基于散列的集合实现了这个约定,所以当你的类在集合这样的数据结构中使用,也需要遵守这种约定。

    同一个对象的hashCode一定相等,不同对象的hashCode也可能相等。【毕竟hashCode是根据内存地址hash出来的一个int 32 位的整型数,相等冲突也不是不可能】。

    equals比较的是两个对象的地址,同一个对象地址肯定相同,不同的对象地址一定不同。

    大总结

    说了那么多,总结就是:

    • equals 为 true , hashCode 必须相等。
    • hashCode 相等时 , equals 不一定为 true,判完hash记得判equals 。
    • hashCode 不相等,则两个对象一定不相同。
    • 两个对象相同,则哈希码和值都一定相等。

    回到最初的论点,记住,只要重写 equals,就必须重写 hashCode,这是一个很重要的细节,如果不注意的话,很容易发生业务上的错误。

    比如有时候我们明明用了HashSet,distinct() 等去重,但是不生效,这时就该回头看看你的对象实体类,是否重写了equals()和hashCode()方法了吗?

    下一篇,我们就来看看业务上真实遇到的equals和hashCode引起的问题

  • 相关阅读:
    【工具篇】Unity翻书效果的三种方式
    ONNX 转换成 ncnn
    javafx-如何在项目中使用多个main函数
    设计模式19-状态模式
    OpenAI 笔记:获取embedding
    什么时候使用继承,好莱坞原则(设计模式与开发实践 P11+)
    【附源码】计算机毕业设计SSM视频网站
    【回归预测-PNN分类】基于粒子群算法群优化概率神经网络算法实现空气质量评价预测附matlab代码
    springcloud面试题及答案
    C2_W3_Assignment_吴恩达_中英_Pytorch
  • 原文地址:https://blog.csdn.net/ITBigGod/article/details/126951972