一个重写了 equals 却忘了重写 hashCode 的 Java 对象,放进 HashSet 后既去不了重、也再也取不出来:一次违背 equals-hashCode 契约的深度复盘
那个 bug 让我对着 HashMap 怀疑人生:我有一个自定义的 User 对象,我明明给它重写了 equals,让"id 相同的两个 User 就算相等"。可当我把它们放进 HashSet 去重时,两个 id 完全相同的 User 居然没被去重,集合里赫然躺着两个"相等"的对象;更邪门的是,我用一个 id 相同的 User 去 map.get(user),明明 key 就在 map 里,却返回了 null——东西放进去了,却再也拿不出来。我对着这段"equals 明明写对了"的代码查了大半天,才终于想起 Java 里那条被我抛在脑后的铁律,后背一凉:在 Java 里,equals 和 hashCode 是一对必须同时重写、且必须保持一致的方法。我只重写了 equals(让 id 相同即相等),却没有重写 hashCode——于是两个 id 相同、equals 判定为"相等"的 User,它们的 hashCode 却用的是 Object 默认的实现(基于对象内存地址),两个不同对象的 hashCode 几乎一定不同。而 HashSet/HashMap 是先按 hashCode 找桶、再用 equals 比对的——hashCode 不同,它俩被分到了不同的桶里,压根没机会走到 equals 那一步,于是"相等"的对象没被去重、放进去的 key 也找不回来。这篇就把这次"只重写 equals 不重写 hashCode"的坑,从头到尾复盘一遍。
故障现场:一个只重写了 equals 的 User
问题代码,是一个只重写了 equals、忘了 hashCode 的类:
// ✗ 出问题的类: 只重写了 equals, 没重写 hashCode
public class User {
private int id;
private String name;
public User(int id, String name) { this.id = id; this.name = name; }
@Override
public boolean equals(Object o) { // ✓ 重写了 equals: id相同就算相等
if (this == o) return true;
if (!(o instanceof User)) return false;
return this.id == ((User) o).id;
}
// ✗ 致命: 没有重写 hashCode()! 用的是 Object 默认的(基于对象内存地址)
// → 两个 id 相同的 User, equals 说它们相等, 但 hashCode 几乎一定不同!
}
// 故障现场:
Set set = new HashSet<>();
set.add(new User(1, "Alice"));
set.add(new User(1, "Alice")); // id相同, 本应被去重
System.out.println(set.size()); // ✗ 输出 2! 没去重!(本应是1)
Map map = new HashMap<>();
map.put(new User(1, "Alice"), "VIP");
String v = map.get(new User(1, "Alice")); // 用id相同的User去取
System.out.println(v); // ✗ 输出 null! 放进去了却取不出来!
// 为什么:
// - HashSet/HashMap 查找/去重的流程是: 先算 key 的 hashCode → 定位到某个"桶(bucket)";
// 再在那个桶里, 用 equals 逐个比对。
// - 两个 id 相同的 User: equals 说相等, 但 hashCode 不同(默认基于地址);
// → 它们被算到了【不同的桶】里; 查找时根本没去对方那个桶, 也就没机会调 equals;
// → 于是: HashSet认为它们不同(没去重)、HashMap在另一个桶找不到(返回null)。
// 关键: hashCode决定"去哪个桶找", equals决定"桶里是不是同一个";
// 只重写equals不重写hashCode → 相等的对象散落在不同桶 → 永远找不到彼此。
第一次理清这个流程时,我恍然又懊恼:"我以为重写 equals 就够了,Java 怎么还要我同时管 hashCode?"这个坑最隐蔽的地方在于:它只在"把对象用作 HashMap/HashSet 的 key"时才暴露——如果你只是用 equals 直接比两个对象(a.equals(b)),它完全正常(你重写的 equals 生效了);只有当对象进了基于哈希的容器,hashCode 的缺失才会酿成"去不了重、取不出来"的诡异后果。而且它不报错:HashSet 不去重、HashMap 取出 null,都是"静默"的错误结果,不会抛任何异常,让你很难联想到是 hashCode 的问题。下面就来拆解,equals 和 hashCode 之间到底是什么契约。
第一件事:搞懂 equals 与 hashCode 的契约,以及 HashMap 怎么工作
我认真重读了 Object 关于 equals/hashCode 的契约,才彻底理解这个坑的根源。
equals 与 hashCode 的契约, 以及 HashMap 的工作原理
【核心: 两个对象 equals 相等, 则它们的 hashCode 必须相等; 否则基于哈希的容器会出错】
1. Object 关于 equals/hashCode 的契约(铁律):
- 如果 a.equals(b) 为 true, 那么 a.hashCode() 必须 == b.hashCode();
- (反之不要求: hashCode相同的两个对象, 不一定equals相等——这叫"哈希冲突", 正常);
- 推论: 你重写了equals, 就【必须】同步重写hashCode, 保证"相等的对象hashCode也相等"。
2. 为什么有这条契约? 因为 HashMap/HashSet 这么工作:
- 存/查一个key时, 第一步: 算 key.hashCode(), 据此定位到一个"桶(bucket)";
- 第二步: 在那个桶里, 用 equals 逐个比对, 找到/确认是不是同一个key。
- → hashCode 负责"快速定位到哪个桶"(为了O(1)的高效);
- → equals 负责"在桶内确认是不是真的相等"。
3. 只重写equals不重写hashCode, 就违背了契约:
- 两个id相同的User: 你的equals说它们"相等";
- 但hashCode用Object默认实现(基于内存地址), 两个对象地址不同→hashCode不同;
- → 违背了"equals相等则hashCode必相等"的契约!
- → 后果: 它们被定位到【不同的桶】, 查找时只去其中一个桶, 找不到另一个;
HashSet以为它们不同(不去重)、HashMap在错误的桶里找不到(返回null)。
4. 正确做法: equals和hashCode要"基于相同的字段":
- equals用id判断相等 → hashCode也必须用id来算;
- 这样"id相同"的对象, 既equals相等、hashCode也相等 → 同一个桶 → 能正确去重/查找。
类比: hashCode是"小区门牌号(去哪栋楼找)", equals是"对身份证(确认是不是这个人)";
你说"id相同就是同一人"(equals), 却给他们编了不同的门牌号(hashCode),
找人时只去其中一栋楼, 当然找不到住在另一栋的"同一个人"。
一句话: equals相等则hashCode必相等(契约); HashMap先用hashCode定位桶、再用equals比对;
重写equals必须同步重写hashCode、且基于相同字段, 否则哈希容器去重/查找全失效。
这套契约,是整个坑的根。Object 的契约(铁律):如果 a.equals(b) 为 true,那么 a.hashCode() 必须等于 b.hashCode();推论是重写了 equals 就必须同步重写 hashCode。为什么有这条契约?因为 HashMap/HashSet 是先算 key.hashCode() 定位到桶、再在桶里用 equals 比对的——hashCode 负责快速定位桶(为了 O(1) 高效)、equals 负责桶内确认相等。只重写 equals 就违背了契约:两个 id 相同的 User、你的 equals 说相等,但 hashCode 用 Object 默认实现(基于地址)、两对象地址不同 hashCode 就不同,于是它们被定位到不同的桶、查找时只去一个桶找不到另一个(HashSet 不去重、HashMap 返回 null)。正解是equals 和 hashCode 基于相同字段(equals 用 id 判等,hashCode 也用 id 算)。就像hashCode 是门牌号(去哪栋楼找)、equals 是对身份证(确认是不是这人);你说 id 相同是同一人却给了不同门牌号,找人只去一栋楼当然找不到。一句话:equals 相等则 hashCode 必相等(契约);HashMap 先用 hashCode 定位桶、再用 equals 比对;重写 equals 必须同步重写 hashCode 且基于相同字段,否则哈希容器去重/查找全失效。
第二件事:正解——equals 和 hashCode 基于相同字段同时重写
搞懂了原理,正解就清晰了:equals 和 hashCode 必须同时重写、且基于相同的字段;用 Objects.equals/Objects.hash 简化;最好用 IDE 生成、或用 record/Lombok 自动生成,避免手写出错。
// ====== 正解一: equals 和 hashCode 基于【相同字段】同时重写 ======
import java.util.Objects;
public class User {
private int id;
private String name;
public User(int id, String name) { this.id = id; this.name = name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return id == user.id; // 基于 id 判等
}
@Override
public int hashCode() {
return Objects.hash(id); // ★ 也基于 id 算hashCode! 和equals一致
}
}
// → 现在 id 相同的两个User: equals相等、hashCode也相等 → 同一个桶 → 正确去重/查找。
// 验证:
Set set = new HashSet<>();
set.add(new User(1, "Alice"));
set.add(new User(1, "Alice"));
System.out.println(set.size()); // ✓ 1, 去重了!
Map map = new HashMap<>();
map.put(new User(1, "Alice"), "VIP");
System.out.println(map.get(new User(1, "Alice"))); // ✓ "VIP", 取到了!
// ====== 正解二: 用 record(Java 16+), 自动生成正确的 equals/hashCode ======
public record User(int id, String name) { }
// → record 自动基于【所有字段】生成 equals、hashCode、toString, 永远一致、不会出错。
// (注意: record基于所有字段; 若你只想用id判等, 还是得自己写或用下面的方式)
// ====== 正解三: 用 IDE 生成 / Lombok ======
// - IDE: "Generate equals() and hashCode()", 选基于哪些字段, 自动生成且配对一致;
// - Lombok: @EqualsAndHashCode(of = "id") 注解, 自动生成基于id的equals和hashCode。
// ====== 关键原则与注意 ======
// 1. equals和hashCode必须"成对出现、基于相同字段"——改了一个就要同步另一个。
// 2. 用作HashMap key / HashSet元素的对象, 最好是【不可变】的:
// 若对象进了集合后, 你改了它参与hashCode的字段(如改了id) → 它的桶位就变了,
// → 它会"迷失"在集合里(在新桶找不到、在老桶还占着) → 又一个诡异bug。
// 所以: 哈希容器的key优先用不可变对象(或至少别改它参与hash的字段)。
// 3. equals还要满足: 自反、对称、传递、一致性(契约的其他部分)。
// 核心: 重写equals必同步重写hashCode、二者基于相同字段; 用Objects.hash/IDE/record/Lombok生成
// 避免手写出错; 哈希容器的key用不可变对象, 别在入集合后改其参与hash的字段。
修复的核心,是"让 equals 和 hashCode 永远基于相同字段、保持一致"。正解一:同时重写、基于相同字段——equals 用 id 判等,hashCode 就用 Objects.hash(id) 也基于 id,二者一致则 id 相同的对象进同一个桶、正确去重/查找。正解二:用 record(Java 16+)——自动基于所有字段生成一致的 equals/hashCode/toString,不会出错。正解三:用 IDE 生成或 Lombok(@EqualsAndHashCode(of="id"))。关键注意:二者必须成对出现、基于相同字段;哈希容器的 key 最好用不可变对象(入集合后若改了参与 hashCode 的字段,它的桶位就变了、会"迷失"在集合里);equals 还要满足自反、对称、传递、一致性。归根结底:重写 equals 必同步重写 hashCode、二者基于相同字段;用 Objects.hash/IDE/record/Lombok 生成避免手写出错;哈希容器 key 用不可变对象、别在入集合后改其参与 hash 的字段。
第三件事:Java equals/集合相关的其他常见坑
排查后我把 Java equals/集合相关的其他常见坑也系统梳理了一遍。
Java equals / 集合的其他常见坑
# 1. 重写equals没重写hashCode(本文): 哈希容器去重/查找失效。→ 同步重写, 基于相同字段。
# 2. 用 == 比较对象: == 比的是引用(地址), 不是内容。→ 比内容用 equals。
# 3. String用==比较: "a"=="a"(常量池)可能true, 但new String("a")==... 是false。→ 用equals。
# 4. Integer的==缓存坑: Integer在-128~127缓存, ==在范围内true、外false。→ 包装类用equals。
# 5. 入集合后修改key的hash字段: 对象"迷失"在集合里, 找不到。→ key用不可变对象。
# 6. equals不满足对称/传递: 如子类和父类equals不对称, 导致集合行为诡异。→ 遵守equals契约。
# 7. 可变对象作HashMap key: 同#5, 改了状态就找不到。→ 用不可变key(如String/Integer/record)。
# 8. 比较器compareTo和equals不一致: TreeMap/TreeSet用compareTo判等, 和equals不一致会出错。
# 共同根源: Java用equals/hashCode/compareTo这套"约定"来定义"相等/顺序", 集合依赖这些约定工作;
# 不遵守约定(漏重写、不一致、用错==), 集合的行为就会以诡异的方式出错。
# 核心: 比内容用equals不用==; 重写equals必同步重写hashCode且一致; 哈希key用不可变对象;
# 遵守equals/hashCode/compareTo的契约——它们是Java集合正确工作的基石。
排查让我把 equals/集合的其他坑也梳理清了。一、重写 equals 没重写 hashCode(本文)。二、用 == 比较对象(== 比引用,用 equals 比内容)。三、String 用 ==(常量池可能 true、new 的 false)。四、Integer 的 == 缓存坑(-128~127 缓存)。五、入集合后改 key 的 hash 字段(对象迷失)。六、equals 不满足对称/传递。七、可变对象作 key。八、compareTo 和 equals 不一致。它们的共同根源是:Java 用 equals/hashCode/compareTo 这套"约定"定义"相等/顺序",集合依赖这些约定工作;不遵守约定(漏重写、不一致、用错 ==),集合就会以诡异方式出错。核心是:比内容用 equals 不用 ==;重写 equals 必同步重写 hashCode 且一致;哈希 key 用不可变对象;遵守 equals/hashCode/compareTo 的契约。下面这张图,是这次 hashCode 缺失坑的成因与解法:
第四件事:equals/hashCode 该怎么做的速查表
这次踩坑后,我把 equals/hashCode 相关的关键点整理成一张表,写自定义类时对照。
| 问题 | 正确做法 | 说明 |
|---|---|---|
| 重写了equals | 必须同步重写hashCode | 否则违背契约 |
| 两者基于什么字段 | 基于相同字段 | equals用啥hashCode也用啥 |
| 怎么写hashCode | Objects.hash(字段...) | 别手写易错 |
| 怎么写equals | 先==, 再instanceof, 再比字段 | 或Objects.equals |
| 最省事的方式 | record/IDE生成/Lombok | 自动一致 |
| 哈希容器的key | 用不可变对象 | 防入集合后改hash字段迷失 |
这张表把 equals/hashCode 钉清了。核心是:equals 和 hashCode 是一对"必须配对、必须一致"的方法——同时重写、基于相同字段、用工具(Objects.hash/record/IDE/Lombok)生成以保证一致,且作为哈希 key 的对象最好不可变。它给我的最大启发是:编程语言/框架里有很多这种"隐式的约定/契约(implicit contract)"——它们不由编译器强制(你不重写 hashCode 照样能编译通过),而是靠开发者自觉遵守;一旦违背,后果不是"编译报错",而是"运行时以诡异的方式出错";equals-hashCode 契约、Comparable 的传递性、迭代器的 fail-fast、不可变对象的约定……都属于这类"编译器管不着、但你必须遵守"的约定。这让我意识到一个进阶的功课:真正掌握一门语言/框架,不仅要会用它的语法(编译器会教你),更要懂它那些"编译器不会告诉你、但违背了就出错"的隐式契约——这些契约往往写在文档(如 Object.hashCode 的 Javadoc)、《Effective Java》这类书、和前人踩过的坑里;"知道语法"是入门,"懂得契约"才是进阶。重视并遵守 equals-hashCode 这类编译器不强制的隐式契约——是这个坑带给我的进阶认知。
第五件事:从"能编译"到"符合契约"的距离
这次最值得反思的,是这个 bug"编译器一声没吭"。我把"编译器能查的"和"编译器查不了的"对比成表。
| 类型 | 编译器能否发现 | 例子 |
|---|---|---|
| 语法错误 | ✓ 能 | 少分号、括号不匹配 |
| 类型错误 | ✓ 能 | String赋给int |
| 未重写hashCode(本文) | ✗ 不能 | 违背隐式契约 |
| 逻辑错误 | ✗ 不能 | 算法写错 |
| 违反约定/契约 | ✗ 多数不能 | equals不对称等 |
| 并发/时序问题 | ✗ 不能 | 数据竞争 |
这张表道出了一个清醒的认知边界。核心是:编译器能帮你挡住的,是"语法"和"类型"这类形式上的错误;但它挡不住"逻辑错误、违背契约、并发问题"这类语义/约定层面的错误——本文的"没重写 hashCode",编译器一声不吭,因为它语法、类型都对,只是违背了一个编译器无从知晓的约定。它给我的深刻启发是:"编译通过"离"代码正确"还有很远的距离——编译通过只意味着"形式上合法",而正确性还需要逻辑对、契约守、并发安全、边界处理周全这些编译器管不了的东西;把"编译通过/没报错"当成"代码没问题",是一种危险的盲目乐观。这让我对保障代码质量有了更全面的认识:正因为编译器只能覆盖一部分错误,我们才需要其他层次的防线来补上它够不着的地方——单元测试(查逻辑)、code review(查契约/设计)、静态分析工具(查一部分隐式契约,如有的 linter 能警告"重写 equals 未重写 hashCode")、文档和规范(传递约定);"编译器 + 测试 + review + 静态分析"层层设防,才能覆盖从"语法"到"语义"到"契约"的完整正确性。认清编译通过不等于正确、用多层防线补上编译器够不着的地方——是这个坑带给我的、关于代码质量保障的体会。
第六件事:写自定义类时,我现在的检查习惯
现在每当我写一个自定义类,我都会按这张图先想一遍 equals/hashCode:
这张图的精髓,是"要按内容判等就同时重写 equals 和 hashCode、基于相同字段、优先自动生成"。类要按内容判等就重写 equals 并同步重写 hashCode、二者基于相同字段;会进哈希容器就确保不可变;优先用 record/IDE/Lombok 生成别手写。这套习惯,让我从"随手只重写 equals"变成了"重写 equals 必想 hashCode"——核心始终是:equals 和 hashCode 是一对、必须同时重写且基于相同字段,哈希 key 用不可变对象。
我立下的几条规矩
这场"只重写 equals 不重写 hashCode"的事故,换来了我写 Java 时,刻进骨子里的几条铁律:
- 重写 equals 必须同步重写 hashCode。它们是一对,绝不能只写一个。
- equals 和 hashCode 必须基于相同字段。equals 用啥判等,hashCode 就用啥算。
- equals 相等则 hashCode 必相等。这是 Object 的契约,违背则哈希容器出错。
- HashMap 先用 hashCode 定位桶、再用 equals 比对。理解这个才懂为何要配对。
- 哈希容器的 key 用不可变对象。入集合后改 hash 字段会让对象迷失。
- 优先用 record/IDE/Lombok 生成。别手写,自动保证一致。
- 编译通过不等于正确。隐式契约靠自觉+测试+review+静态分析守住。
附:用一段小程序看清"桶定位"的过程
为了让团队彻底理解"hashCode 决定去哪个桶",我写了一段小程序把这个过程打印出来。
// 打印两个"相等"对象的 hashCode 和它们会落到的桶
User a = new User(1, "Alice");
User b = new User(1, "Alice");
System.out.println("a.equals(b) = " + a.equals(b)); // 重写了equals → true
System.out.println("a.hashCode = " + a.hashCode());
System.out.println("b.hashCode = " + b.hashCode());
// HashMap默认16个桶, 桶下标 = (hashCode 扰动后) & (容量-1)
int cap = 16;
System.out.println("a的桶 = " + (a.hashCode() & (cap - 1)));
System.out.println("b的桶 = " + (b.hashCode() & (cap - 1)));
// 没重写hashCode时: 两个hashCode不同 → 两个桶下标大概率不同 → a、b进了不同桶 → 找不到彼此;
// 重写hashCode后(基于id): 两个hashCode相同 → 桶下标相同 → 同一个桶 → 能正确去重/查找。
这段能跑的小程序,把"对象落到哪个桶"这件看不见的事变得一目了然。核心是:通过打印 hashCode 和算出的桶下标(hashCode & (容量-1)),亲眼看到——没重写 hashCode 时两个"相等"对象落进不同桶(找不到彼此)、重写后落进同一个桶(能找到);抽象的"契约"瞬间变成了看得见的"桶号"。它再次印证了我学习底层机制的方法:把"看不见的内部机制"用最小的代码打印出来、亲眼验证,远比死记规则理解得深——当你看到"a 在 3 号桶、b 在 7 号桶",就再也不会忘记"为什么相等的对象会找不到"了。用小程序把哈希桶定位可视化、亲眼看懂契约——是这个坑教我的收尾一招。
写在最后
回头看,这场由"忘了重写 hashCode"引发的、对象放进集合却取不出来的事故,真正教给我的,远不止"重写 equals 要同时重写 hashCode"这一条规则。它让我对"很多东西是成对存在、必须协同的,只动其一就会破坏它们之间的一致性",有了一次刻骨的体会。我栽跟头,根源在于我把 equals 和 hashCode 当成了两个独立的方法,以为"我要的是'相等判断',那重写 equals 就够了,hashCode 跟我没关系"。可它们其实是一个"对象相等性"概念的两个协同面——equals 定义"什么叫相等",hashCode 则要"与这个相等定义保持一致"(相等的对象给出相同的哈希),它们共同、一致地支撑起"对象在哈希容器里如何被识别"这件事;我只改了 equals 而没动 hashCode,就让这两个本该一致的面产生了矛盾(equals 说相等、hashCode 说不同),而正是这个矛盾,让依赖它们协同工作的哈希容器彻底乱了套。这让我领悟到一个普适的认知:系统里有大量"成对/成组、必须协同保持一致"的东西——equals 与 hashCode、compareTo 与 equals、构造与析构、加锁与解锁、申请与释放、序列化与反序列化、读与写的格式、接口与实现的约定;它们的正确性不在于单个写得对,而在于'彼此之间是否一致';只修改其中一个、而忘了同步另一个,是一类极其常见的 bug 来源。这给了我一种"成对思维"的警觉:每当我修改一个"有搭档"的东西时,就条件反射地问一句:"它有没有一个必须保持一致的'另一半'?我改了它,那一半要不要跟着改?"——改了 equals 想到 hashCode、改了序列化格式想到反序列化、加了锁想到在所有路径上解锁、改了数据结构想到所有读写它的地方;"成对的东西要成对地改",是避开一大类一致性 bug 的关键意识。建立"成对协同、改一个必同步另一个"的一致性思维——这,是我用一次 hashCode 缺失的事故,换来的、关于 Java、也关于如何维护一切"协同一致性"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次重写 equals 时,手指自然地接着去写那个 hashCode,那我对着那个取不出值的 HashMap 排查的这大半天,就值了。
—— 别看了 · 2026