equals和hashCode的契约:重写了equals却忘了hashCode的惨案
equals和hashCode的契约:重写了equals却忘了hashCode的惨案
适读人群:Java初中级开发者、有过Set/Map行为异常困惑的后端工程师 | 阅读时长:约13分钟 | 文章类型:原理剖析+踩坑实录
开篇故事
几年前我做了一个用户去重的需求:从多个渠道导入用户数据,同一个手机号视为同一个用户,需要去重。
同事小赵写了这段代码:
public class User {
private String phone;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(phone, user.phone);
}
// 忘了重写hashCode
}
Set<User> uniqueUsers = new HashSet<>();
for (User user : importedUsers) {
uniqueUsers.add(user);
}
// 期望去重,但Set里出现了大量重复的phone小赵发现HashSet根本没有去重效果,导入了500个用户,HashSet里有490个。他百思不得其解——equals明明已经按phone比较了,为什么Set还是不去重?
我看了一眼,说:"你的hashCode怎么实现的?"
他愣了一下,说:"没有重写,用的Object默认的。"
我说:"那就是这里的问题。"
这是Java里最经典的一个坑:重写了equals,必须同时重写hashCode。
一、equals和hashCode的约定
Java的Object类对equals和hashCode有一个明确的契约(Contract),在Javadoc里写得很清楚:
hashCode的约定:
- 在同一次JVM运行中,对同一对象多次调用
hashCode(),必须返回相同的值 - 如果
a.equals(b) == true,那么a.hashCode() == b.hashCode()必须成立 - 如果
a.equals(b) == false,a.hashCode() == b.hashCode()不是必须的(但推荐不同,以减少哈希冲突)
最关键的是第2条:equals相等,hashCode必须相等。反过来,hashCode相等,equals不一定相等(哈希碰撞)。
二、核心原理深挖
HashSet/HashMap的内部工作原理
HashSet底层就是HashMap,HashSet.add(element)本质上是map.put(element, PRESENT)。
HashMap.put的逻辑(简化):
public V put(K key, V value) {
// 第一步:计算hashCode,确定bucket位置
int hash = hash(key.hashCode()); // hashCode决定存在哪个桶
int bucketIndex = hash & (capacity - 1);
// 第二步:遍历该bucket里的链表/红黑树,用equals判断是否已存在
for (Entry e = bucket[bucketIndex]; e != null; e = e.next) {
if (e.hash == hash && e.key.equals(key)) {
// 找到了,更新value
e.value = value;
return oldValue;
}
}
// 第三步:没找到,新建Entry
addEntry(hash, key, value, bucketIndex);
return null;
}两步走:先hashCode定位桶,再equals确认相等。
如果只重写equals不重写hashCode:
phone相同的两个User,equals返回true(按phone比较)- 但
hashCode()用的是Object默认实现(基于内存地址),两个对象的hashCode不同 - HashMap先用hashCode定位,找到不同的桶,根本不会去调用equals
- 结果:两个"相等"的User被放到了不同的桶里,Set没有去重
对象的hashCode、equals和集合操作的关系
三、完整代码实现
代码一:错误示范和正确实现
package com.laozhang.trap.equalsHashcode;
import java.util.*;
/**
* equals和hashCode契约的完整示范
*/
public class EqualsHashCodeContract {
// ===== 错误示范:只重写equals,不重写hashCode =====
static class UserWrong {
String phone;
String name;
UserWrong(String phone, String name) {
this.phone = phone;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof UserWrong)) return false;
return Objects.equals(phone, ((UserWrong) o).phone);
}
// 没有重写hashCode!!
@Override
public String toString() {
return "User{phone='" + phone + "'}";
}
}
// ===== 正确实现:同时重写equals和hashCode =====
static class UserCorrect {
String phone;
String name;
UserCorrect(String phone, String name) {
this.phone = phone;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof UserCorrect)) return false;
return Objects.equals(phone, ((UserCorrect) o).phone);
}
@Override
public int hashCode() {
return Objects.hash(phone); // equals用phone判断,hashCode也要基于phone
}
@Override
public String toString() {
return "User{phone='" + phone + "'}";
}
}
// ===== Lombok的@EqualsAndHashCode =====
// 推荐用Lombok,自动生成且保持一致性
// @EqualsAndHashCode(of = {"phone"}) // 只用phone字段生成
// static class UserWithLombok { ... }
public static void main(String[] args) {
System.out.println("=== 错误实现:HashSet去重失效 ===");
Set<UserWrong> wrongSet = new HashSet<>();
wrongSet.add(new UserWrong("13800000001", "张三"));
wrongSet.add(new UserWrong("13800000001", "张三(重复)")); // 相同phone,应被去重
wrongSet.add(new UserWrong("13800000002", "李四"));
System.out.println("Set大小: " + wrongSet.size()); // 3!没有去重!
System.out.println("contains(相同phone): " +
wrongSet.contains(new UserWrong("13800000001", "张三"))); // false!
System.out.println("\n=== 正确实现:HashSet去重正常 ===");
Set<UserCorrect> correctSet = new HashSet<>();
correctSet.add(new UserCorrect("13800000001", "张三"));
correctSet.add(new UserCorrect("13800000001", "张三(重复)")); // 相同phone,被去重
correctSet.add(new UserCorrect("13800000002", "李四"));
System.out.println("Set大小: " + correctSet.size()); // 2,正确!
System.out.println("contains(相同phone): " +
correctSet.contains(new UserCorrect("13800000001", "任意名字"))); // true!
System.out.println("\n=== HashMap的key也有相同问题 ===");
Map<UserWrong, String> wrongMap = new HashMap<>();
wrongMap.put(new UserWrong("13800000001", "张三"), "VALUE_A");
System.out.println("get相同phone: " +
wrongMap.get(new UserWrong("13800000001", "张三"))); // null!!
Map<UserCorrect, String> correctMap = new HashMap<>();
correctMap.put(new UserCorrect("13800000001", "张三"), "VALUE_A");
System.out.println("get相同phone: " +
correctMap.get(new UserCorrect("13800000001", "张三"))); // VALUE_A ✓
System.out.println("\n=== equals的自反性、对称性、传递性测试 ===");
UserCorrect a = new UserCorrect("13800000001", "张三");
UserCorrect b = new UserCorrect("13800000001", "李四"); // 同phone不同name
UserCorrect c = new UserCorrect("13800000001", "王五");
System.out.println("自反性 a.equals(a): " + a.equals(a)); // true
System.out.println("对称性 a.equals(b)==b.equals(a): " +
(a.equals(b) == b.equals(a))); // true
System.out.println("传递性 a.equals(b) && b.equals(c) => a.equals(c): " +
(a.equals(b) && b.equals(c) && a.equals(c))); // true
System.out.println("非空性 a.equals(null): " + a.equals(null)); // false
}
}代码二:各种场景下的equals和hashCode最佳实践
package com.laozhang.trap.equalsHashcode;
import java.util.Arrays;
import java.util.Objects;
/**
* 不同场景下equals和hashCode的正确实现方式
*/
public class EqualsHashCodePatterns {
/**
* 模式1:值对象(Value Object)
* equals比较所有"有意义"的业务字段
* 推荐用Objects.equals和Objects.hash(null安全)
*/
static class Money {
private final long amount; // 分
private final String currency; // 货币代码
Money(long amount, String currency) {
this.amount = amount;
this.currency = currency;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money money = (Money) o;
return amount == money.amount &&
Objects.equals(currency, money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency); // 与equals保持一致
}
}
/**
* 模式2:实体对象(Entity)
* 通常只按ID比较,忽略其他业务字段
*/
static class OrderEntity {
private final Long id; // 数据库主键
private String status;
private String detail;
OrderEntity(Long id) { this.id = id; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderEntity)) return false;
OrderEntity other = (OrderEntity) o;
// 实体:只按ID比较
return Objects.equals(id, other.id);
}
@Override
public int hashCode() {
return Objects.hash(id); // 与equals保持一致
}
}
/**
* 模式3:包含数组字段的类
* 数组的equals是引用比较,需要用Arrays.equals
*/
static class ByteKey {
private final byte[] key;
ByteKey(byte[] key) {
this.key = key.clone(); // 防御性拷贝
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ByteKey)) return false;
return Arrays.equals(key, ((ByteKey) o).key); // 数组用Arrays.equals
}
@Override
public int hashCode() {
return Arrays.hashCode(key); // 数组用Arrays.hashCode
}
}
/**
* 模式4:继承关系中的equals(需要特别注意对称性)
*/
static class Point {
final int x, y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
// 注意:用getClass()而不是instanceof,保证对称性
// 用instanceof的话,子类ColorPoint.equals(Point)可能返回false
// 但Point.equals(ColorPoint)返回true,破坏对称性
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
static class ColorPoint extends Point {
final String color;
ColorPoint(int x, int y, String color) {
super(x, y);
this.color = color;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
return Objects.equals(color, ((ColorPoint) o).color);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), color);
}
}
/**
* 反面模式:equals比较可变字段,然后用作HashMap/HashSet的key
* 如果key改变,就找不到了!
*/
static class MutableKeyDanger {
int mutableField; // 可变字段
@Override
public boolean equals(Object o) {
if (!(o instanceof MutableKeyDanger)) return false;
return mutableField == ((MutableKeyDanger) o).mutableField;
}
@Override
public int hashCode() {
return mutableField; // 可变!放入HashMap后再改变,就找不到了
}
}
public static void main(String[] args) {
System.out.println("=== Money值对象 ===");
Money m1 = new Money(100, "CNY");
Money m2 = new Money(100, "CNY");
System.out.println("m1.equals(m2): " + m1.equals(m2)); // true
System.out.println("hashCode相同: " + (m1.hashCode() == m2.hashCode())); // true
System.out.println("\n=== ByteKey数组 ===");
ByteKey k1 = new ByteKey(new byte[]{1, 2, 3});
ByteKey k2 = new ByteKey(new byte[]{1, 2, 3});
System.out.println("k1.equals(k2): " + k1.equals(k2)); // true
System.out.println("\n=== 可变key的陷阱 ===");
java.util.HashMap<MutableKeyDanger, String> map = new java.util.HashMap<>();
MutableKeyDanger key = new MutableKeyDanger();
key.mutableField = 1;
map.put(key, "value");
System.out.println("放入后get: " + map.get(key)); // value
key.mutableField = 99; // 修改了key!
System.out.println("改变key后get: " + map.get(key)); // null!找不到了!
}
}四、踩坑实录
坑1:只重写equals不重写hashCode,HashSet/HashMap行为异常
这就是开篇的案例,不赘述了。
解法要点:
// equals用什么字段比较,hashCode就用什么字段计算
@Override
public int hashCode() {
return Objects.hash(phone); // 与equals里的phone保持一致
}坑2:把可变对象放入HashSet后修改它
报错现象:
Set<User> set = new HashSet<>();
User user = new User("13800000001");
set.add(user);
System.out.println(set.contains(user)); // true
user.setPhone("13800000002"); // 修改了参与hashCode计算的字段
System.out.println(set.contains(user)); // false!
System.out.println(set.size()); // 1(对象还在Set里,但找不到了)根本原因:
修改了对象参与hashCode计算的字段,导致当前hashCode和放入Set时的hashCode不同,在不同的桶里,contains找不到。但对象还在原来的桶里,Set.size()还是1。
具体解法:
- 用作Set/Map的key的对象,应该是不可变的(final字段)
- 或者hashCode只基于不可变字段计算
- 或者在对象加入Set之后,不修改参与hashCode计算的字段
坑3:equals对称性被破坏
报错现象:
ChildClass a = new ChildClass();
ParentClass b = new ParentClass();
System.out.println(a.equals(b)); // false
System.out.println(b.equals(a)); // true!不对称!根本原因:
子类重写equals时用了instanceof,导致父类equals子类返回true(子类是父类的实例),但子类equals父类返回false(父类不是子类的实例)。
具体解法:
在继承关系中,如果要在equals里使用类型检查,用getClass() != o.getClass()而不是instanceof。这样父子类之间就不会互相equals,保证对称性。
五、总结与延伸
这个契约,用一句话记住:
equals相等 → hashCode一定相等;hashCode相等 → equals不一定相等。
实践原则:
1. 重写equals必须重写hashCode,且两者基于相同的字段
2. 推荐用Lombok的@EqualsAndHashCode(指定字段)或Java 16+的Record
// Lombok
@EqualsAndHashCode(of = {"phone"})
class User { String phone; String name; }
// Java 16+ Record(自动基于所有字段生成equals和hashCode)
record User(String phone, String name) {}3. 用作Map/Set key的对象,字段应该是不可变的
4. equals实现要满足:自反性、对称性、传递性、非空性、一致性
5. 不要用null字段参与hashCode计算(可能NullPointerException),用Objects.hash(null安全)
IDEA和Eclipse都能自动生成equals和hashCode——但自动生成时要注意勾选正确的字段,不要把所有字段都选进去,只选有业务意义的标识字段。
