我自定义对象重写了 equals 判断相等,放进 HashSet 却还是有重复、用它当 HashMap 的 key 也取不到值,我对着 equals 和 hashCode 排查了大半天的复盘

写去重逻辑,自定义类 User 重写了 equals 规定 id 相同就算同一用户,把一堆 User 放进 HashSet 去重、又用 User 当 HashMap 的 key。结果诡异:id 相同的两个 User 放进 HashSet 竟没去重、用 id 相同的 User 去 HashMap get 竟取不到值返回 null。盯着 equals 反复看明明重写了 id 相同返回 true 啊为啥集合不认?排查大半天才理解被无数人忽略的铁律:重写 equals 必须同时重写 hashCode。根因是 HashSet/HashMap 底层是哈希表,查找分两步:先用 hashCode 算出在哪个桶,再在桶里用 equals 比对;我只重写 equals 没重写 hashCode、用的是 Object 默认基于内存地址的 hashCode,equals 相等的两个对象 hashCode 却不同、被算到不同的桶里,equals 根本没机会比,于是 HashSet 不去重、HashMap 取不到。这篇从 equals/hashCode 契约(equals相等则hashCode必须相等)、用相同字段同步重写/Objects.hash/IDE生成/record最推荐的正解、equals/hashCode 其他坑(用不同字段/用可变字段/违反对称传递/用==比内容)、几种重写方式对比、哈希集合正确使用要点、决策图与铁律,到附上一段对比只重写equals/都重写/record三种情况集合表现的实验。核心领悟:很多东西不是孤立的而是有契约有协同(equals和hashCode必须协同一致),改一个要考虑会不会破坏和另一个的契约,成对必须一致的搭档要么一起改要么都别改;需要人维护的一致性迟早被疏忽破坏,用工具(IDE/record)自动保证才可靠。

我自定义对象重写了 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 时,刻进骨子里的几条铁律:

  1. 重写 equals 必须同时重写 hashCode。这是 Object 契约,违反则哈希集合失效。
  2. equals 和 hashCode 必须用相同的字段。否则 equals 相等的对象 hashCode 可能不同。
  3. 哈希表先用 hashCode 定位桶,再用 equals 比对。hashCode 不同就到不了同一个桶。
  4. hashCode 用不可变字段。用可变字段,对象进集合后改了就再也找不到它。
  5. 优先用 record(Java14+)。自动生成一致的 equals/hashCode,彻底免操心。
  6. 别手写,用 IDE 生成/Lombok。手写易漏字段、易不一致。
  7. 比较对象内容用 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: 2null,你就会从心底里、永久地记住"重写 equals 必须重写 hashCode"这条铁律——而不只是把它当成一条"听说要这样"的规则。把"违反契约的恶果"亲手跑出来看一遍,是让一条规则从"知道"变成"刻进本能"最有效的方式这,也是我这一整个系列复盘里,贯穿始终、屡试不爽的学习心法:对任何重要的规则或反直觉的行为,别只记结论,写个对比实验,让"对"与"错"的结果,亲自说服你。

写在最后

回头看,这场由"只重写 equals 没重写 hashCode"引发的、哈希集合失效的事故,真正教给我的,远不止"两个方法要一起重写"这一个规则。它让我对"契约"和"协同"有了更深刻的体会。我栽跟头,是因为我把 equalshashCode 看成了"两个独立的方法",只按自己的需要重写了其中一个(equals)。可实际上,它们是一对"必须协同工作、共同遵守一份契约"的搭档——它们不是各自独立的,而是被一个共同的机制(哈希表)联合使用的;只改其中一个、不管另一个,就破坏了它们之间的协同,导致那个依赖它们协同的机制(哈希表)失效这让我领悟到一个在编程(乃至设计系统)中极其重要的道理:很多东西不是"孤立"存在的,而是和别的东西"有契约、有协同关系"的;当你修改其中一个时,必须考虑"它和谁有契约?改了它,会不会破坏这个契约、影响到协同的另一方?"equals 和 hashCode 是这样,接口和实现是这样,生产者和消费者是这样,数据格式的写入方和读取方也是这样……这些"成对/成组出现、必须保持一致"的契约关系,一旦你"只改一边、忘了另一边",就会埋下隐患所以,这件事给我的最大警示是:修改任何东西时,都要有"契约意识"——问一句"它有没有和别的东西约定好的契约?我这个修改,需不需要同步更新契约的另一方,以维持一致?";对那些"必须协同一致"的搭档(如 equals/hashCode),要么一起改、要么都别改,绝不能只动一半。契约意识、协同思维——这,是我用一次"集合失效"的事故,换来的、关于 Java、也关于"协同契约"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次重写 equals 时,条件反射地把 hashCode 也一起重写(或直接用 record),那我对着那个去不了重的 HashSet 熬的这大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我在 for 循环里处理一批文件、每个都 defer f.Close(),结果跑到一半就报 too many open files,我对着 defer 的执行时机排查了大半天的复盘

2026-6-2 8:40:16

技术教程

我用 NOT IN 子查询过滤数据,结果返回了空集、明明该有很多行,还有个 != 查询莫名其妙漏了一批数据,我对着 SQL 里 NULL 的三值逻辑排查了大半天的复盘

2026-6-2 8:52:48

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索