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 只用不可变字段参与计算。