菜单

Administrator
发布于 2026-05-18 / 0 阅读
0
0

Equal 和 HashCode

Java 中 equals 和 hashCode 详解

一、引言

在 Java 编程中,equals()hashCode() 是两个基础但极其重要的方法。它们定义在 Object 类中,是所有 Java 对象的基石。正确理解并重写这两个方法,对于集合框架的正确使用(如 HashMapHashSetHashtable)、对象比较逻辑的实现以及性能优化都至关重要。

本文将深入探讨:

  • equals()== 的区别
  • equals() 方法的契约
  • hashCode() 方法的契约
  • 为什么必须同时重写 equals()hashCode()
  • 哈希碰撞及其处理
  • IDE 自动生成代码
  • 最佳实践与常见陷阱

二、equals() 与 == 的区别

2.1 == 运算符

== 运算符比较的是内存地址(引用)。对于基本类型(intboolean 等),它比较的是值;对于引用类型,它比较的是两个引用是否指向堆内存中的同一个对象

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

但许多标准类(如 StringIntegerDate 等)已经重写了 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)

对于任何非空引用 xx.equals(x) 必须返回 true

// 一个对象必须等于它自己
Person p = new Person("Alice", 25);
assert p.equals(p);  // 必须为 true

3.2 对称性(Symmetric)

对于任何非空引用 xy,如果 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)

对于任何非空引用 xyz,如果 x.equals(y) 返回 truey.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)

对于任何非空引用 xy,只要 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)

对于任何非空引用 xx.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() 返回一个整型哈希值,用于在基于哈希的集合(如 HashMapHashSetHashtable)中快速定位对象。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 面试中最经典的问题之一。答案很简单:如果不这样做,基于哈希的集合(HashMapHashSetHashtable)将无法正常工作。

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?流程如下:

  1. 调用 new Student("001", "Bob").hashCode() — 使用 Object 默认的 hashCode(),返回基于内存地址的哈希值
  2. HashSet 根据这个哈希值定位到某个哈希桶
  3. 在该桶中遍历元素调用 equals() 比较
  4. 但由于 "Alice" 对象和 "Bob" 对象的哈希值不同(不同的内存地址),它们被放到了不同的桶中
  5. 所以根本不会比较 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!

虽然 s1s2 的哈希值相同(位于同一个桶中),但 Objectequals() 比较的是内存地址,s1 != s2,所以 contains() 返回 false

结论:equals()hashCode() 必须同时重写,以保持一致性约定。


六、哈希碰撞(Hash Collision)

6.1 什么是哈希碰撞?

哈希碰撞是指两个不同的对象产生了相同的哈希码。由于哈希码是 int 类型的(32 位),而对象的可能数量远大于 $2^{32}$,碰撞是不可避免的。

6.2 Java 如何处理哈希碰撞?

在 Java 8+ 中,HashMap 使用以下策略处理碰撞:

  1. 链地址法(拉链法):当多个对象映射到同一个桶时,这些对象被存储在一个链表(或红黑树)中。
  2. 树化优化:当桶中的元素数量超过阈值(TREEIFY_THRESHOLD = 8)时,链表会被转换为红黑树,将查找时间复杂度从 $O(n)$ 优化到 $O(\log n)$。
HashMap 内部结构示意:

┌─────┐
│ 桶 0│ → null
├─────┤
│ 桶 1│ → Node("Aa") → Node("BB")  (链表,hashCode 相同)
├─────┤
│ 桶 2│ → null
├─────┤
│ 桶 3│ → TreeNode(...)  (红黑树,元素超过 8 个)
├─────┤
│ ... │
└─────┘

经典碰撞示例 — StringhashCode() 计算方式使得某些字符串产生相同值:

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 + SGenerate 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 开发者的必备技能。

核心要点回顾:

  1. == 比较引用,equals() 比较内容
  2. equals() 必须满足自反性、对称性、传递性、一致性和非空性
  3. hashCode() 必须与 equals() 保持一致:equals 相等则 hashCode 必须相等
  4. 哈希碰撞不可避免,Java 通过链表/红黑树处理
  5. 善用 IDE 自动生成和工具类(Objects、Lombok)
  6. 优先设计不可变类,避免 equals/hashCode 相关的陷阱

掌握这些知识,不仅能帮你写出正确的代码,还能让你在面试中对答如流。


Happy Coding!


评论