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 知识问答

  1. 引用类型的 == 主要比较什么?
  2. Object.equals() 默认采用什么相等语义?
  3. 为什么 String 的 equals 可以比较字符串内容?
  4. equals 有哪些基本契约?
  5. hashCode 返回什么类型?
  6. hashCode 有哪些核心契约?
  7. 为什么 equals 相等必须 hashCode 相同?
  8. 为什么 hashCode 相同不代表 equals 相等?
  9. 什么是哈希碰撞?
  10. 为什么重写 equals 通常必须同时重写 hashCode?
  11. 只重写 hashCode 是否能够完成逻辑去重?
  12. equals/hashCode 应该选择哪些字段?
  13. 为什么不能机械地让所有字段参与 equals?
  14. 为什么 HashSet 元素的身份字段不应该随意修改?
  15. 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());

回答:

  1. s1 == s2 应如何分析?
  2. Student 没有重写 equals 时调用的是谁的 equals?
  3. 默认 equals 如何判断?
  4. HashSet 最终可能保存几个对象?
  5. 如果希望内容相同的 Student 只保存一个,需要修改什么?

7.3 手写代码

任务一:Student 去重

定义:

Student
├── name
├── age
├── address
└── phone

规定:

四个字段完全相同视为重复。

要求:

  1. 手写 equals()
  2. 手写 hashCode()
  3. 创建两个内容相同对象;
  4. 加入 HashSet;
  5. 验证集合大小。

不要使用 IDE 自动生成完成第一次练习。


任务二:学号唯一

重新设计 Student:

studentNo
name
age
major

业务规定:

studentNo 相同

就是同一个学生。

要求:

  1. 重新设计 equals/hashCode;
  2. 姓名、年龄不同但学号相同的两个对象应如何处理?
  3. 解释为什么这种业务设计可能比“所有字段相等”更加合理。

任务三:用户去重

定义:

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

回答:

  1. equals 使用什么字段判断?
  2. 两个 equals 相等对象的 hashCode 是否一定相同?
  3. 违反了什么契约?
  4. 为什么放入 HashSet 后可能导致逻辑重复?
  5. 应如何修复?

7.5 综合训练

设计“课程选课学生集合”。

要求:

一个学生包含:

studentNo
name
age
major
phone

业务要求:

学号相同表示同一个学生。

使用:

HashSet<Student>

保存选课学生。

程序需要支持:

  • 学生选课;
  • 重复选课拒绝;
  • 查询某学生是否已经选课;
  • 取消选课;
  • 查看当前选课人数。

完成后回答:

  1. 哪个字段应该参与 equals/hashCode?
  2. 为什么姓名不应该作为唯一判断依据?
  3. 为什么手机号是否参与需要根据业务规则分析?
  4. 修改学生姓名是否应该破坏 HashSet 查询?
  5. 修改 studentNo 为什么危险?

7.6 本章验收

关闭资料和 AI 自动补全:

  • [ ] 能准确解释 ==equals()hashCode()
  • [ ] 能口述 equals 的自反、对称、传递、一致和 null 规则。
  • [ ] 能口述 hashCode 的核心契约。
  • [ ] 能写出“equals 相等 ⇒ hashCode 相同”。
  • [ ] 不会写成“hashCode 相同 ⇒ equals 相等”。
  • [ ] 能解释哈希碰撞。
  • [ ] 能解释为什么必须协同设计 equals/hashCode。
  • [ ] 能从零为 Student 重写 equals/hashCode。
  • [ ] 能根据业务语义选择身份字段。
  • [ ] 能解释为什么 HashSet 元素的身份字段不应该随意修改。
  • [ ] 能解释 HashSet 如何利用 hashCode 与 equals 完成对象去重。