根本原因是HashSet先用hashCode()分桶,再对同桶元素调equals();若只重写equals()未重写hashCode(),或hashCode()使用了可变字段/不参与equals比较的字段,会导致逻辑相等对象被分到不同桶,equals()不被调用。

为什么 equals() 返回 true,但放进 HashSet 还是重复?
根本原因在于:集合类(如 HashSet、HashMap)在判断元素是否已存在时,**先调用 hashCode() 快速分桶,再对同桶内元素调用 equals() 逐个比较**。如果两个逻辑相等的对象 hashCode() 不一致,它们会被分到不同桶里,equals() 根本不会被触发。
常见错误场景:
- 只重写
equals(),没重写hashCode() - 重写
hashCode()时用了未参与equals()比较的字段(比如用id判断相等,却用name算哈希) - 在
hashCode()中使用了可变字段(对象加入HashSet后修改了该字段,导致哈希值变化,后续contains()失败)
equals() 和 hashCode() 的契约必须同时满足
Java 规范强制要求以下三点,缺一不可:
- 自反性:
a.equals(a)必须返回true - 对称性:
a.equals(b)为true⇒b.equals(a)也必须为true - 一致性:
equals()结果在对象生命周期内不因外部调用而改变(除非涉及的字段被修改) - 若
a.equals(b)为true,则a.hashCode() == b.hashCode()必须成立 - 若两个对象
hashCode()相同,equals()不一定为true(哈希冲突允许)
违反任一条,集合行为就会不可预测——比如 HashSet.add() 重复添加、HashMap.get() 查不到已存的键。
立即学习“Java免费学习笔记(深入)”;
手写 equals() 和 hashCode() 的实操要点
以一个典型实体类为例,关键不是“怎么写”,而是“为什么这么写”:
public class User {
private final String name;
private final int age;
public User(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; // 类型检查
User user = (User) o;
return age == user.age && Objects.equals(name, user.name); // 字段逐个比
}
@Override
public int hashCode() {
return Objects.hash(name, age); // 顺序、字段必须与 equals 一致
}
}
注意点:
- 用
Objects.equals()安全处理null,别直接调name.equals() -
hashCode()中字段顺序、是否参与比较,必须和equals()完全一致 - 如果字段是数组,用
Arrays.equals()和Arrays.hashCode(),别用默认toString() - IDE 自动生成的代码(如 IntelliJ 的
Generate → equals() and hashCode())基本可靠,但要确认勾选的字段和业务语义一致
什么时候可以不重写?哪些类自带靠谱实现?
不是所有类都需要手动重写:
-
String、Integer、LocalDateTime等 JDK 不可变类,equals()/hashCode()已按值语义实现,可直接用作HashMap的 key - 记录类(
record)自动按全部组件字段生成equals()和hashCode(),且不可变,推荐优先使用 - 如果类只用于临时计算、不放入集合、不作为 Map 键,且你明确不依赖逻辑相等判断,可跳过重写(但团队协作中不建议)
最容易被忽略的是:**Lombok 的 @Data 注解虽自动生成两者,但如果类里有自定义字段(比如含 byte[] 或自定义对象),仍需手动校验或覆盖 hashCode()**。










