一个重写了 equals 却忘了重写 hashCode 的 Java 对象,放进 HashSet 后既去不了重、也再也取不出来:一次违背 equals-hashCode 契约的深度复盘

给 User 重写了 equals(id 相同即相等),放进 HashSet 却没去重、用 id 相同的 User 去 map.get 竟返回 null——东西放进去了却取不出来。根因是只重写了 equals、没重写 hashCode:两个 equals 相等的对象,hashCode 却用 Object 默认实现(基于地址)而不同,违背了'equals 相等则 hashCode 必相等'的契约;而 HashMap 先按 hashCode 定位桶、再用 equals 比对,hashCode 不同就被分到不同桶、永远找不到彼此。本文讲透 equals-hashCode 契约与 HashMap 工作原理,给出二者基于相同字段同时重写、用 Objects.hash/record/Lombok 生成、哈希 key 用不可变对象的正解,梳理集合常见坑,最后落到'编译通过不等于正确、成对的东西要成对地改'的认知。

一个重写了 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 里,equalshashCode 是一对必须同时重写、且必须保持一致的方法。我只重写了 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 时,刻进骨子里的几条铁律:

  1. 重写 equals 必须同步重写 hashCode。它们是一对,绝不能只写一个。
  2. equals 和 hashCode 必须基于相同字段。equals 用啥判等,hashCode 就用啥算。
  3. equals 相等则 hashCode 必相等。这是 Object 的契约,违背则哈希容器出错。
  4. HashMap 先用 hashCode 定位桶、再用 equals 比对。理解这个才懂为何要配对。
  5. 哈希容器的 key 用不可变对象。入集合后改 hash 字段会让对象迷失。
  6. 优先用 record/IDE/Lombok 生成。别手写,自动保证一致。
  7. 编译通过不等于正确。隐式契约靠自觉+测试+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"这一条规则。它让我对"很多东西是成对存在、必须协同的,只动其一就会破坏它们之间的一致性",有了一次刻骨的体会。我栽跟头,根源在于我把 equalshashCode 当成了两个独立的方法,以为"我要的是'相等判断',那重写 equals 就够了,hashCode 跟我没关系"。可它们其实是一个"对象相等性"概念的两个协同面——equals 定义"什么叫相等",hashCode 则要"与这个相等定义保持一致"(相等的对象给出相同的哈希),它们共同、一致地支撑起"对象在哈希容器里如何被识别"这件事;我只改了 equals 而没动 hashCode,就让这两个本该一致的面产生了矛盾(equals 说相等、hashCode 说不同),而正是这个矛盾,让依赖它们协同工作的哈希容器彻底乱了套这让我领悟到一个普适的认知:系统里有大量"成对/成组、必须协同保持一致"的东西——equals 与 hashCode、compareTo 与 equals、构造与析构、加锁与解锁、申请与释放、序列化与反序列化、读与写的格式、接口与实现的约定;它们的正确性不在于单个写得对,而在于'彼此之间是否一致';只修改其中一个、而忘了同步另一个,是一类极其常见的 bug 来源这给了我一种"成对思维"的警觉:每当我修改一个"有搭档"的东西时,就条件反射地问一句:"它有没有一个必须保持一致的'另一半'?我改了它,那一半要不要跟着改?"——改了 equals 想到 hashCode、改了序列化格式想到反序列化、加了锁想到在所有路径上解锁、改了数据结构想到所有读写它的地方;"成对的东西要成对地改",是避开一大类一致性 bug 的关键意识建立"成对协同、改一个必同步另一个"的一致性思维——这,是我用一次 hashCode 缺失的事故,换来的、关于 Java、也关于如何维护一切"协同一致性"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次重写 equals 时,手指自然地接着去写那个 hashCode,那我对着那个取不出值的 HashMap 排查的这大半天,就值了。

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

一次对 Go slice 做切片后 append,意外覆盖了原始 slice 里的数据,让一份订单列表凭空窜了值:一次共享底层数组的深度复盘

2026-6-2 15:25:40

技术教程

一个用 LIMIT offset 做分页的接口,翻到第几万页时一条查询要跑十几秒,把数据库拖垮:一次深分页性能的深度复盘与游标分页正解

2026-6-2 15:36:49

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