equals 与 hashCode 的契约关系:从 HashMap 到 Lombok 注解

equals 与 hashCode 的契约关系:从 HashMap 到 Lombok 注解

写在前面 :equals 和 hashCode 是 Java 中最重要的两个方法,它们之间的契约关系直接影响 HashMap、HashSet 等集合的正确性。为什么只重写 equals 不重写 hashCode 会导致 HashSet 出现重复元素?Lombok 的 @EqualsAndHashCode 是怎么生成这两个方法的?本文从 Object 源码出发,彻底讲透这对"黄金搭档"。


目录

  • [一、Object 类中的 equals 与 hashCode](#一、Object 类中的 equals 与 hashCode)
  • [二、为什么必须同时重写 equals 和 hashCode](#二、为什么必须同时重写 equals 和 hashCode)
  • [三、HashMap 中 equals 与 hashCode 的协作机制](#三、HashMap 中 equals 与 hashCode 的协作机制)
  • [四、正确重写 equals 和 hashCode](#四、正确重写 equals 和 hashCode)
  • [五、Lombok @EqualsAndHashCode 注解](#五、Lombok @EqualsAndHashCode 注解)
  • [六、IDEA 自动生成 vs Lombok 对比](#六、IDEA 自动生成 vs Lombok 对比)
  • 七、实战避坑清单
  • 八、总结

一、Object 类中的 equals 与 hashCode

1.1 Object 中的默认实现

java 复制代码
public class Object {

    // equals 默认实现:比较的是内存地址(==)
    public boolean equals(Object obj) {
        return (this == obj);
    }

    // hashCode 默认实现:返回对象的内存地址(通过 JVM 内部计算)
    // native 方法,不同 JVM 实现不同
    public native int hashCode();
}

1.2 默认行为验证

java 复制代码
public class DefaultEqualsDemo {
    public static void main(String[] args) {
        Person p1 = new Person("张三", 25);
        Person p2 = new Person("张三", 25);

        // equals 默认比较地址 → false(两个不同的堆对象)
        System.out.println(p1.equals(p2));  // false

        // hashCode 默认基于地址 → 不同
        System.out.println(p1.hashCode());  // 例如: 1639705018
        System.out.println(p2.hashCode());  // 例如: 1627674030
    }

    static class Person {
        String name;
        int age;
        Person(String name, int age) {
            this.name = name;
            this.age = age;
        }
    }
}

1.3 String 为什么重写了 equals

java 复制代码
// String 重写的 equals --- 比较字符内容
public boolean equals(Object anObject) {
    if (this == anObject) {           // 先比地址,快速判断
        return true;
    }
    if (anObject instanceof String) {  // 类型检查
        String anotherString = (String) anObject;
        int n = value.length;
        if (n == anotherString.value.length) {  // 长度不同直接 false
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {                 // 逐字符比较
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

// String 重写的 hashCode --- 基于字符内容计算
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];   // 经典的 31 倍散列算法
        }
        hash = h;
    }
    return h;
}

💡 为什么用 31? 因为 31 是奇质数,且 31 * i == (i << 5) - i,JVM 会优化为位运算,性能好。


二、为什么必须同时重写 equals 和 hashCode

2.1 Java 的契约规范

⭐ 核心契约 --- equals 和 hashCode 的五大约定

约定 说明
自反性 x.equals(x) 必须返回 true
对称性 x.equals(y) 为 true,则 y.equals(x) 也为 true
传递性 x.equals(y) 且 y.equals(z) 为 true,则 x.equals(z) 为 true
一致性 多次调用 x.equals(y) 结果一致(对象未被修改时)
null 处理 x.equals(null) 必须返回 false

⭐ equals 与 hashCode 的核心契约:

复制代码
如果两个对象 equals 相等 → hashCode 必须相等
如果两个对象 hashCode 相等 → equals 不一定相等(哈希冲突)
如果两个对象 equals 不等 → hashCode 可以相等也可以不等

2.2 只重写 equals 不重写 hashCode 的后果

java 复制代码
public class BrokenPerson {
    String name;
    int age;

    BrokenPerson(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 只重写了 equals,没重写 hashCode!
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        BrokenPerson that = (BrokenPerson) o;
        return age == that.age && Objects.equals(name, that.name);
    }
    // hashCode 用的是 Object 默认实现(基于地址)
}

public class BrokenDemo {
    public static void main(String[] args) {
        BrokenPerson p1 = new BrokenPerson("张三", 25);
        BrokenPerson p2 = new BrokenPerson("张三", 25);

        System.out.println(p1.equals(p2));  // true ← 逻辑上相等

        // 放入 HashSet
        Set<BrokenPerson> set = new HashSet<>();
        set.add(p1);
        set.add(p2);  // p2 的 hashCode 和 p1 不同 → 分到不同桶 → 不会被判重

        System.out.println(set.size());  // 2 ← 出现了"重复"元素!Bug!

        // 放入 HashMap
        Map<BrokenPerson, String> map = new HashMap<>();
        map.put(p1, "value1");
        map.put(p2, "value2");  // p2 的 hashCode 不同 → 分到不同桶 → 存为两个 entry

        System.out.println(map.size());  // 2 ← 逻辑上相同的 key 存了两份!Bug!
        System.out.println(map.get(p1)); // value1
        System.out.println(map.get(p2)); // value2 ← 应该覆盖 p1 才对
    }
}

问题根因:

复制代码
HashMap 的查找过程:
1. 先计算 key.hashCode() → 定位到桶(数组下标)
2. 桶内遍历,用 equals 比较每个元素

p1.hashCode() = 1639705018 → 桶 #A
p2.hashCode() = 1627674030 → 桶 #B  ← 不同桶!
→ 根本没机会走到 equals 比较
→ p1 和 p2 被认为是不同的 key

2.3 正确同时重写后的效果

java 复制代码
public class FixedPerson {
    String name;
    int age;

    FixedPerson(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;
        FixedPerson that = (FixedPerson) o;
        return age == that.age && Objects.equals(name, that.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

public class FixedDemo {
    public static void main(String[] args) {
        FixedPerson p1 = new FixedPerson("张三", 25);
        FixedPerson p2 = new FixedPerson("张三", 25);

        System.out.println(p1.equals(p2));  // true
        System.out.println(p1.hashCode() == p2.hashCode()); // true

        Set<FixedPerson> set = new HashSet<>();
        set.add(p1);
        set.add(p2);  // 相同 hashCode → 同一桶 → equals 返回 true → 判重

        System.out.println(set.size());  // 1 ← 正确!
    }
}

三、HashMap 中 equals 与 hashCode 的协作机制

3.1 HashMap put 流程

复制代码
put(key, value):
1. 计算 hash = (h = key.hashCode()) ^ (h >>> 16)  // 扰动函数
2. 定位桶: index = (n-1) & hash  // n 是数组长度
3. 如果桶为空 → 直接放入
4. 如果桶不空 → 遍历桶内链表/树:
   a. 先比 hash: if (e.hash == hash && ...)  // 先比 hashCode(快)
   b. 再比 equals: ((k = e.key) == key || key.equals(k))  // 再比 equals(慢)
   c. 找到相等的 key → 替换 value,返回旧值
   d. 没找到 → 在链表/树末尾新增节点

⭐ 核心代码 --- HashMap 的 key 判断逻辑(JDK 8 简化)

java 复制代码
// HashMap 的 putVal 方法中判断 key 是否存在的核心逻辑
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // ...
    for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
            // 链表到头了,没有匹配的 key → 新增节点
            p.next = newNode(hash, key, value, null);
            // ...
            break;
        }
        // ★ 核心:先比 hashCode,再比 equals
        if (e.hash == hash &&                      // 第一步:hashCode 相等?
            ((k = e.key) == key ||                 // 第二步:地址相等(快速路径)?
             (key != null && key.equals(k))))       // 第三步:equals 相等?
            break;  // 找到了匹配的 key,退出循环准备替换
        p = e;
    }
    // ...
}

3.2 为什么先比 hashCode 再比 equals

比较方式 时间复杂度 说明
hashCode 比较 O(1) 整数比较,极快
equals 比较 O(n) 可能逐字符/逐字段比较

先比 hashCode 可以快速排除不相等的 key ,只有在 hashCode 相同时才需要调用较慢的 equals。

复制代码
桶 #A 中有 1000 个元素:
→ 比较 hashCode: 1000 次整数比较(极快)
→ 大多数 hashCode 不同,直接跳过
→ 只有 hashCode 相同的少数元素才调用 equals

四、正确重写 equals 和 hashCode

4.1 equals 重写模板

⭐ 核心代码 --- equals 标准实现模板

java 复制代码
public class Person {
    private String name;
    private int age;
    private String email;

    @Override
    public boolean equals(Object o) {
        // 1. 引用相同 → 直接 true
        if (this == o) return true;

        // 2. null 或类型不匹配 → false
        if (o == null || getClass() != o.getClass()) return false;

        // 3. 强转后逐字段比较
        Person person = (Person) o;
        return age == person.age
            && Objects.equals(name, person.name)
            && Objects.equals(email, person.email);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age, email);
    }
}

4.2 equals 重写的常见错误

java 复制代码
// ❌ 错误一:参数类型写成当前类
public boolean equals(Person o) {  // 不是 Object!重载不是重写
    return this.age == o.age;
}
// 后果:HashMap 调用 equals(Object) 仍走 Object 默认实现(比地址)

// ❌ 错误二:用 instanceof 代替 getClass
public boolean equals(Object o) {
    if (o instanceof Person) {  // 子类也会通过检查
        return this.age == ((Person) o).age;
    }
    return false;
}
// 后果:子类与父类可能"相等",破坏对称性
// Person p = new Person("A", 20);
// Employee e = new Employee("A", 20, "IT");
// p.equals(e) = true, e.equals(p) = false ← 对称性被破坏!

// ❌ 错误三:不处理 null
public boolean equals(Object o) {
    Person p = (Person) o;  // o 为 null 时 NPE!
    return this.age == p.age;
}

// ❌ 错误四:包含可变字段
// 如果用 name + age 做 equals,放入 HashMap 后修改 name → 再也 get 不到

4.3 使用 Objects.equals 避免空指针

⭐ 核心代码 --- Objects.equals 安全比较

java 复制代码
import java.util.Objects;

// 手动写 → 可能 NPE
if (this.name.equals(that.name)) { }  // this.name 为 null 时 NPE

// Objects.equals → 安全
Objects.equals(this.name, that.name);  // 内部处理 null

// Objects.equals 源码
public static boolean equals(Object a, Object b) {
    return (a == b) || (a != null && a.equals(b));
}

4.4 hashCode 计算的推荐方式

java 复制代码
// 方式一:Objects.hash(最简单,推荐)
@Override
public int hashCode() {
    return Objects.hash(name, age, email);
}

// 方式二:手动计算(性能稍好,但代码冗长)
@Override
public int hashCode() {
    int result = name != null ? name.hashCode() : 0;
    result = 31 * result + age;
    result = 31 * result + (email != null ? email.hashCode() : 0);
    return result;
}

// Objects.hash 源码
public static int hash(Object... values) {
    // 实际也是用 31 倍算法
    if (values == null) return 0;
    int result = 1;
    for (Object element : values)
        result = 31 * result + (element == null ? 0 : element.hashCode());
    return result;
}

4.5 只用部分字段参与计算

java 复制代码
public class User {
    private Long id;       // 唯一标识
    private String name;   // 可能变化
    private String email;  // 可能变化
    private int age;

    // 方案一:所有字段参与(逻辑正确,但 name/email 变化后 HashMap 失效)
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id)
            && Objects.equals(name, user.name)
            && Objects.equals(email, user.email)
            && age == user.age;
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name, email, age);
    }

    // 方案二:只用 id 参与(推荐用于实体类)
    // 优点:name/email 变化不影响 HashMap 中的查找
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }
}

⚠️ 重要原则 :如果对象作为 HashMap 的 key,不要用可变字段参与 equals/hashCode 计算!放入后修改字段会导致再也找不到这个 key。


五、Lombok @EqualsAndHashCode 注解

5.1 基本使用

java 复制代码
import lombok.Data;

@Data  // 包含 @EqualsAndHashCode
public class Person {
    private String name;
    private int age;
    private String email;
}
// Lombok 自动生成 equals 和 hashCode,包含所有非静态字段

5.2 @EqualsAndHashCode 详解

java 复制代码
import lombok.EqualsAndHashCode;

@EqualsAndHashCode
public class Person {
    private String name;
    private int age;
    private String email;

    // 排除某个字段
    @EqualsAndHashCode.Exclude
    private String debugInfo;
}

⭐ 核心代码 --- Lombok 高级配置

java 复制代码
@EqualsAndHashCode(
    of = {"id"},                    // 只用 id 字段参与计算
    exclude = {"debugInfo"},        // 排除 debugInfo 字段
    callSuper = true,               // 调用父类的 equals/hashCode
    doNotUseGetters = true          // 直接访问字段而非 getter
)
public class Employee extends Person {
    private Long id;
    private String department;
    private String debugInfo;
}

5.3 callSuper 详解

java 复制代码
// 父类
@EqualsAndHashCode
public class Person {
    private String name;
    private int age;
}

// 子类 --- callSuper = false(默认)
@EqualsAndHashCode
public class Employee extends Person {
    private String department;
}
// 生成的 equals 只比较 department,不比较 name/age
// 两个 Employee 部门相同但姓名年龄不同 → equals 为 true ← 可能不是期望行为

// 子类 --- callSuper = true
@EqualsAndHashCode(callSuper = true)
public class Employee extends Person {
    private String department;
}
// 生成的 equals 先调用 super.equals(比较 name + age),再比较 department
// 更符合直觉

六、IDEA 自动生成 vs Lombok 对比

6.1 IDEA 自动生成的代码

java 复制代码
// IDEA → Generate → equals() and hashCode()
public class Person {
    private String name;
    private int age;
    private String email;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age
            && Objects.equals(name, person.name)
            && Objects.equals(email, person.email);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age, email);
    }
}

6.2 对比

特性 IDEA 自动生成 Lombok @EqualsAndHashCode
代码量 多(手写模板) 零(注解搞定)
可读性 直观 需了解 Lombok 行为
灵活性 完全自定义 通过注解参数配置
字段变更 需重新生成 自动感知新字段
调试 可设断点 生成的代码不可见
callSuper 默认包含 默认 false(需手动开启)

💡 推荐:新项目用 Lombok,老项目或安全要求高的用 IDEA 生成。


七、实战避坑清单

序号 陷阱 正确做法
1 只重写 equals 不重写 hashCode 必须同时重写
2 equals 参数不是 Object 签名必须是 equals(Object o)
3 equals 中含可变字段 作为 HashMap key 时只用不可变字段(如 id)
4 hashCode 用 == 代替 equals hashCode 相等不代表 equals 相等
5 Lombok 子类没设 callSuper 继承时加 @EqualsAndHashCode(callSuper = true)
6 用 instanceof 破坏对称性 用 getClass() != o.getClass()
7 直接 this.field.equals(that.field) 用 Objects.equals() 防空指针
8 equals 包含 float/double 用 Float.compare / Double.compare

7.1 float/double 字段的特殊处理

java 复制代码
// ❌ 直接 == 比较 float
if (this.score == that.score) { }  // NaN != NaN,精度问题

// ✅ 使用 Float.compare / Double.compare
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Student student = (Student) o;
    return Float.compare(student.score, this.score) == 0;  // 正确处理 NaN
}

@Override
public int hashCode() {
    return Float.floatToIntBits(score);  // 将 float 转为 int 做哈希
}

7.2 数组字段的特殊处理

java 复制代码
public class Team {
    private String name;
    private int[] scores;

    // ❌ 错误:数组用 equals 比的是地址
    // this.scores.equals(that.scores) → 永远 false(除非同一个数组对象)

    // ✅ 使用 Arrays.equals
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Team team = (Team) o;
        return Objects.equals(name, team.name)
            && Arrays.equals(scores, team.scores);  // 逐元素比较
    }

    // ✅ 使用 Arrays.hashCode
    @Override
    public int hashCode() {
        int result = Objects.hash(name);
        result = 31 * result + Arrays.hashCode(scores);
        return result;
    }
}

八、总结

复制代码
equals 与 hashCode 知识图谱:

核心契约
├── equals 相等 → hashCode 必须相等
├── hashCode 相等 → equals 不一定相等(哈希冲突)
├── equals 不等 → hashCode 可以相等也可以不等
└── 一致性:对象未变时多次调用结果一致

Object 默认行为
├── equals → 比地址 (==)
├── hashCode → 基于地址
└── String 重写 → 比内容 / 31倍散列

HashMap 协作
├── 先 hashCode 定位桶
├── 桶内先比 hashCode 再比 equals
├── hashCode 不同 → 快速跳过
└── 不重写 hashCode → 同值对象分到不同桶 → 判重失败

正确重写
├── 模板:地址检查 → 类型检查 → 逐字段比较
├── Objects.equals() 防空指针
├── Objects.hash() 简化 hashCode
├── float/double → Float.compare
├── 数组 → Arrays.equals / Arrays.hashCode
└── HashMap key → 只用不可变字段

Lombok
├── @EqualsAndHashCode 自动生成
├── of/exclude 控制参与字段
├── callSuper = true 处理继承
└── @Data 包含此注解

一句话记住核心:

equals 和 hashCode 必须同时重写,hashCode 决定桶位置、equals 决定桶内匹配,HashMap key 只用不可变字段参与计算。

相关推荐
xiaogai_gai1 小时前
金蝶收料通知单查询策略实战教程:从源系统拉取到中间层落地的完整路径
java·前端·数据库
打工仔折腾 AI2 小时前
用 Dashlet 把极空间 NAS 服务入口整理成统一导航首页的实践
java·开发语言·人工智能·后端·python·rpc·ai agent 实战
小蒜学长3 小时前
微信小程序-高校自习室管理系统(代码+数据库+LW)
java·spring boot·后端·微信小程序·自习室管理
鸥梨菌Honevid3 小时前
UVM 中 REQ 和 RSP 类型详解:从通信模型到 item_done 与 put_response
java·前端·servlet
瑞克-桑切斯3 小时前
真实企业场景 运维安全与管理
java·网络·github
老板一杯拿铁4 小时前
Java 八股文面试详解:从基础到进阶的全面梳理
java·面试
const_qiu4 小时前
第 2.5 章 2.5.4-2.5.5:JWT 双 Token 与声明式权限守卫
java·人工智能
bug菌4 小时前
一个周末就想把短链服务推到 10 万 QPS,真能靠加 Redis 做到吗?
java·redis·go
撸串-研究生4 小时前
springboot福利院管理系统73979
java·spring boot·后端·福利院管理系统