最近在审查小伙伴的代码时,发现一个不起眼但是会引起大问题的事情。再加上,很久以前,写过一篇博文 实体类为什么要写.toString()方法?,本次的问题,跟这一类,也差不多。
早在 Effective Java 第三版中所提到的每个覆盖了equals方法的类中,必须覆盖hashCode。如果不这么做,就违背了hashCode的通用约定,后来在翻开阿里巴巴规范手册时,发现也被指出来了:只要重写 equals,就必须重写 hashCode。

估计很多人都会问为什么?今天就具体来说一说,这个小细节,最后还有一个关于Java8中stream 使用 distinct去重的问题。
在java语言中,hashCode 、equals 是Object类的方法,因此,所有用户编写的类都默认拥有这2个方法。
也就是说,如果你新建的类,不重写这两个方法,当做比较时,它就会自动调用父类Object的hashCode 、equals 方法。
hashCode() 方法的作用是: 获取当前对象的内存地址。返回的是一个int整数(哈希码)。不同对象调用hashCode返回的值往往是不同的。

还记得我们说,对于引用类型的对象,比较的是两者的内存地址吗?
equals()方法的作用主要是:用来判断两个对象是否相等。

可以看到,Object类的equals方法是用 “ == ” 号来进行比较。
而在java中,因为 “==” 号比较的是两个对象的内存地址而不是实际的值,这就意味着当两个对象地址值不一致时,就不是同一个对象,就返回为false。
这种判断方式本质上没错,但可能是不符合实际场景需求。
上面我们说了,单纯用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()方法是不是就可以了?
在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()方法就可以比较两个对象是否相等了,为什么诸多权威手册,规范约束,都还要求重写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不同则必定不相等,就不会再往下判定。)
源码分析:

而 我们 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()比较了。
那么有些人看到这里,就会问,如果两个对象返回的哈希码都是一样的话,是不是就一定相等?答案是不一定的。
上面说了,不重写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下。
所以,看上面源码,HashSet、HashMap判断哈希码相等后还会再用equals()方法来判断,这样就大大的提升了对象比较的效率,这也是为什么 Java 设计使用 hashCode 和 equals 协同的方式,来确认两个对象是否相等的原因。
equals 和 hashCode 都可以用来判断两个对象相不相等的,那么问题来了,为什么要设计两个呢?
原因是 “ 性能 ”。
也就说,一个是保证了可靠,一个是保证了性能。
因此,在Java关键约定中,要求:两个相等的对象必须具有相等的散列码(哈希码)。
因为java中基于散列的集合实现了这个约定,所以当你的类在集合这样的数据结构中使用,也需要遵守这种约定。
同一个对象的hashCode一定相等,不同对象的hashCode也可能相等。【毕竟hashCode是根据内存地址hash出来的一个int 32 位的整型数,相等冲突也不是不可能】。
equals比较的是两个对象的地址,同一个对象地址肯定相同,不同的对象地址一定不同。
说了那么多,总结就是:
回到最初的论点,记住,只要重写 equals,就必须重写 hashCode,这是一个很重要的细节,如果不注意的话,很容易发生业务上的错误。
比如有时候我们明明用了HashSet,distinct() 等去重,但是不生效,这时就该回头看看你的对象实体类,是否重写了equals()和hashCode()方法了吗?
下一篇,我们就来看看业务上真实遇到的equals和hashCode引起的问题。