主题
界面
主题
"两个对象相等"这件事,在 Java 里需要你自己定义。本节讲清三件事:默认行为是什么、该怎么重写、以及为什么两个方法必须成对重写。
这是面试高频题("为什么重写 equals 还要重写 hashCode"),也是日常最容易埋雷的地方——不重写时,HashSet 去重、HashMap 查找、List.contains 全都会"莫名其妙"失效。
完成本节后,你应该能够:
Object 默认的 equals 与 hashCode 的行为;Objects.equals 与 Objects.hash 写出正确的重写;record 会自动生成它们,并说清适用边界;equals 参数类型写错(变成重载而非重写)这个隐蔽 bug。== 比较地址吗?(《类与对象》)HashSet / HashMap 吗?(《HashMap》、《HashSet、TreeSet 与集合排序》 会深入)看一段"看起来没问题"的代码:
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 类可以正常比较——因为它们已经重写好了。所以这件事不是"可选优化",而是"自定义类型进入集合前的必修课"。
Object first = new Object();
Object second = new Object();
first.equals(second); // false —— 比较地址| 方法 | 默认实现 | 含义 |
|---|---|---|
equals | this == other | 同一个对象才相等 |
hashCode | 由对象地址派生的整数 | 不同对象几乎不同 |
这也是为什么"内容相同的自定义对象"默认不相等。
1. equals 为 true 的两个对象,hashCode 必须相等 ← 硬性要求
2. hashCode 相等的两个对象,equals 不一定为 true ← 允许哈希冲突
3. equals 必须满足:自反、对称、传递、一致、与 null 比较返回 false第 1 条是全部问题的根源。集合类(HashSet、HashMap)先用 hashCode 定位"桶",再用 equals 精确匹配。如果两个"相等"的对象哈希值不同,它们会被放进不同的桶,于是永远找不到对方。
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 简化
}
}三个细节:
getClass():用 instanceof 的话,子类实例可能被判为"等于"父类实例,破坏对称性;==,引用类型用 Objects.equals(a, b)(它会处理 null)。public record PointRecord(int x, int y) { }编译器自动生成 equals、hashCode、toString,语义就是"所有字段都相等"。
适用边界:
| 场景 | 用 record |
|---|---|
| 值对象、DTO、参数对象、返回值 | 合适 |
| 需要自定义相等语义(如只按 ID 比较) | 不合适,需手写 |
| 需要可变字段 | 不合适(record 字段是 final) |
"只按 ID 比较"是很常见的业务需求:
// 两个用户对象,只要 ID 相同就算同一个人(其他字段可能一个是旧的、一个是新的)
@Override
public boolean equals(Object other) {
return other instanceof User user && Objects.equals(id, user.id);
}这种情况不能用 record 的默认语义。
| 类型 | 是否重写 | 说明 |
|---|---|---|
String / Integer / LocalDate 等 JDK 类 | 已重写 | 直接用 |
| 枚举 | 不需要 | 常量是单例,== 就是最准确的比较 |
只用 == 比较的对象(如无状态的工具类实例) | 可以不重写 | 但不要放进集合 |
任何要放进 Set / 作为 Map key 的对象 | 必须重写 | 否则行为不符合预期 |
Set<Point> set = new HashSet<>();
Point point = new MutablePoint(1, 2);
set.add(point);
point.setX(99); // 哈希值变了
set.contains(point); // false —— 它躺在错误的桶里结论:参与 hashCode 的字段应该不可变。这也是 《String 与不可变性》 强调"key 用不可变类型"的原因。
// 未重写
new RawPoint(1, 2).equals(new RawPoint(1, 2)); // false
// 重写后
new Point(1, 2).equals(new Point(1, 2)); // trueSet<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 最直接的收益。
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 条"被破坏的真实后果。
文件:labs/common-classes/src/main/java/cn/kb/learn/common/ 下的 Point.java、PointRecord.java、EqualsLesson.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 + ")";
}
}package cn.kb.learn.common;
/** 同样的语义用 record 表达:编译器自动生成 equals、hashCode、toString */
public record PointRecord(int x, int y) {
}运行命令:
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.EqualsLesson预期输出:
两个新对象 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: false | Object 默认比较地址 |
| 未重写 equals: false | 内容相同的自定义对象不相等 |
| 重写后 equals / hashCode | 契约被满足 |
| HashSet / HashMap 两行 | 集合能按内容查找 |
| record 三行 | 不用手写就有同样的能力 |
| 最后一行 | 不同类型的对象不相等(getClass() 检查生效) |
raw1.equals(raw2) 处按 F7 进入 Object.equals,看它就是一行 return this == obj;——默认行为一目了然;point1.equals(point2) 处 F7 进入自己写的 equals,逐步执行三次判断:地址 → 类型 → 字段;points.contains(point2) 处按 F7 进入 HashSet.contains → HashMap.containsKey,观察它先算 hash(key) 再找桶——这就是"先哈希后比较"的实证;Point.hashCode() 改成 return 1;(所有对象哈希相同),重新运行:HashSet.contains 仍然为 true(equals 兜底),但性能会退化——这能帮你理解两个方法的分工。@Override
public boolean equals(Point other) { // 参数是 Point 而不是 Object
...
}如果加了 @Override,编译器会直接报错提醒你——这就是始终写 @Override 的价值。如果不加,它就是一个新重载方法,运行时永远不会被调用。
见 6.3 节。修复:两个始终一起写,或直接用 IDE 的生成功能(Alt+Insert → equals() and hashCode())。
见 5.6 节。修复:参与 hashCode 的字段尽量用 final。
equals 里没用 Objects.equals 处理 null return name.equals(other.name); // name 为 null 时 NPE
return Objects.equals(name, other.name); // 安全BigDecimal 的 equals 当"数值相等" new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false(精度不同)
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")); // 0(数值相同)这是 BigDecimal 特有的陷阱,《浮点精度与 BigDecimal》 会详细讲。
目标:亲手体验"只重写一个"和"两个都重写"的差别。
步骤:
Point 为 PointOnlyEquals,删掉 hashCode 方法,放进 HashSet 测试 contains;PointBadHash,让 hashCode 固定返回 1,测试 contains 与插入 100 个对象的耗时;User 类(id、name、email),按业务要求实现"id 相同即为同一用户"的 equals/hashCode,并说明为什么不能用 record 默认语义;User 放进 HashSet,验证"两个 id 相同但 name 不同的对象只保留一个"。验收方式:能解释第 2 步与第 3 步结果不同的原因。
扩展任务:研究 Objects.hash 的实现(它接受可变参数),思考:为什么它对基本类型会自动装箱?在极端性能敏感的场景下有什么替代方案?
基础题
代码阅读题
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)));编程题
Money 类(金额分值 + 币种): Objects.equals 与 Objects.hash 实现;HashSet,验证 <100, "CNY"> 与 <100, "USD"> 是两个不同的对象。综合任务
List<Order>,订单字段为 orderId、userId、amountInCents;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) 是关键——第三个参数决定冲突时保留谁。
hashCode,但 equals 为 false。反过来(equals 为 true 则 hashCode 必相同)才是硬性要求。hashCode。== 就是最准确的"相等"判断,不需要也不应该用 equals。输出 true。
因为 equals 与 hashCode 都只基于 code 字段,"A" 相同即视为同一条目——两个方法自洽,所以 HashSet.contains 能找到。
设计是否合理:取决于业务语义。
这正是重写 equals 的核心决策点:哪些字段代表"身份"。 判断不清时,应该写注释说明理由。
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")));
}
}预期输出:
集合大小: 2
包含 100 CNY: true
包含 100 USD: truepackage 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 决定;金额与用户是这笔订单的属性,会随时间变化(比如补款),不应该影响身份判定。
预期输出:
直接去重: [O1(u1, 100 分), O2(u1, 200 分)]
按金额保留更大的一条: [O1(u1, 300 分), O2(u1, 200 分)]Objects.equals 与 Objects.hash 写出正确重写;record 的默认语义、什么时候需要手写"只按 ID 比较";@Override 来捕获签名写错的 bug。equals 与 hashCode 的契约原文equals / hash / requireNonNull《日期时间 API》:Java 8 引入的时间类为什么比老的 Date 好用,以及日期计算里的常见误区。
暂无