Skip to content

equals、hashCode 与对象比较 ​

S1-012必学入门约 60 分钟JDK 21编程语言基础 · 常用类

1. 本节定位 ​

"两个对象相等"这件事,在 Java 里需要你自己定义。本节讲清三件事:默认行为是什么、该怎么重写、以及为什么两个方法必须成对重写。

这是面试高频题("为什么重写 equals 还要重写 hashCode"),也是日常最容易埋雷的地方——不重写时,HashSet 去重、HashMap 查找、List.contains 全都会"莫名其妙"失效。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说清 Object 默认的 equals 与 hashCode 的行为;
  • 背出并解释 equals/hashCode 契约;
  • 用 Objects.equals 与 Objects.hash 写出正确的重写;
  • 知道 record 会自动生成它们,并说清适用边界;
  • 解释"只重写 equals 不重写 hashCode"会导致什么具体故障;
  • 避免 equals 参数类型写错(变成重载而非重写)这个隐蔽 bug。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

看一段"看起来没问题"的代码:

java
Set<Point> points = new HashSet<>();
points.add(new Point(1, 2));
System.out.println(points.contains(new Point(1, 2)));   // 期望 true

如果你没重写 equals 与 hashCode,输出是 false。

原因:Object 的默认实现认为"只有同一个对象才相等"。两个 new Point(1, 2) 是不同对象,所以不相等。

而 String、Integer、LocalDate 这些 JDK 类可以正常比较——因为它们已经重写好了。所以这件事不是"可选优化",而是"自定义类型进入集合前的必修课"。

5. 核心概念 ​

5.1 默认行为 ​

java
Object first = new Object();
Object second = new Object();
first.equals(second);        // false —— 比较地址
方法默认实现含义
equalsthis == other同一个对象才相等
hashCode由对象地址派生的整数不同对象几乎不同

这也是为什么"内容相同的自定义对象"默认不相等。

5.2 契约(必须记牢) ​

text
1. equals 为 true 的两个对象,hashCode 必须相等   ← 硬性要求
2. hashCode 相等的两个对象,equals 不一定为 true   ← 允许哈希冲突
3. equals 必须满足:自反、对称、传递、一致、与 null 比较返回 false

第 1 条是全部问题的根源。集合类(HashSet、HashMap)先用 hashCode 定位"桶",再用 equals 精确匹配。如果两个"相等"的对象哈希值不同,它们会被放进不同的桶,于是永远找不到对方。

5.3 正确的手写模板 ​

java
public final class Point {
    private final int x;
    private final int y;

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;                       // 1. 地址相同直接返回
        }
        if (other == null || getClass() != other.getClass()) {
            return false;                      // 2. 类型检查(用 getClass 而非 instanceof)
        }
        Point point = (Point) other;
        return x == point.x && y == point.y;   // 3. 逐字段比较
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);             // 用 Objects.hash 简化
    }
}

三个细节:

  • 第 1 步不能省:地址相同必然相等,还能避免后续比较;
  • 类型检查用 getClass():用 instanceof 的话,子类实例可能被判为"等于"父类实例,破坏对称性;
  • 字段比较:基本类型用 ==,引用类型用 Objects.equals(a, b)(它会处理 null)。

5.4 record:自动生成 ​

java
public record PointRecord(int x, int y) { }

编译器自动生成 equals、hashCode、toString,语义就是"所有字段都相等"。

适用边界:

场景用 record
值对象、DTO、参数对象、返回值合适
需要自定义相等语义(如只按 ID 比较)不合适,需手写
需要可变字段不合适(record 字段是 final)

"只按 ID 比较"是很常见的业务需求:

java
// 两个用户对象,只要 ID 相同就算同一个人(其他字段可能一个是旧的、一个是新的)
@Override
public boolean equals(Object other) {
    return other instanceof User user && Objects.equals(id, user.id);
}

这种情况不能用 record 的默认语义。

5.5 什么时候可以不用重写 ​

类型是否重写说明
String / Integer / LocalDate 等 JDK 类已重写直接用
枚举不需要常量是单例,== 就是最准确的比较
只用 == 比较的对象(如无状态的工具类实例)可以不重写但不要放进集合
任何要放进 Set / 作为 Map key 的对象必须重写否则行为不符合预期

5.6 用可变字段参与 hashCode 的坑 ​

java
Set<Point> set = new HashSet<>();
Point point = new MutablePoint(1, 2);
set.add(point);
point.setX(99);                 // 哈希值变了
set.contains(point);            // false —— 它躺在错误的桶里

结论:参与 hashCode 的字段应该不可变。这也是 《String 与不可变性》 强调"key 用不可变类型"的原因。

6. 递进示例 ​

6.1 最小示例:默认 vs 重写 ​

java
// 未重写
new RawPoint(1, 2).equals(new RawPoint(1, 2));      // false

// 重写后
new Point(1, 2).equals(new Point(1, 2));            // true

6.2 业务示例:去重与查找 ​

java
Set<Point> points = new HashSet<>();
points.add(new Point(1, 2));
points.contains(new Point(1, 2));                   // true

Map<Point, String> labels = new HashMap<>();
labels.put(new Point(1, 2), "第一个点");
labels.get(new Point(1, 2));                        // "第一个点"

这两个场景是重写 equals/hashCode 最直接的收益。

6.3 反例:只重写 equals ​

java
class OnlyEqualsPoint {
    // 重写了 equals,忘了 hashCode
}

Set<OnlyEqualsPoint> set = new HashSet<>();
set.add(new OnlyEqualsPoint(1, 2));
set.contains(new OnlyEqualsPoint(1, 2));   // false!

原因:HashSet.contains 先用 hashCode 找桶,两个对象的哈希值不同(默认实现基于地址),根本不在同一个桶里,equals 压根没机会执行。

这就是"契约第 1 条"被破坏的真实后果。

7. 完整可运行代码 ​

文件:labs/common-classes/src/main/java/cn/kb/learn/common/ 下的 Point.java、PointRecord.java、EqualsLesson.java

java
package cn.kb.learn.common;

import java.util.Objects;

/** 手写 equals 与 hashCode:演示两个方法必须成对重写 */
public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() {
        return x;
    }

    public int y() {
        return y;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }
        if (other == null || getClass() != other.getClass()) {
            return false;
        }
        Point point = (Point) other;
        return x == point.x && y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }

    @Override
    public String toString() {
        return "Point(" + x + ", " + y + ")";
    }
}
java
package cn.kb.learn.common;

/** 同样的语义用 record 表达:编译器自动生成 equals、hashCode、toString */
public record PointRecord(int x, int y) {
}

运行命令:

powershell
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.EqualsLesson

预期输出:

text
两个新对象 equals: false
未重写 equals: false
重写后 equals: true
重写后 hashCode 相同: true
HashSet 能按内容找到: true
HashMap 能按内容取值: 第一个点
record equals: true
record hashCode 相同: true
record toString: PointRecord[x=1, y=2]
字符串 key 查找: 90
--- 只重写一个的后果 ---
Point 与 PointRecord 不是同一类: false

逐行对照:

输出说明
两个新对象 equals: falseObject 默认比较地址
未重写 equals: false内容相同的自定义对象不相等
重写后 equals / hashCode契约被满足
HashSet / HashMap 两行集合能按内容查找
record 三行不用手写就有同样的能力
最后一行不同类型的对象不相等(getClass() 检查生效)

8. Debug 观察点 ​

  1. 在 raw1.equals(raw2) 处按 F7 进入 Object.equals,看它就是一行 return this == obj;——默认行为一目了然;
  2. 在 point1.equals(point2) 处 F7 进入自己写的 equals,逐步执行三次判断:地址 → 类型 → 字段;
  3. 在 points.contains(point2) 处按 F7 进入 HashSet.contains → HashMap.containsKey,观察它先算 hash(key) 再找桶——这就是"先哈希后比较"的实证;
  4. 把 Point.hashCode() 改成 return 1;(所有对象哈希相同),重新运行:HashSet.contains 仍然为 true(equals 兜底),但性能会退化——这能帮你理解两个方法的分工。

9. 常见错误 ​

错误 1:参数类型写错,变成重载 ​

java
@Override
public boolean equals(Point other) {    // 参数是 Point 而不是 Object
    ...
}

如果加了 @Override,编译器会直接报错提醒你——这就是始终写 @Override 的价值。如果不加,它就是一个新重载方法,运行时永远不会被调用。

错误 2:只重写 equals 不重写 hashCode ​

见 6.3 节。修复:两个始终一起写,或直接用 IDE 的生成功能(Alt+Insert → equals() and hashCode())。

错误 3:用可变字段参与计算 ​

见 5.6 节。修复:参与 hashCode 的字段尽量用 final。

错误 4:equals 里没用 Objects.equals 处理 null ​

java
return name.equals(other.name);              // name 为 null 时 NPE
return Objects.equals(name, other.name);     // 安全

错误 5:把 BigDecimal 的 equals 当"数值相等" ​

java
new BigDecimal("1.0").equals(new BigDecimal("1.00"));        // false(精度不同)
new BigDecimal("1.0").compareTo(new BigDecimal("1.00"));     // 0(数值相同)

这是 BigDecimal 特有的陷阱,《浮点精度与 BigDecimal》 会详细讲。

10. 动手实验 ​

目标:亲手体验"只重写一个"和"两个都重写"的差别。

步骤:

  1. 运行第 7 节代码,核对输出;
  2. 复制 Point 为 PointOnlyEquals,删掉 hashCode 方法,放进 HashSet 测试 contains;
  3. 再复制一份 PointBadHash,让 hashCode 固定返回 1,测试 contains 与插入 100 个对象的耗时;
  4. 写一个 User 类(id、name、email),按业务要求实现"id 相同即为同一用户"的 equals/hashCode,并说明为什么不能用 record 默认语义;
  5. 把 User 放进 HashSet,验证"两个 id 相同但 name 不同的对象只保留一个"。

验收方式:能解释第 2 步与第 3 步结果不同的原因。

扩展任务:研究 Objects.hash 的实现(它接受可变参数),思考:为什么它对基本类型会自动装箱?在极端性能敏感的场景下有什么替代方案?

11. 练习题 ​

基础题

  1. 判断:hashCode 相同的两个对象一定相等。(对 / 错)
  2. 填空:equals 为 true 的两个对象,______ 必须相等。
  3. 简答:为什么枚举不需要重写 equals?

代码阅读题

java
public class Item {
    private final String code;
    private final int quantity;

    public Item(String code, int quantity) {
        this.code = code;
        this.quantity = quantity;
    }

    @Override
    public boolean equals(Object other) {
        return other instanceof Item item && code.equals(item.code);
    }

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

Set<Item> items = new HashSet<>();
items.add(new Item("A", 1));
System.out.println(items.contains(new Item("A", 99)));
  1. 输出是什么?这个设计合理吗?

编程题

  1. 写一个 Money 类(金额分值 + 币种):
    • 重写 equals/hashCode,要求金额与币种都相同才算相等;
    • 用 Objects.equals 与 Objects.hash 实现;
    • 测试:把它放进 HashSet,验证 <100, "CNY"> 与 <100, "USD"> 是两个不同的对象。

综合任务

  1. 设计一个"去重逻辑":
    • 有 List<Order>,订单字段为 orderId、userId、amountInCents;
    • 业务要求:orderId 相同的订单视为同一笔,去重后保留"金额最大"的那条;
    • 要求:用 Map<Order, Order> 或 Collectors.toMap 实现,并说明为什么不能直接用 Set<Order>(提示:Set 会保留先加入的那个);
    • 写出你的 Order 的 equals/hashCode 并说明理由。
分级提示

提示 1(方向):第 4 题注意 equals 只比较了 code,hashCode 也只用 code——两个方法是自洽的。

提示 2(关键点):第 6 题如果直接用 Set,重复的订单会被丢弃,你无法控制"保留哪一条"。用 Map 可以做合并。

提示 3(接近答案):第 6 题的 Collectors.toMap(Order::getOrderId, order -> order, (a, b) -> a.amountInCents() >= b.amountInCents() ? a : b) 是关键——第三个参数决定冲突时保留谁。

12. 参考答案 ​

基础题
  1. 错。哈希冲突是允许的:不同对象可以有相同的 hashCode,但 equals 为 false。反过来(equals 为 true 则 hashCode 必相同)才是硬性要求。
  2. hashCode。
  3. 因为枚举常量是单例——每个常量在 JVM 里只有一个对象,== 就是最准确的"相等"判断,不需要也不应该用 equals。
代码阅读题(第 4 题)

输出 true。

因为 equals 与 hashCode 都只基于 code 字段,"A" 相同即视为同一条目——两个方法自洽,所以 HashSet.contains 能找到。

设计是否合理:取决于业务语义。

  • 如果业务上"商品编码相同就是同一个商品"(数量只是当前快照)→ 合理;
  • 如果数量也是身份的一部分 → 不合理,会误判为相等。

这正是重写 equals 的核心决策点:哪些字段代表"身份"。 判断不清时,应该写注释说明理由。

编程题(第 5 题)
java
package cn.kb.learn.common;

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public final class Money {
    private final long amountInCents;
    private final String currency;

    public Money(long amountInCents, String currency) {
        this.amountInCents = amountInCents;
        this.currency = Objects.requireNonNull(currency, "币种不能为空");
    }

    public long amountInCents() {
        return amountInCents;
    }

    public String currency() {
        return currency;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }
        if (other == null || getClass() != other.getClass()) {
            return false;
        }
        Money money = (Money) other;
        return amountInCents == money.amountInCents && Objects.equals(currency, money.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amountInCents, currency);
    }

    @Override
    public String toString() {
        return amountInCents + " 分(" + currency + ")";
    }

    public static void main(String[] args) {
        Set<Money> set = new HashSet<>();
        set.add(new Money(100, "CNY"));
        set.add(new Money(100, "USD"));
        set.add(new Money(100, "CNY"));      // 与第一个重复

        System.out.println("集合大小: " + set.size());
        System.out.println("包含 100 CNY: " + set.contains(new Money(100, "CNY")));
        System.out.println("包含 100 USD: " + set.contains(new Money(100, "USD")));
    }
}

预期输出:

text
集合大小: 2
包含 100 CNY: true
包含 100 USD: true
综合任务(第 6 题)
java
package cn.kb.learn.common;

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;

public final class Order {
    private final String orderId;
    private final String userId;
    private final long amountInCents;

    public Order(String orderId, String userId, long amountInCents) {
        this.orderId = orderId;
        this.userId = userId;
        this.amountInCents = amountInCents;
    }

    public String orderId() {
        return orderId;
    }

    public long amountInCents() {
        return amountInCents;
    }

    /** 身份:只有 orderId;金额与用户可变,不参与身份判定 */
    @Override
    public boolean equals(Object other) {
        return other instanceof Order order && Objects.equals(orderId, order.orderId);
    }

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

    @Override
    public String toString() {
        return orderId + "(" + userId + ", " + amountInCents + " 分)";
    }

    public static void main(String[] args) {
        List<Order> orders = List.of(
                new Order("O1", "u1", 100),
                new Order("O2", "u1", 200),
                new Order("O1", "u1", 300)
        );

        // 用 Set:重复的 O1 会被丢弃,但保留的是"先加入的"那条
        System.out.println("直接去重: " + orders.stream().distinct().toList());

        // 用 Map:冲突时按业务规则合并
        Map<String, Order> merged = orders.stream()
                .collect(Collectors.toMap(Order::orderId, Function.identity(),
                        (first, second) -> first.amountInCents() >= second.amountInCents() ? first : second));
        System.out.println("按金额保留更大的一条: " + merged.values());
    }
}

为什么不能直接用 Set<Order>:Set 的语义是"重复的丢弃",但丢弃哪一条由加入顺序决定,业务无法控制。而 Collectors.toMap 的第三个参数是合并函数,让你显式决定冲突时保留谁——这才是"有业务规则的去重"。

equals/hashCode 的理由:业务上"orderId 相同的订单视为同一笔",所以身份只由 orderId 决定;金额与用户是这笔订单的属性,会随时间变化(比如补款),不应该影响身份判定。

预期输出:

text
直接去重: [O1(u1, 100 分), O2(u1, 200 分)]
按金额保留更大的一条: [O1(u1, 300 分), O2(u1, 200 分)]

13. 掌握标准 ​

  • [ ] 能背出 equals/hashCode 契约,并解释为什么;
  • [ ] 能用 Objects.equals 与 Objects.hash 写出正确重写;
  • [ ] 知道什么时候该用 record 的默认语义、什么时候需要手写"只按 ID 比较";
  • [ ] 能说出"只重写 equals"会导致的具体故障;
  • [ ] 会写 @Override 来捕获签名写错的 bug。

14. 扩展资源 ​

15. 下一步学习 ​

《日期时间 API》:Java 8 引入的时间类为什么比老的 Date 好用,以及日期计算里的常见误区。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引