我自定义对象重写了 equals 判断相等,放进 HashSet 却还是有重复、用它当 HashMap 的 key 也取不到值,我对着 equals 和 hashCode 排查了大半天的复盘
那是我用 Java 写的一段去重逻辑。我有个自定义类 User,我重写了它的 equals 方法,规定"只要 id 相同就算同一个用户"。然后我把一堆 User 放进 HashSet 想去重,又用 User 当 HashMap 的 key 存了些数据。结果诡异极了:明明 id 相同的两个 User,放进 HashSet 后竟然没被去重(集合里有两个 id 一样的);用一个 id 相同的 User 去 HashMap 里 get,竟然取不到之前用另一个 id 相同的 User 存进去的值(返回 null)。我盯着 equals 反复看:我明明重写了 equals、id 相同就返回 true 啊,为什么 HashSet/HashMap 不认?排查了大半天,我才真正理解了那条被无数 Java 程序员忽略的铁律:重写 equals 必须同时重写 hashCode。这篇就把这场"只重写 equals 导致集合失效"的事故,从头复盘一遍。
故障现场:equals 说相等,HashSet/HashMap 却不认
先看现场。问题就藏在"只重写了 equals、没重写 hashCode"上:
class User {
int id;
String name;
User(int id, String name) { this.id = id; this.name = name; }
// ✓ 我重写了 equals: id 相同就算相等
@Override
public boolean equals(Object o) {
if (!(o instanceof User)) return false;
return this.id == ((User) o).id;
}
// ✗✗ 但是: 我没有重写 hashCode!
}
User u1 = new User(1, "张三");
User u2 = new User(1, "张三的另一个对象"); // id 也是1, equals 认为它俩相等
System.out.println(u1.equals(u2)); // true ✓ (我重写的equals生效)
// 坑1: 放进 HashSet 不去重
Set set = new HashSet<>();
set.add(u1);
set.add(u2);
System.out.println(set.size()); // ✗ 2! (没去重! 应该是1才对)
// 坑2: 用它当 HashMap 的 key 取不到
Map map = new HashMap<>();
map.put(u1, "数据A");
System.out.println(map.get(u2)); // ✗ null! (取不到! u1和u2明明equals相等)
// 为什么? HashSet/HashMap 是先用 hashCode 找"桶", 再用 equals 比对:
// 1. HashMap/HashSet 底层是哈希表, 查找/去重分两步:
// 第一步: 用 key 的 hashCode() 算出它该在哪个"桶(bucket)"。
// 第二步: 在那个桶里, 用 equals() 逐个比对, 找到相等的。
// 2. 我【没重写 hashCode】, 用的是 Object 默认的 hashCode:
// → Object 的默认 hashCode 是基于"对象内存地址"的, 每个new出来的对象都不同!
// → u1 和 u2 虽然 equals 相等, 但它们的 hashCode 【不同】!
// 3. 所以: u1 和 u2 被算到了【不同的桶】里:
// - HashSet: u2 进来时, 算 hashCode 到了和 u1 不同的桶, 根本不会和 u1 比equals
// → 以为是新元素, 加进去 → 没去重(size=2)。
// - HashMap: get(u2) 时, 算 u2 的 hashCode 到了一个桶, 但 u1 存在另一个桶里
// → 在 u2 的桶里找不到 → 返回 null。
// 4. ★ 即: hashCode 不同 → 压根到不了同一个桶 → equals 根本没机会比!
// 现象拼图:
// - HashMap/HashSet 先用 hashCode 定位桶, 再用 equals 比对。
// - 只重写 equals 没重写 hashCode: equals 相等的对象 hashCode 却不同,
// → 它们到了不同的桶, equals 根本没机会发挥作用。
// - ★ 根因: equals 和 hashCode 必须"协同一致", 我只改了一半。
看清真相后,我才明白这"equals 说相等却不认"的根子。问题的根源,是 HashSet/HashMap 的查找机制:它底层是哈希表,查找/去重分两步——第一步用 key 的 hashCode() 算出它该在哪个"桶",第二步在那个桶里用 equals() 逐个比对。而我只重写了 equals、没重写 hashCode,用的是 Object 默认的 hashCode(基于对象内存地址,每个 new 出来的对象都不同)。所以,u1 和 u2 虽然 equals 相等,但 hashCode 不同,被算到了不同的桶里:HashSet 里 u2 进来时算到了和 u1 不同的桶、根本不会和 u1 比 equals,以为是新元素就加进去了(没去重);HashMap 里 get(u2) 算到一个桶、但 u1 存在另一个桶,在 u2 的桶里找不到就返回 null。关键是:hashCode 不同 → 压根到不了同一个桶 → equals 根本没机会比。根因是:equals 和 hashCode 必须"协同一致",我只改了一半。
第一件事:搞懂 equals 和 hashCode 的契约
要解决它,得先搞懂 equals 和 hashCode 之间那条必须遵守的"契约"。
equals 和 hashCode 的契约
# 一、哈希表(HashMap/HashSet)是怎么工作的?
# - 它不是"逐个遍历比较", 而是"用hashCode快速定位 + equals精确比对":
# 存: 用 key.hashCode() 算出桶位置, 放进去(同桶内若equals相等则覆盖)。
# 取/查: 用 key.hashCode() 算出桶位置, 在该桶内用 equals 找相等的。
# - 这样查找接近 O(1)(直接算到桶), 而不是 O(n)(逐个比)。
# 二、契约(Object文档明确规定, 必须遵守):
# 1. 如果两个对象 equals 相等, 那它们的 hashCode 【必须相等】! ★最关键★
# (否则它们会到不同的桶, 哈希表认为它们不相等 → 失效)
# 2. 如果 hashCode 相等, equals 不一定相等(允许哈希冲突, 同桶内再用equals分辨)。
# 3. hashCode 要稳定: 对象不变时, 多次调用返回同一个值。
# 三、为什么"重写equals必须重写hashCode"?
# - 你重写 equals, 是想定义"什么样的对象算相等"(如id相同)。
# - 但如果不重写 hashCode, equals相等的对象 hashCode 却不同(默认基于地址),
# → 违反契约1! → 它们到不同桶 → 哈希表里equals失效(本文的坑)。
# - 所以: 重写equals时, 必须【同步重写hashCode】, 保证
# "equals相等的对象, hashCode也相等"(通常用equals用到的那些字段算hashCode)。
# 四、反过来呢? 只重写hashCode不重写equals?
# - 也不行: hashCode相同会到同一个桶, 但桶内还要用equals分辨,
# equals没重写(用默认地址比较)→ 还是认为不相等 → 同样失效。
# - 结论: equals 和 hashCode 要么都不重写, 要么【一起】重写, 且保持一致。
# 核心: 哈希表先用hashCode定位桶再用equals比对; 契约规定"equals相等则hashCode必须相等";
# 重写equals必须同步重写hashCode(用相同字段), 否则equals相等的对象hashCode不同、哈希表失效。
想透 equals 和 hashCode 的契约,这个坑就清楚了。一、哈希表怎么工作?——不是逐个遍历比较,而是"用 hashCode 快速定位桶 + equals 精确比对":存时用 hashCode 算桶位置放进去,取/查时用 hashCode 算桶位置、在该桶内用 equals 找相等的,这样查找接近 O(1)。二、契约(Object 文档明确规定):① 如果两个对象 equals 相等,它们的 hashCode 必须相等(最关键!否则到不同桶、哈希表认为不相等);② hashCode 相等 equals 不一定相等(允许哈希冲突);③ hashCode 要稳定。三、为什么"重写 equals 必须重写 hashCode"?——你重写 equals 定义了"什么算相等",但不重写 hashCode 的话,equals 相等的对象 hashCode 却不同(默认基于地址)、违反契约 ①,它们到不同桶、哈希表里 equals 失效(本文的坑);所以重写 equals 时必须同步重写 hashCode(通常用 equals 用到的那些字段算 hashCode)。四、反过来只重写 hashCode 不重写 equals 也不行——同桶内还要用 equals 分辨;结论:equals 和 hashCode 要么都不重写,要么一起重写、且保持一致。
第二件事:正解——equals 和 hashCode 用相同字段一起重写
搞懂了原理,正解就清晰了:重写 equals 时用 Objects.equals/Objects.hash 同步重写 hashCode、用相同的字段、或用 IDE/Lombok/record 自动生成。
import java.util.Objects;
// ====== 正解一: equals 和 hashCode 用"相同字段"一起重写 ======
class User {
int id;
String 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; // equals 用 id 判断
}
@Override
public int hashCode() {
return Objects.hash(id); // ★ hashCode 也用 id! 和 equals 用同样的字段
}
// → 现在 equals 相等(id相同)的对象, hashCode 也相等(都基于id)→ 契约满足!
// HashSet能正确去重, HashMap能正确get。
}
// ★ 关键: equals 和 hashCode 必须用【相同的字段】计算, 才能保证一致!
// (如果 equals 用 id+name, hashCode 也要用 id+name)
// ====== 正解二: 用 java.util.Objects 工具类(推荐, 简洁安全)======
// equals 多字段:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User u = (User) o;
return id == u.id && Objects.equals(name, u.name); // Objects.equals 处理null
}
@Override
public int hashCode() {
return Objects.hash(id, name); // Objects.hash 处理多字段, 简洁
}
// ====== 正解三: 用 IDE 自动生成(最省心)======
// IDEA: 右键 → Generate → equals() and hashCode(), 选字段, 自动生成且保证一致。
// → 别手写(容易写错/漏字段/不一致), 让IDE生成最稳。
// ====== 正解四: 用 Lombok 注解(减少样板代码)======
// @EqualsAndHashCode // Lombok 自动生成 equals 和 hashCode(保证一致)
// @EqualsAndHashCode(of = {"id"}) // 指定只用 id
// class User { ... }
// ====== 正解五(Java 14+, 最推荐): 用 record ======
record User(int id, String name) {}
// → record 自动生成 equals/hashCode/toString(基于所有字段, 且保证一致)!
// 对"不可变数据类", record 是最简洁、最安全的选择, 完全不用操心这个坑。
// ====== 注意: hashCode 要用"不可变/稳定"的字段 ======
// - 如果用可变字段算hashCode, 对象放进HashSet后改了那个字段,
// 它的hashCode变了 → 在桶里再也找不到它了(又一个坑)!
// - 所以: 用作hashCode的字段, 最好是"对象生命周期内不变"的(如id)。
// 核心: 重写equals必须同步重写hashCode且用相同字段; 用Objects.equals/Objects.hash简化、
// 或IDE自动生成/Lombok/record(最推荐); hashCode用不可变字段(否则放进集合后改了又找不到)。
修复的核心,是"重写 equals 时,必须用相同的字段同步重写 hashCode,保证两者一致"。正解一:equals 和 hashCode 用"相同字段"一起重写——equals 用 id 判断,hashCode 也用 Objects.hash(id),这样 equals 相等的对象 hashCode 也相等、契约满足,HashSet 能去重、HashMap 能 get;关键是必须用相同字段计算(equals 用 id+name,hashCode 也要用 id+name)。正解二:用 java.util.Objects 工具类(Objects.equals 处理 null、Objects.hash 处理多字段,简洁安全)。正解三:用 IDE 自动生成(最省心)——IDEA 的 Generate → equals() and hashCode(),自动生成且保证一致,别手写。正解四:用 Lombok 的 @EqualsAndHashCode 注解。正解五(Java 14+,最推荐):用 record——自动生成 equals/hashCode/toString(基于所有字段、保证一致),对不可变数据类是最简洁安全的选择,完全不用操心这个坑。还有个注意点:hashCode 要用"不可变/稳定"的字段——用可变字段算 hashCode,对象放进 HashSet 后改了那个字段、hashCode 变了,就在桶里再也找不到它了(又一个坑);所以用不变的字段(如 id)。归根结底:重写 equals 必须同步重写 hashCode 且用相同字段;用 Objects.hash 简化、或 IDE/Lombok/record(最推荐);hashCode 用不可变字段。
第三件事:equals/hashCode 的其他常见坑
排查后我把 equals/hashCode 相关的其他常见坑也系统梳理了一遍。
equals/hashCode 的其他常见坑
# 1. 只重写equals不重写hashCode(本文): 哈希集合失效。→ 一起重写。
# 2. equals 和 hashCode 用了不同的字段:
# equals 用 id+name, hashCode 只用 id → 还是可能违反契约的边界情况。
# → 必须用【完全相同】的字段。
# 3. hashCode 用了可变字段, 对象进集合后改了该字段:
# → hashCode变了, 在桶里找不到它了(集合里有它却get不到/remove不掉)。
# → 用作 key 的对象, 进集合后别改它参与hashCode的字段(或用不可变对象当key)。
# 4. equals 没遵守"自反/对称/传递/一致"性:
# - 自反: a.equals(a) 必须true。
# - 对称: a.equals(b) 和 b.equals(a) 结果一致。
# - 传递: a=b, b=c → a=c。
# - 一致: 多次调用结果一致。
# → 重写equals要满足这些(尤其继承时容易破坏对称/传递)。
# 5. 用 == 比较对象(而非equals):
# "abc" == new String("abc") // false! == 比的是引用, 不是内容。
# → 比较对象内容用 equals, 不用 ==(== 只对基本类型/判断同一对象)。
# 6. 继承时的 equals: 父子类 equals 容易破坏对称性。
# → 用 getClass() 而非 instanceof, 或用组合代替继承, 或 record/final。
# 核心: equals/hashCode坑还有 用不同字段、用可变字段(进集合改了找不到)、违反自反对称传递、
# 用==比内容、继承破坏对称; 核心是equals和hashCode用相同不可变字段、满足契约、用record最省心。
排查让我把 equals/hashCode 的其他坑也梳理清了。一、只重写 equals 不重写 hashCode(本文)。二、equals 和 hashCode 用了不同字段(必须用完全相同的字段)。三、hashCode 用了可变字段、对象进集合后改了该字段——hashCode 变了、在桶里找不到它了(集合里有它却 get 不到/remove 不掉);用作 key 的对象进集合后别改参与 hashCode 的字段。四、equals 没遵守自反/对称/传递/一致性(尤其继承时易破坏)。五、用 == 比较对象内容(== 比引用不比内容,比内容用 equals)。六、继承时 equals 易破坏对称性(用 getClass() 而非 instanceof,或用 record/final)。它们的核心是:equals 和 hashCode 用相同的不可变字段、满足契约、用 record 最省心。下面这张图,是这次集合失效的成因与解法:
第四件事:重写 equals/hashCode 的几种方式对比
这次踩坑后,我把重写 equals/hashCode 的几种方式整理成一张表,按场景选。
| 方式 | 一致性保证 | 样板代码 | 适用 |
|---|---|---|---|
| 手写 | ✗ 易写错/不一致 | 多 | 不推荐(易漏字段) |
| Objects.hash/equals | △ 靠自己用对字段 | 较少 | 手写时用,简化 |
| IDE 生成 | ✓ 保证一致 | 多但自动 | 普通类 |
| Lombok @EqualsAndHashCode | ✓ 保证一致 | 一个注解 | 用 Lombok 的项目 |
| record(Java14+) | ✓ 自动且一致 | 几乎为0 | 不可变数据类(最推荐) |
这张表,把重写 equals/hashCode 的几种方式摆清了:手写易错(最不推荐)、Objects.hash 简化手写、IDE 生成/Lombok/record 都能保证一致,其中 record 最省心。它给我的最大启发是:对于"容易写错、且必须保持一致"的样板代码(equals/hashCode 这种"必须配对、必须用相同字段"的),最好的办法不是"小心翼翼地手写",而是"用工具自动生成或语言特性消除它"。因为"必须保持一致的两段代码",靠人去手动维护一致性是不可靠的——你改了 equals 的字段、很容易忘了同步改 hashCode;而工具生成(IDE/Lombok)或语言特性(record)能从机制上保证这种一致性,根本不给"不一致"留机会。这呼应了一个我反复体会的工程思想:"需要人去维护的一致性/正确性",迟早会因为人的疏忽而被破坏;而"由工具/机制自动保证的一致性/正确性",才是可靠的。尤其是 record——它通过"把'不可变数据类'变成一种语言特性",一举消除了 equals/hashCode/toString/构造器/getter 这一大堆样板代码及其潜在的不一致。能用语言特性/工具消除的样板和易错点,就别用人力去维护——这是减少 bug 最高效的方式之一。
第五件事:哈希集合的正确使用要点
这次的坑根在哈希集合,我也借机把哈希集合(HashMap/HashSet)的正确使用要点梳理了一遍。
| 要点 | 问题 | 正确做法 |
|---|---|---|
| 自定义对象做key/元素 | 不重写hashCode则失效(本文) | 同时重写equals和hashCode |
| key用可变对象 | 改了字段后找不到 | 用不可变对象当key |
| hashCode质量差 | 都算到一个桶,退化成O(n) | hashCode要分散均匀 |
| 并发读写HashMap | 非线程安全,可能死循环/数据错 | 用ConcurrentHashMap |
| 遍历时修改 | ConcurrentModificationException | 用迭代器remove/removeIf |
| 依赖遍历顺序 | HashMap无序 | 要顺序用LinkedHashMap/TreeMap |
这张表,把哈希集合的正确使用要点串了起来。几个高频的:自定义对象做 key/元素必须同时重写 equals 和 hashCode(本文)、key 用不可变对象、hashCode 要分散均匀(否则退化成 O(n))、并发用 ConcurrentHashMap、遍历时改用迭代器、要顺序用 LinkedHashMap/TreeMap。它给我的最大启发是:HashMap/HashSet 是我们every day都在用的"最基础的数据结构",但要"正确、高效"地用它,背后有这么多讲究(hashCode 契约、不可变 key、分散性、并发安全、遍历);而它的高效(O(1) 查找)是有"前提条件"的——前提就是"key 的 hashCode 实现正确且分散",一旦这个前提被破坏(如本文不重写 hashCode、或 hashCode 都返回同一个值),它就会失效或退化。这让我领悟到:越是"基础、常用、看起来简单"的工具(哈希表、数组、字符串),越要理解它"正确高效工作的前提条件";因为我们用得太顺手、太频繁,反而容易忘了它"是有前提的"——而一旦不经意间破坏了那个前提,就会踩坑。用好基础工具的关键,是理解并维护好它高效工作的那些"隐含前提"。
第六件事:定义一个要放进集合的类时,我现在的习惯
现在每当我定义一个类、且它可能被放进 HashSet/HashMap,我都会按这张图走一遍:
这张图的精髓,是"定义类时,先想它会不会进哈希集合、要不要按内容判等"。第一问 "它会被放进 HashSet/HashMap 或需要按内容判等吗":不会(只按引用)就不用重写;会就看是不是不可变数据类。是不可变数据类(Java14+)就直接用 record(自动生成且一致,最推荐);否则同时重写 equals 和 hashCode、用完全相同的字段、用 IDE 生成或 Objects.hash、且 hashCode 用不可变字段。最后验证放进 HashSet 能去重、HashMap 能 get 到。这套习惯,让我定义类时,从"重写个 equals 就完事"变成了"想清楚要不要进集合、equals 和 hashCode 一起重写并验证"——核心始终是:要按内容判等并进哈希集合的类,必须同时重写 equals 和 hashCode 且用相同字段,优先用 record。
我立下的几条规矩
这场"只重写 equals 导致集合失效"的事故,换来了我写 Java 时,刻进骨子里的几条铁律:
- 重写 equals 必须同时重写 hashCode。这是 Object 契约,违反则哈希集合失效。
- equals 和 hashCode 必须用相同的字段。否则 equals 相等的对象 hashCode 可能不同。
- 哈希表先用 hashCode 定位桶,再用 equals 比对。hashCode 不同就到不了同一个桶。
- hashCode 用不可变字段。用可变字段,对象进集合后改了就再也找不到它。
- 优先用 record(Java14+)。自动生成一致的 equals/hashCode,彻底免操心。
- 别手写,用 IDE 生成/Lombok。手写易漏字段、易不一致。
- 比较对象内容用 equals 不用 ==。== 比引用,equals 比内容。
附:一段亲眼看清 hashCode 影响的实验
口说无凭。下面用一段对比代码,让你亲眼看到"只重写 equals""都重写""用 record"三种情况下集合的不同表现:
import java.util.*;
public class HashCodeDemo {
// 1. 只重写 equals(踩坑版)
static class UserBad {
int id;
UserBad(int id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof UserBad && id == ((UserBad) o).id;
}
// 没重写 hashCode!
}
// 2. 都重写(正确版)
static class UserGood {
int id;
UserGood(int id) { this.id = id; }
@Override public boolean equals(Object o) {
return o instanceof UserGood && id == ((UserGood) o).id;
}
@Override public int hashCode() { return Objects.hash(id); } // ★ 同步重写
}
// 3. record(最推荐版)
record UserRecord(int id) {}
public static void main(String[] args) {
System.out.println("--- 只重写equals(踩坑) ---");
Set s1 = new HashSet<>();
s1.add(new UserBad(1)); s1.add(new UserBad(1)); // 两个id=1
System.out.println("HashSet size: " + s1.size()); // 2! 没去重
Map m1 = new HashMap<>();
m1.put(new UserBad(1), "A");
System.out.println("get: " + m1.get(new UserBad(1))); // null! 取不到
System.out.println("\n--- 都重写(正确) ---");
Set s2 = new HashSet<>();
s2.add(new UserGood(1)); s2.add(new UserGood(1));
System.out.println("HashSet size: " + s2.size()); // 1! 正确去重
Map m2 = new HashMap<>();
m2.put(new UserGood(1), "A");
System.out.println("get: " + m2.get(new UserGood(1))); // "A"! 取到了
System.out.println("\n--- record(最推荐) ---");
Set s3 = new HashSet<>();
s3.add(new UserRecord(1)); s3.add(new UserRecord(1));
System.out.println("HashSet size: " + s3.size()); // 1! record自动搞定
Map m3 = new HashMap<>();
m3.put(new UserRecord(1), "A");
System.out.println("get: " + m3.get(new UserRecord(1))); // "A"!
}
}
/* 输出(对比鲜明):
--- 只重写equals(踩坑) ---
HashSet size: 2 ← 没去重!
get: null ← 取不到!
--- 都重写(正确) ---
HashSet size: 1 ← 正确去重 ✓
get: A ← 取到了 ✓
--- record(最推荐) ---
HashSet size: 1 ← record自动搞定 ✓
get: A ← ✓
*/
// 核心: 只重写equals则HashSet不去重(size=2)、HashMap取不到(null); 都重写或用record则正常;
// 跑一遍这个对比, "为什么必须一起重写hashCode"就再也忘不了了。
这段对比代码,把"hashCode 到底有没有影响"这个抽象问题,变成了三组对比鲜明的输出。它把"只重写 equals""都重写""用 record"三种情况,用同样的"放进 HashSet 去重 + 用 HashMap get"的操作去试,输出清清楚楚地显示:只重写 equals 的版本,HashSet 没去重(size=2)、HashMap 取不到(null);而都重写和 record 的版本,HashSet 正确去重(size=1)、HashMap 正确取到("A")。这一组"2 vs 1""null vs A"的对比,把"不重写 hashCode 的后果"和"正确做法的效果",放在一起、一目了然。这,正是我想用这段代码,留给每个 Java 开发者的最后一课:对于"看不见、却影响行为"的契约(equals/hashCode 的协同),最好的理解方式,就是把"遵守契约"和"违反契约"两种情况,放在一起跑、对比结果。当你亲眼看到"只重写 equals"那行刺眼的 size: 2 和 null,你就会从心底里、永久地记住"重写 equals 必须重写 hashCode"这条铁律——而不只是把它当成一条"听说要这样"的规则。把"违反契约的恶果"亲手跑出来看一遍,是让一条规则从"知道"变成"刻进本能"最有效的方式。这,也是我这一整个系列复盘里,贯穿始终、屡试不爽的学习心法:对任何重要的规则或反直觉的行为,别只记结论,写个对比实验,让"对"与"错"的结果,亲自说服你。
写在最后
回头看,这场由"只重写 equals 没重写 hashCode"引发的、哈希集合失效的事故,真正教给我的,远不止"两个方法要一起重写"这一个规则。它让我对"契约"和"协同"有了更深刻的体会。我栽跟头,是因为我把 equals 和 hashCode 看成了"两个独立的方法",只按自己的需要重写了其中一个(equals)。可实际上,它们是一对"必须协同工作、共同遵守一份契约"的搭档——它们不是各自独立的,而是被一个共同的机制(哈希表)联合使用的;只改其中一个、不管另一个,就破坏了它们之间的协同,导致那个依赖它们协同的机制(哈希表)失效。这让我领悟到一个在编程(乃至设计系统)中极其重要的道理:很多东西不是"孤立"存在的,而是和别的东西"有契约、有协同关系"的;当你修改其中一个时,必须考虑"它和谁有契约?改了它,会不会破坏这个契约、影响到协同的另一方?"。equals 和 hashCode 是这样,接口和实现是这样,生产者和消费者是这样,数据格式的写入方和读取方也是这样……这些"成对/成组出现、必须保持一致"的契约关系,一旦你"只改一边、忘了另一边",就会埋下隐患。所以,这件事给我的最大警示是:修改任何东西时,都要有"契约意识"——问一句"它有没有和别的东西约定好的契约?我这个修改,需不需要同步更新契约的另一方,以维持一致?";对那些"必须协同一致"的搭档(如 equals/hashCode),要么一起改、要么都别改,绝不能只动一半。契约意识、协同思维——这,是我用一次"集合失效"的事故,换来的、关于 Java、也关于"协同契约"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次重写 equals 时,条件反射地把 hashCode 也一起重写(或直接用 record),那我对着那个去不了重的 HashSet 熬的这大半天,就值了。
—— 别看了 · 2026