Java 中 equals 和 hashCode 详解
一、引言
在 Java 编程中,equals() 和 hashCode() 是两个基础但极其重要的方法。它们定义在 Object 类中,是所有 Java 对象的基石。正确理解并重写这两个方法,对于集合框架的正确使用(如 HashMap、HashSet、Hashtable)、对象比较逻辑的实现以及性能优化都至关重要。
本文将深入探讨:
equals()与==的区别equals()方法的契约hashCode()方法的契约- 为什么必须同时重写
equals()和hashCode() - 哈希碰撞及其处理
- IDE 自动生成代码
- 最佳实践与常见陷阱
二、equals() 与 == 的区别
2.1 == 运算符
== 运算符比较的是内存地址(引用)。对于基本类型(int、boolean 等),它比较的是值;对于引用类型,它比较的是两个引用是否指向堆内存中的同一个对象。
String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2); // false — 两个不同的对象
2.2 equals() 方法
equals() 方法比较的是对象的内容(逻辑相等)。Object 类中的默认实现等价于 ==:
// Object 类中的默认实现
public boolean equals(Object obj) {
return (this == obj);
}
但许多标准类(如 String、Integer、Date 等)已经重写了 equals(),使其比较的是对象的内容而非引用:
String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1.equals(s2)); // true — 内容相同
2.3 对比总结
| 比较方式 | 基本类型 | 引用类型 |
|---|---|---|
== |
比较值 | 比较内存地址(是否是同一个对象) |
equals() |
不适用(编译错误) | 比较内容(默认同 ==,可重写) |
三、equals() 方法的契约
Java 语言规范要求 equals() 方法必须满足以下五个性质,这些约定定义在 Object 类的 JavaDoc 中:
3.1 自反性(Reflexive)
对于任何非空引用 x,x.equals(x) 必须返回 true。
// 一个对象必须等于它自己
Person p = new Person("Alice", 25);
assert p.equals(p); // 必须为 true
3.2 对称性(Symmetric)
对于任何非空引用 x 和 y,如果 x.equals(y) 返回 true,那么 y.equals(x) 必须返回 true。
// ❌ 违反对称性的例子
public class Person {
private String name;
@Override
public boolean equals(Object o) {
if (o instanceof Person) {
return name.equalsIgnoreCase(((Person) o).name);
}
if (o instanceof String) {
return name.equalsIgnoreCase((String) o);
}
return false;
}
}
Person p = new Person("Alice");
String s = "alice";
System.out.println(p.equals(s)); // true
System.out.println(s.equals(p)); // false — String 不认识 Person,违反对称性!
3.3 传递性(Transitive)
对于任何非空引用 x、y 和 z,如果 x.equals(y) 返回 true 且 y.equals(z) 返回 true,那么 x.equals(z) 必须返回 true。
// ❌ 违反传递性的例子(继承场景)
class Point {
private int x, y;
// equals 基于坐标比较
}
class ColoredPoint extends Point {
private Color color;
// equals 基于坐标 + 颜色比较
}
Point p = new Point(1, 2);
ColoredPoint cp1 = new ColoredPoint(1, 2, Color.RED);
ColoredPoint cp2 = new ColoredPoint(1, 2, Color.BLUE);
p.equals(cp1); // true (仅比较坐标)
p.equals(cp2); // true (仅比较坐标)
cp1.equals(cp2); // false (颜色不同) — 违反传递性!
解决方案:在继承体系中,建议使用组合而非继承,或者遵循"如果两个类有 equals 逻辑,则不能存在继承关系"的原则。Effective Java 推荐的做法是:在重写 equals 时,不要使 equals 方法依赖于不可靠的资源,且如果在子类中添加了新的值组件,则不要重写 equals(让子类继承父类的 equals 逻辑)。
3.4 一致性(Consistent)
对于任何非空引用 x 和 y,只要 equals() 比较中使用的信息没有被修改,多次调用 x.equals(y) 必须始终返回相同的结果。
// ❌ 违反一致性 — equals 依赖于可变状态
public class Person {
private String name;
private int age; // 年龄可能变化
@Override
public boolean equals(Object o) {
// 依赖 age 字段
return age == ((Person) o).age && name.equals(((Person) o).name);
}
}
最佳实践:不要使 equals() 依赖于不可靠或可变的状态(如 Date 对象、可变的集合等)。一旦对象被放入 HashSet 或作为 HashMap 的键,就不应修改参与 equals() 比较的字段。
3.5 非空性(Non-null)
对于任何非空引用 x,x.equals(null) 必须返回 false。
// ✅ 正确的 null 检查
@Override
public boolean equals(Object o) {
if (o == null) {
return false;
}
if (getClass() != o.getClass()) {
return false;
}
// ... 比较字段
}
注意:不能抛出 NullPointerException,而应优雅地返回 false。使用 instanceof 运算符可以自动处理 null 检查:
// instanceof 会在左侧为 null 时返回 false,所以下面的写法更简洁且安全
@Override
public boolean equals(Object o) {
if (!(o instanceof Person)) {
return false;
}
// ... 比较字段
}
四、hashCode() 方法的契约
hashCode() 返回一个整型哈希值,用于在基于哈希的集合(如 HashMap、HashSet、Hashtable)中快速定位对象。Object 类的 JavaDoc 定义了以下约定:
4.1 一致性
在 Java 程序执行期间,只要 equals() 比较中使用的信息没有被修改,对同一对象多次调用 hashCode() 必须返回相同的整数值。这一值在同一程序的多次执行间不必保持一致。
4.2 equals 相等则 hashCode 必须相等
如果两个对象根据 equals(Object) 方法相等,那么对这两个对象调用 hashCode() 必须产生相同的整数结果。
// ✅ 符合约定
Person p1 = new Person("Alice", 25);
Person p2 = new Person("Alice", 25);
p1.equals(p2); // true
p1.hashCode(); // 必须等于
p2.hashCode(); //
4.3 equals 不等则 hashCode 不要求不等(但推荐)
如果两个对象根据 equals(Object) 方法不相等,那么对这两个对象调用 hashCode() 不要求产生不同的整数结果。但是,为不相等的对象产生不同的哈希值可以提高哈希表的性能(减少碰撞)。
简言之:equals 相等 → hashCode 必须相等;equals 不等 → hashCode 最好不等。
五、为什么必须同时重写 equals() 和 hashCode()?
这是 Java 面试中最经典的问题之一。答案很简单:如果不这样做,基于哈希的集合(HashMap、HashSet、Hashtable)将无法正常工作。
5.1 违反约定的后果
假如我们只重写 equals() 而不重写 hashCode():
public class Student {
private String id;
private String name;
public Student(String id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Student)) return false;
Student s = (Student) o;
return id.equals(s.id);
}
// ⚠️ 没有重写 hashCode()
}
// 使用 HashSet
Set<Student> set = new HashSet<>();
set.add(new Student("001", "Alice"));
System.out.println(set.contains(new Student("001", "Bob"))); // false!
为什么是 false?流程如下:
- 调用
new Student("001", "Bob").hashCode()— 使用Object默认的hashCode(),返回基于内存地址的哈希值 - HashSet 根据这个哈希值定位到某个哈希桶
- 在该桶中遍历元素调用
equals()比较 - 但由于
"Alice"对象和"Bob"对象的哈希值不同(不同的内存地址),它们被放到了不同的桶中 - 所以根本不会比较
equals()— 直接返回false!
5.2 反过来:只重写 hashCode() 而不重写 equals()
public class Student {
private String id;
@Override
public int hashCode() {
return id.hashCode();
}
// ⚠️ 没有重写 equals()
}
Set<Student> set = new HashSet<>();
Student s1 = new Student("001");
Student s2 = new Student("001");
set.add(s1);
System.out.println(set.contains(s2)); // false!
虽然 s1 和 s2 的哈希值相同(位于同一个桶中),但 Object 的 equals() 比较的是内存地址,s1 != s2,所以 contains() 返回 false。
结论:equals() 和 hashCode() 必须同时重写,以保持一致性约定。
六、哈希碰撞(Hash Collision)
6.1 什么是哈希碰撞?
哈希碰撞是指两个不同的对象产生了相同的哈希码。由于哈希码是 int 类型的(32 位),而对象的可能数量远大于 $2^{32}$,碰撞是不可避免的。
6.2 Java 如何处理哈希碰撞?
在 Java 8+ 中,HashMap 使用以下策略处理碰撞:
- 链地址法(拉链法):当多个对象映射到同一个桶时,这些对象被存储在一个链表(或红黑树)中。
- 树化优化:当桶中的元素数量超过阈值(
TREEIFY_THRESHOLD = 8)时,链表会被转换为红黑树,将查找时间复杂度从 $O(n)$ 优化到 $O(\log n)$。
HashMap 内部结构示意:
┌─────┐
│ 桶 0│ → null
├─────┤
│ 桶 1│ → Node("Aa") → Node("BB") (链表,hashCode 相同)
├─────┤
│ 桶 2│ → null
├─────┤
│ 桶 3│ → TreeNode(...) (红黑树,元素超过 8 个)
├─────┤
│ ... │
└─────┘
经典碰撞示例 — String 的 hashCode() 计算方式使得某些字符串产生相同值:
System.out.println("Aa".hashCode()); // 2112
System.out.println("BB".hashCode()); // 2112 (碰撞!)
6.3 如何减少哈希碰撞?
- 编写高质量的哈希函数,使哈希值分布均匀
- 使用质数作为乘数(如 31),减少哈希码的重复
- 避免使用非质数的乘数(如偶数会导致哈希值低位信息丢失)
// String 的 hashCode 计算
// 使用 31 作为乘数:31 * i == (i << 5) - i,JVM 可优化为位运算
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
七、如何正确重写 equals() 和 hashCode()
7.1 重写 equals() 的步骤(Effective Java 推荐)
@Override
public boolean equals(Object o) {
// 1. 自反性优化:如果是同一个对象引用,直接返回 true
if (this == o) return true;
// 2. 类型检查:确保参数是正确类型
if (!(o instanceof Person)) return false;
// 3. 类型转换
Person p = (Person) o;
// 4. 比较关键字段
return age == p.age
&& Objects.equals(name, p.name)
&& Objects.equals(email, p.email);
}
比较浮点数时注意事项:
float使用Float.compare()而非==double使用Double.compare()而非==- 因为
Float.NaN != Float.NaN,且-0.0f != 0.0f
// ✅ 正确的浮点数比较
return Float.compare(salary, p.salary) == 0;
7.2 重写 hashCode() 的方法
标准做法(使用质数 31):
@Override
public int hashCode() {
int result = 17; // 非零初始值
result = 31 * result + (name != null ? name.hashCode() : 0);
result = 31 * result + age;
result = 31 * result + (email != null ? email.hashCode() : 0);
return result;
}
使用 Objects.hash() 工具方法(简洁但性能略低,因为会创建数组):
@Override
public int hashCode() {
return Objects.hash(name, age, email);
}
7.3 完整示例
import java.util.Objects;
public class Employee {
private final String id;
private String name;
private int age;
private double salary;
// 构造方法、getter、setter 省略
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee)) return false;
Employee e = (Employee) o;
return age == e.age
&& Double.compare(e.salary, salary) == 0
&& Objects.equals(id, e.id)
&& Objects.equals(name, e.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name, age, salary);
}
}
八、IDE 自动生成 equals() 和 hashCode()
现代 IDE 提供了自动生成这两个方法的功能,极大地减少了手工编码的错误。
8.1 IntelliJ IDEA
快捷键:Cmd + N (Mac) / Alt + Insert (Windows) → equals() and hashCode()
IDEA 的生成选项包括:
- 模板选择:Java 7+
Objects.equals/hash、Apache Commons Lang、Guava 等 - 字段选择:选择参与比较的字段
- 接受子类参数(使用
instanceof)或要求相同类型(使用getClass()) - 处理 null 值
IDEA 生成的典型代码:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Employee employee = (Employee) o;
return age == employee.age
&& Double.compare(employee.salary, salary) == 0
&& Objects.equals(id, employee.id)
&& Objects.equals(name, employee.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name, age, salary);
}
8.2 Eclipse
快捷键:Alt + Shift + S → Generate hashCode() and equals()
Eclipse 也提供类似的选项,包括选择字段、使用 instanceof vs getClass() 等。
8.3 自动生成的优缺点
优点:
- 避免手动编码的低级错误(如忘记比较某个字段)
- 保证符合 Java 规范
- 处理了 null 安全、浮点数比较等边缘情况
缺点:
- 如果后续在类中添加了新字段,需要重新生成(容易忘记)
- 自动生成的代码有时会包含不必要的 null 检查
- 对于需要特殊业务逻辑的 equals 比较,自动生成无法满足
最佳实践:使用 IDE 生成作为起点,然后根据需要调整。在团队中建议使用统一的生成模板。
九、最佳实践总结
9.1 黄金法则
重写 equals() 时,必须同时重写 hashCode()。
9.2 选择参与 equals 的字段
- 每个参与
equals()的字段必须也参与hashCode()计算 - 选择能唯一标识对象的字段(如 ID、业务主键)
- 不要包含可变字段(否则会导致对象放入集合后无法正确查找)
- 不要包含派生字段(可由其他字段计算得出的字段)
9.3 选择类型比较策略
instanceof:允许子类对象与父类对象比较(违反对称性风险),适用于值对象(Value Object)的继承层次getClass():更严格,要求两个对象必须是同一个类,更安全
// instanceof 方式(宽松)
@Override
public boolean equals(Object o) {
if (!(o instanceof Person)) return false;
// ...
}
// getClass() 方式(严格)
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
// ...
}
9.4 使用工具类和框架
Objects.equals(a, b):null 安全的 equals 比较Objects.hash(...):便捷的 hashCode 生成Double.compare()/Float.compare():正确的浮点数比较- Lombok:
@EqualsAndHashCode注解自动生成
import lombok.EqualsAndHashCode;
@EqualsAndHashCode
public class Person {
private String name;
private int age;
// Lombok 自动生成 equals 和 hashCode
}
9.5 不变性优先
尽量设计不可变类(所有字段用 final 修饰)。不可变对象的 equals() 和 hashCode() 天然满足所有契约要求,也是作为 HashMap 键或 HashSet 元素的最佳选择。
9.6 性能考虑
hashCode()应尽可能高效——计算成本过高的哈希函数会拖慢哈希表操作- 缓存不可变对象的哈希码(如
String的做法) - 在
equals()中,先比较最可能不同的字段(短路优化),再比较计算成本较高的字段
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person p = (Person) o;
// 先比较最可能不同的字段
return age == p.age
&& Objects.equals(id, p.id) // 其次
&& Objects.equals(name, p.name); // 最后
}
// 缓存哈希码(仅对不可变类)
private int hashCode; // 默认为 0
@Override
public int hashCode() {
int result = hashCode;
if (result == 0) {
result = Objects.hash(id, name, age);
hashCode = result;
}
return result;
}
9.7 常见陷阱清单
| 陷阱 | 说明 |
|---|---|
只用 == 比较对象 |
不比较内容,除非你想比较引用 |
重写 equals 但不重写 hashCode |
哈希集合无法正常工作 |
在 equals 中使用可变字段 |
对象放入集合后修改字段会导致丢失 |
equals 依赖于外部资源 |
如数据库查询、网络调用,违反一致性 |
| 忘记 null 检查 | 可能导致 NullPointerException |
| 继承中破坏对称性/传递性 | 见前文 ColoredPoint 示例 |
使用 float/double 的 == |
需要使用 Float.compare() / Double.compare() |
| 使用非质数作为哈希乘数 | 可能导致哈希分布不均匀 |
十、总结
equals() 和 hashCode() 是 Java 中最基础也最容易被误解的两个方法。理解它们的契约、正确实现它们,是成为合格 Java 开发者的必备技能。
核心要点回顾:
==比较引用,equals()比较内容equals()必须满足自反性、对称性、传递性、一致性和非空性hashCode()必须与equals()保持一致:equals 相等则 hashCode 必须相等- 哈希碰撞不可避免,Java 通过链表/红黑树处理
- 善用 IDE 自动生成和工具类(
Objects、Lombok) - 优先设计不可变类,避免 equals/hashCode 相关的陷阱
掌握这些知识,不仅能帮你写出正确的代码,还能让你在面试中对答如流。
Happy Coding!