hashCode、equals 与对象去重机制 | JavaSE
hashCode、equals 与对象去重机制
一、学习目标
完成本章学习后,你应该能够:
- 能够解释
==、equals()、hashCode()三者的职责差异。 - 能够说明
Object.equals()与Object.hashCode()的默认语义。 - 能够完整口述
equals()与hashCode()的核心契约。 - 能够解释 HashSet 为什么需要结合哈希信息和
equals()判断重复。 - 能够为自定义对象正确重写
equals()和hashCode()。 - 能够根据业务唯一性规则选择参与相等性判断的字段。
- 能够解释为什么对象进入 HashSet 后不应该随意修改参与
equals/hashCode的字段。
二、核心知识
2.1 问题从哪里产生
考虑两个学生:
Student s1 = new Student("张三", 20);
Student s2 = new Student("张三", 20);
从业务角度看:
姓名相同
年龄相同
我们可能希望把他们认为是:
同一个逻辑学生
于是:
Set<Student> students = new HashSet<>();
students.add(s1);
students.add(s2);
我们希望最终:
size == 1
但是 Java 怎么知道:
“姓名和年龄一样”就是你定义的重复规则?
它不知道。
因此必须由程序员为类定义:
对象相等性语义。
这就是:
equals()
hashCode()
存在的重要意义。
2.2 == 比较什么
对于基本数据类型:
int a = 10;
int b = 10;
System.out.println(a == b);
== 比较的是值。
但是对于引用类型:
Student s1 = new Student("张三", 20);
Student s2 = new Student("张三", 20);
执行:
s1 == s2
核心判断的是:
两个引用是否指向同一个对象。
逻辑上可以理解为:
s1 ──────→ Student对象A
s2 ──────→ Student对象B
即使:
A.name == B.name
A.age == B.age
只要它们是两个不同对象:
s1 == s2
仍然为:
false
2.3 Object.equals() 默认做什么
所有普通 Java 类最终都继承:
Object
Object 定义了:
public boolean equals(Object obj)
如果一个类没有重写 equals(),那么 Object 的默认实现本质上采用:
引用相等性。
也就是接近:
this == obj
因此:
new Student("张三", 20)
和另一个:
new Student("张三", 20)
默认不会因为字段一样就自动相等。
2.4 为什么 String 可以比较内容
你早期学习过:
String a = new String("Java");
String b = new String("Java");
System.out.println(a.equals(b));
结果会认为两个字符串内容相等。
原因不是:
Java 所有对象默认都会比较内容。
真正原因是:
String类已经重写了equals()。
String 自己定义了字符串的逻辑相等规则。
同理:
Integer
LocalDate
BigDecimal
等类也都有自己的相等性设计。
2.5 我们也可以定义 Student 的相等规则
例如业务规定:
姓名和年龄都相同,就认为两个 Student 是同一个逻辑学生。
那么应该为 Student 重写:
equals()
hashCode()
例如:
import java.util.Objects;
public class Student {
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Student student = (Student) o;
return age == student.age
&& Objects.equals(name, student.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
此时:
Student s1 = new Student("张三", 20);
Student s2 = new Student("张三", 20);
System.out.println(s1.equals(s2));
根据我们定义的业务语义:
true
2.6 equals() 的核心契约
Java 对 equals() 有正式要求。
对于非 null 引用,正确的 equals 应满足以下性质。
自反性(Reflexive)
x.equals(x)
必须为:
true
一个对象应该等于它自己。
对称性(Symmetric)
如果:
x.equals(y)
为 true,那么:
y.equals(x)
也必须为 true。
不能出现:
x 认为 y 是自己人
y 却说不认识 x
传递性(Transitive)
如果:
x.equals(y) == true
y.equals(z) == true
那么:
x.equals(z)
也必须为 true。
一致性(Consistent)
只要参与 equals 判断的信息没有变化:
x.equals(y)
重复调用应该保持一致结果。
与 null 比较
对于任意非 null 对象:
x.equals(null)
必须返回:
false
而不是抛异常或者返回 true。
2.7 hashCode() 是什么
Object 还定义:
public int hashCode()
它返回:
int
类型的哈希码。
哈希码最重要的用途之一,就是服务:
HashMap
HashSet
Hashtable
等哈希数据结构。
2.8 hashCode 的核心契约
需要牢牢记住三条。
第一条:同一个对象要保持一致
同一次 Java 程序执行过程中,只要参与相等性判断的信息没有发生变化:
obj.hashCode()
多次调用应该返回相同的整数。
第二条:equals 相等,hashCode 必须相同
如果:
a.equals(b) == true
那么必须保证:
a.hashCode() == b.hashCode()
记成:
equals 相等
↓
hashCode 必须相等
第三条:hashCode 相同,不代表 equals 相等
即:
a.hashCode() == b.hashCode()
不能推出:
a.equals(b) == true
因为:
哈希碰撞是允许存在的。
因此关系是:
equals 相等
⇒
hashCode 相同
但:
hashCode 相同
⇏
equals 相等
这是集合框架最重要的逻辑之一。
2.9 为什么重写 equals 通常必须同时重写 hashCode
考虑错误设计:
@Override
public boolean equals(Object obj) {
...
}
只重写:
equals()
却没有重写:
hashCode()
于是可能出现:
s1.equals(s2) == true
但是:
s1.hashCode() != s2.hashCode()
这违反了 Java 对象哈希契约。
放进:
HashSet
后,两个逻辑相等对象可能首先被定位到了不同桶。
于是集合的行为就不符合我们设计的逻辑相等语义。
因此:
重写 equals 时,通常必须同时重写 hashCode。
2.10 只重写 hashCode 行不行
也不行。
即使强行让:
s1.hashCode() == s2.hashCode()
HashSet 在哈希信息相同时还必须继续判断:
equals()
如果仍然使用 Object 默认 equals:
不同对象引用
↓
equals = false
那么它们仍然可以被认为是两个不同元素。
所以:
只有 hashCode
不能完成逻辑对象去重。
三、使用方法
3.1 一个完整 Student
假设业务规则:
姓名、年龄、地址、手机号全部相同,视为同一个学生。
可以这样设计:
import java.util.Objects;
public class Student {
private String name;
private int age;
private String address;
private String phone;
public Student(String name, int age, String address, String phone) {
this.name = name;
this.age = age;
this.address = address;
this.phone = phone;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Student student = (Student) o;
return age == student.age
&& Objects.equals(name, student.name)
&& Objects.equals(address, student.address)
&& Objects.equals(phone, student.phone);
}
@Override
public int hashCode() {
return Objects.hash(name, age, address, phone);
}
}
之后:
Set<Student> students = new HashSet<>();
students.add(
new Student("张三", 20, "太原", "13800000000")
);
students.add(
new Student("张三", 20, "太原", "13800000000")
);
按照上述业务相等规则,HashSet 可以把两个对象视为重复。
3.2 equals 第一行为什么经常判断 this == o
经典写法:
if (this == o) {
return true;
}
意思是:
如果两个引用本来就指向同一个对象,当然相等。
例如:
student
│
├──────────────┐
↓ ↓
this o
此时没有必要继续逐字段判断。
3.3 为什么判断 null 和类型
if (o == null || getClass() != o.getClass()) {
return false;
}
首先:
o == null
时:
当前 Student 不可能和 null 相等
其次,如果运行时类型不同:
Student
Teacher
在当前这种严格类型相等设计中,也直接返回 false。
之后才能安全:
Student student = (Student) o;
3.4 为什么 String 字段使用 Objects.equals()
不要直接写:
name.equals(student.name)
因为:
name
可能是:
null
更稳妥的方式:
Objects.equals(name, student.name)
它能够安全处理:
null
情况。
3.5 Objects.hash()
可以使用:
Objects.hash(name, age, address, phone)
帮助组合多个字段生成哈希码。
重点不是死背具体计算算法,而是保证:
与 equals 采用一致的业务字段。
例如 equals 使用:
name + age
而 hashCode 却使用:
phone
这种设计就很危险。
3.6 最关键原则:equals 与 hashCode 字段要保持语义一致
如果你的业务规定:
studentId
唯一标识学生,那么可以围绕:
studentId
建立相等规则。
如果业务规定:
name + age
相同就视为重复,那么:
name
age
就应该同时参与:
equals()
hashCode()
因此没有万能答案:
“Student 到底应该用哪些字段做 equals?”
答案只能由:
业务身份语义(Identity Semantics)
决定。
四、原理与进阶
4.1 HashSet 到底如何判断重复
可以把核心流程简化成:
新元素
↓
计算 hashCode
↓
计算桶位置
↓
找到候选桶
↓
检查桶中已有元素
↓
哈希信息是否匹配?
↓
必要时调用 equals()
↓
是否为逻辑相等对象?
最终:
相等
↓
不保存新元素
或者:
不相等
↓
保存新元素
因此可以理解为:
hashCode
↓
快速缩小搜索范围
equals
↓
最终确认逻辑相等性
两者职责完全不同。
4.2 为什么不能只用 equals
假设集合中有:
1,000,000 个对象
如果每次:
contains(target)
都从第一个对象开始:
equals
equals
equals
equals
...
HashSet 就失去了哈希结构的优势。
因此:
hashCode()
先帮助快速缩小:
“目标大概在哪个桶”
再在候选桶中进一步通过:
equals()
确认是否真正相等。
4.3 为什么不能只用 hashCode
因为:
int
只有有限数量的可能值。
而现实中可以创建的对象状态远远超过这个范围。
从数学上就决定了:
不同对象出现相同 int 哈希码是不可避免的。
因此必须允许:
Hash Collision
再通过:
equals()
消除歧义。
4.4 一个非常重要的反例
假设:
class Student {
private String name;
private int age;
@Override
public int hashCode() {
return 1;
}
}
所有 Student:
hashCode = 1
这并没有违反:
不同对象允许 hashCode 相同
这一规则。
但是它会制造极其严重的:
哈希碰撞
从而严重破坏哈希表性能。
因此:
合法的 hashCode 实现不一定是高质量的 hashCode 实现。
好的哈希函数应该尽量让不同对象:
均匀分散
4.5 为什么 IDE 能自动生成 equals/hashCode
IntelliJ IDEA 可以帮助生成:
equals()
hashCode()
这当然应该使用。
真正需要程序员自己决定的是:
哪些字段表示对象身份?
例如 User:
id
username
email
nickname
password
avatar
应该使用所有字段吗?
不一定。
如果:
id
才是真正的唯一身份,那么头像变化显然不应该让:
“同一个用户”
突然变成:
“另一个用户”
所以工具只能帮你:
写代码。
不能代替你:
定义业务语义。
4.6 放入 HashSet 后修改对象为什么危险
考虑:
Student student = new Student("张三", 20);
Set<Student> set = new HashSet<>();
set.add(student);
假设:
name + age
参与:
equals()
hashCode()
之后修改:
student.setAge(21);
那么对象当前:
hashCode()
可能已经变化。
但它在 HashSet 内部原来的存储位置,是按照之前的哈希状态确定的。
此时再执行:
set.contains(student);
集合可能按照:
新的 hash
前往另一个桶寻找。
于是产生:
对象明明还在集合中
却可能无法按正常哈希查找路径找到
这种极其隐蔽的问题。
4.7 Set 对可变元素的约束
因此,非常重要的工程原则是:
对象作为 HashSet 元素后,不应该随意修改参与 equals/hashCode 的字段。
类似原则也适用于:
HashMap 的 key
因此很多适合作为:
Set 元素
Map key
的对象,会尽量采用稳定、不可变的身份数据。
4.8 equals 与继承
相等性设计遇到继承时会变得更加复杂。
例如:
Person
↑
Student
如果父类和子类分别使用不同字段设计 equals,很容易破坏:
对称性
传递性
因此:
getClass() != o.getClass()
与:
o instanceof Student
并不是机械意义上谁永远更好。
它取决于:
类的继承语义是否允许跨类型相等。
JavaSE 当前阶段首先掌握:
- equals 契约;
- hashCode 契约;
- 常规实体对象正确实现;
即可。
复杂继承模型中的相等性设计属于进一步工程问题。
五、实践应用
5.1 用户去重
假设:
username
定义用户唯一身份。
那么 User 的相等规则可以围绕:
username
建立。
此时:
HashSet<User>
能够直接完成用户逻辑去重。
5.2 商品唯一性
例如电商系统中:
skuCode
代表具体商品 SKU。
那么:
skuCode
可能比:
商品名称
商品价格
商品描述
更加适合作为业务身份。
因为:
价格改变
不应该意味着商品突然变成一个全新的身份。
5.3 学号去重
学生系统中:
studentNo
通常非常适合表达学生身份。
如果学号唯一,那么:
equals()
hashCode()
围绕:
studentNo
设计可能比:
姓名 + 年龄
更加合理。
因为现实中:
姓名相同
年龄相同
完全可能是两个不同学生。
这说明:
技术正确不等于业务正确。
5.4 数据库主键与 Java 对象身份
在数据库系统中,我们经常会遇到:
id
字段。
于是 Java 实体对象到底应该使用:
数据库 id
业务唯一键
所有字段
中的哪些字段定义 equals/hashCode,就成为真实项目中的设计问题。
不能机械使用:
“所有字段全部参与。”
要根据:
- 对象生命周期;
- ID 什么时候生成;
- 是否存在业务唯一键;
- 对象是否会修改;
- 是否作为 HashMap key / HashSet element;
综合判断。
这也是集合知识真正进入工程实践的地方。
六、常见问题
6.1 == 和 equals 是一回事吗?
不是。
对于引用类型:
==
主要判断:
是否为同一个对象引用
而:
equals()
可以被类重写,用于表达:
逻辑相等
6.2 equals 相等时 hashCode 一定相同吗?
按照 Java 契约:
必须相同。
即:
equals = true
⇒
hashCode 相同
6.3 hashCode 相同是否表示 equals 相等?
不是。
因为存在:
哈希碰撞
所以:
hashCode 相同
⇏
equals 相等
6.4 为什么必须同时重写两个方法?
因为:
equals()
定义:
两个对象什么时候在逻辑上相等。
而:
hashCode()
服务于:
哈希数据结构快速定位。
二者必须遵守一致契约。
6.5 equals 中是不是字段越多越好?
不是。
应该由:
业务身份定义
决定。
例如用户昵称改变,并不一定意味着这个用户成为了另一个人。
6.6 能不能让 hashCode 永远 return 1?
从“相等对象必须拥有相同哈希码”的最低契约上,它并不会因此直接违法。
但是:
return 1;
会导致几乎所有对象发生哈希碰撞。
最终严重降低:
HashSet
HashMap
的性能。
所以:
契约正确只是最低标准,良好的哈希分布同样重要。
6.7 Objects.hash 是否等于“绝对不会碰撞”?
不是。
无论使用什么正常哈希算法:
碰撞都可能存在
Objects.hash() 只是帮助根据多个值生成符合常见需求的哈希结果,并不能创造无限数量的唯一 int。
6.8 对象放入 HashSet 后还能修改吗?
不是所有字段都绝对不能改。
真正危险的是:
修改参与 equals/hashCode 的字段。
如果修改与对象身份无关、不参与哈希计算的普通字段,通常不会改变 HashSet 的定位语义。
6.9 为什么 String 能直接在 HashSet 中正确去重?
因为 String 已经正确实现了:
equals()
hashCode()
并且 String 本身是不可变对象。
因此非常适合作为:
HashSet 元素
HashMap key
七、练习与验收
7.1 知识问答
- 引用类型的
==主要比较什么? Object.equals()默认采用什么相等语义?- 为什么 String 的 equals 可以比较字符串内容?
- equals 有哪些基本契约?
- hashCode 返回什么类型?
- hashCode 有哪些核心契约?
- 为什么 equals 相等必须 hashCode 相同?
- 为什么 hashCode 相同不代表 equals 相等?
- 什么是哈希碰撞?
- 为什么重写 equals 通常必须同时重写 hashCode?
- 只重写 hashCode 是否能够完成逻辑去重?
- equals/hashCode 应该选择哪些字段?
- 为什么不能机械地让所有字段参与 equals?
- 为什么 HashSet 元素的身份字段不应该随意修改?
Objects.equals()与直接调用a.equals(b)在 null 安全方面有什么区别?
7.2 代码阅读
不运行代码:
public class Student {
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
}
然后:
Student s1 = new Student("张三", 20);
Student s2 = new Student("张三", 20);
Set<Student> set = new HashSet<>();
set.add(s1);
set.add(s2);
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
System.out.println(set.size());
回答:
s1 == s2应如何分析?- Student 没有重写 equals 时调用的是谁的 equals?
- 默认 equals 如何判断?
- HashSet 最终可能保存几个对象?
- 如果希望内容相同的 Student 只保存一个,需要修改什么?
7.3 手写代码
任务一:Student 去重
定义:
Student
├── name
├── age
├── address
└── phone
规定:
四个字段完全相同视为重复。
要求:
- 手写
equals(); - 手写
hashCode(); - 创建两个内容相同对象;
- 加入 HashSet;
- 验证集合大小。
不要使用 IDE 自动生成完成第一次练习。
任务二:学号唯一
重新设计 Student:
studentNo
name
age
major
业务规定:
studentNo 相同
就是同一个学生。
要求:
- 重新设计 equals/hashCode;
- 姓名、年龄不同但学号相同的两个对象应如何处理?
- 解释为什么这种业务设计可能比“所有字段相等”更加合理。
任务三:用户去重
定义:
User
├── username
├── password
├── nickname
└── avatar
规定:
username
唯一。
要求:
- 设计 equals/hashCode;
- 修改 nickname 后分析对象是否仍应该是同一用户;
- 修改 username 后分析为什么可能影响 HashSet。
7.4 Debug
下面代码存在严重设计问题:
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Student)) {
return false;
}
Student other = (Student) obj;
return age == other.age
&& Objects.equals(name, other.name);
}
@Override
public int hashCode() {
return System.identityHashCode(this);
}
回答:
- equals 使用什么字段判断?
- 两个 equals 相等对象的 hashCode 是否一定相同?
- 违反了什么契约?
- 为什么放入 HashSet 后可能导致逻辑重复?
- 应如何修复?
7.5 综合训练
设计“课程选课学生集合”。
要求:
一个学生包含:
studentNo
name
age
major
phone
业务要求:
学号相同表示同一个学生。
使用:
HashSet<Student>
保存选课学生。
程序需要支持:
- 学生选课;
- 重复选课拒绝;
- 查询某学生是否已经选课;
- 取消选课;
- 查看当前选课人数。
完成后回答:
- 哪个字段应该参与 equals/hashCode?
- 为什么姓名不应该作为唯一判断依据?
- 为什么手机号是否参与需要根据业务规则分析?
- 修改学生姓名是否应该破坏 HashSet 查询?
- 修改 studentNo 为什么危险?
7.6 本章验收
关闭资料和 AI 自动补全:
- [ ] 能准确解释
==、equals()、hashCode()。 - [ ] 能口述 equals 的自反、对称、传递、一致和 null 规则。
- [ ] 能口述 hashCode 的核心契约。
- [ ] 能写出“equals 相等 ⇒ hashCode 相同”。
- [ ] 不会写成“hashCode 相同 ⇒ equals 相等”。
- [ ] 能解释哈希碰撞。
- [ ] 能解释为什么必须协同设计 equals/hashCode。
- [ ] 能从零为 Student 重写 equals/hashCode。
- [ ] 能根据业务语义选择身份字段。
- [ ] 能解释为什么 HashSet 元素的身份字段不应该随意修改。
- [ ] 能解释 HashSet 如何利用 hashCode 与 equals 完成对象去重。