我重写了 equals 让两个字段相同的对象相等,把它当 HashMap 的 key 存进去,再用一个一模一样的 key 去取却拿到了 null:一次 Java 只重写 equals 没重写 hashCode 的深度复盘
那个 bug 是缓存"明明存了却取不到"才暴露的:我用一个自定义的 OrderKey(含 userId 和 date 两个字段)当 HashMap 的 key,缓存一些订单数据。为了让"两个字段都相同的 key 被视为同一个 key",我很自觉地重写了 equals。可线上诡异得很:我先 map.put(key1, value) 存进去;再用一个字段完全相同的新 key2 去 map.get(key2)——居然返回 null!可我明明 key1.equals(key2) 是 true 啊!我对着这个"存了却取不到、equals 还相等"的怪事查了好久,才看明白,后背发凉:问题出在我只重写了 equals,却忘了重写 hashCode。HashMap 查找一个 key 的过程,是分两步的:第一步,用 key 的 hashCode() 算出它该在哪个"桶(bucket)";第二步,在那个桶里,用 equals 逐个比对找到真正相等的 key。我重写了 equals(让 key1 和 key2 在第二步能匹配),但没重写 hashCode——于是 key1 和 key2 用的是 Object 默认的 hashCode(基于对象内存地址),两个不同的对象 hashCode 几乎必然不同;结果:key1 和 key2 被算到了不同的桶里;用 key2 去 get 时,HashMap 跑到 key2 的那个桶里找,那个桶里根本没有 key1(key1 在另一个桶),第二步的 equals 比对压根没机会执行,自然返回 null。根本原因是违反了 Java 的一条铁律:equals 和 hashCode 必须一起重写、保持一致——两个 equals 相等的对象,hashCode 必须也相等。问题的根,是只重写 equals 没重写 hashCode:HashMap 先用 hashCode 定位桶再用 equals 比对,hashCode 不一致导致相等的 key 被分到不同桶、根本找不到。这篇就把这次"只重写 equals 没重写 hashCode"的坑,从头到尾复盘一遍。
故障现场:equals 相等,HashMap 却取不到
问题在于只重写了 equals 没重写 hashCode,导致相等的 key hashCode 不同、被分到不同桶:
// ✗ 出问题的代码: 只重写了 equals, 没重写 hashCode
class OrderKey {
int userId;
String date;
OrderKey(int userId, String date) { this.userId = userId; this.date = date; }
@Override
public boolean equals(Object o) { // ✓ 重写了 equals
if (!(o instanceof OrderKey)) return false;
OrderKey k = (OrderKey) o;
return userId == k.userId && date.equals(k.date);
}
// ✗ 没有重写 hashCode! → 用的是 Object 默认的(基于对象地址)
}
Map map = new HashMap<>();
OrderKey key1 = new OrderKey(1, "2026-06-02");
map.put(key1, "订单数据");
OrderKey key2 = new OrderKey(1, "2026-06-02"); // 字段完全相同的新对象
System.out.println(key1.equals(key2)); // true ← equals相等
System.out.println(map.get(key2)); // ✗ null! 明明equals相等却取不到!
// 为什么? HashMap 查找 key 分两步: 先hashCode定位桶, 再equals比对:
// 1. 用 key2.hashCode() 算出 key2 应该在哪个桶;
// 2. 到那个桶里, 用 equals 逐个比对桶里的key, 找到相等的。
//
// 而我只重写了equals没重写hashCode:
// - key1 和 key2 是两个不同的对象, 用Object默认hashCode(基于地址) → 几乎必然【不同】;
// - → key1存进了"桶A"(按key1的hashCode), key2去get时算出的是"桶B"(按key2的hashCode);
// - → HashMap跑到桶B找, 桶B里没有key1(它在桶A) → 第二步的equals根本没机会比 → 返回null。
//
// 违反的铁律(equals-hashCode契约): 两个对象equals相等, 则它们的hashCode必须相等;
// (反之不要求: hashCode相等的对象不一定equals相等, 那是哈希冲突, 正常。)
// 我重写equals让key1/key2"相等"了, 却没让它们hashCode相等 → 违反契约 → HashMap行为错乱。
// 关键: HashMap先用hashCode定位桶再用equals比对; 只重写equals没重写hashCode, 会让equals相等的对象
// hashCode不同、被分到不同桶, 导致存了取不到 —— equals和hashCode必须一起重写、保持一致。
第一次理清"原来 HashMap 先按 hashCode 找桶,key1 和 key2 压根不在一个桶"时,我又懊恼又恍然:"我以为重写了 equals、让两个 key'相等'就够了,完全没想到 HashMap 是先靠 hashCode 找桶的,hashCode 不一样,它俩连见面比 equals 的机会都没有。"这个坑最隐蔽的地方在于:它违反直觉——key1.equals(key2) 明明是 true,你会笃定"它俩就是同一个 key",完全想不到 map 会取不到;而且只在"用自定义对象当 HashMap/HashSet 的 key"时才暴露,平时单独用这个对象毫无异样。下面就来拆解,equals 和 hashCode 的契约到底是什么、该怎么正确重写。
第一件事:搞懂 equals 与 hashCode 的契约
我顺着这次事故,把 Java 的 equals/hashCode 契约和 HashMap 的工作原理彻底理清了。
equals 与 hashCode 的契约是什么? 为什么必须一起重写?
【核心: HashMap先用hashCode定位桶再用equals比对; 契约要求"equals相等则hashCode必相等"; 只重写equals不重写hashCode会让相等对象进不同桶、找不到】
1. HashMap/HashSet 怎么找一个 key —— 两步:
- 第一步: 用 key.hashCode() 算出它在哪个桶(bucket); (hashCode决定"去哪个桶找")
- 第二步: 在那个桶里, 用 equals 逐个比对, 找到真正相等的那个。
- → hashCode负责"快速定位到一小撮候选(桶)", equals负责"在候选里精确认定"; 缺一不可。
2. equals-hashCode 契约(Java的硬性约定):
- ① 若 a.equals(b) 为 true, 则 a.hashCode() 必须 == b.hashCode();(相等→哈希值必相等)
- ② 若 a.hashCode() == b.hashCode(), a.equals(b) 不一定为true(允许哈希冲突);
- ③ equals/hashCode在对象不变时要稳定。
- 核心是①: 相等的对象, 哈希值必须相等(否则它们会被分到不同桶, HashMap就乱了)。
3. 为什么只重写equals会出错:
- 你重写equals让"字段相同"的对象相等了(满足了第二步的比对);
- 但没重写hashCode → 它们仍用Object默认hashCode(基于地址) → 不同对象哈希值不同;
- → 违反契约①: equals相等但hashCode不等;
- → 第一步就把它们定位到不同桶, key2的桶里根本没有key1 → 第二步equals没机会执行 → 找不到。
4. 为什么默认hashCode基于地址:
- Object.hashCode()默认按对象身份(地址)算, 所以"两个内容相同但不同的对象"哈希值不同;
- 你重写equals改成"按内容相等", 就必须同步重写hashCode改成"按内容算哈希", 二者才一致。
5. 正确做法: equals和hashCode一起重写, 且基于"相同的字段"
- equals用哪些字段判断相等, hashCode就用哪些字段算哈希 → 保证"equals相等的对象hashCode也相等";
- 用 Objects.equals / Objects.hash 简化; 或用IDE生成、Lombok @EqualsAndHashCode、record(自动生成)。
一句话: HashMap先用hashCode定位桶再用equals比对; 契约要求equals相等的对象hashCode必相等;
只重写equals不重写hashCode会让相等对象哈希值不同、进不同桶而找不到; 二者必须一起重写、基于相同字段。
这套认知,是整个坑的根。HashMap 找 key 分两步:第一步用 hashCode 算在哪个桶(决定去哪个桶找)、第二步在桶里用 equals 逐个比对;hashCode 负责快速定位候选桶、equals 负责精确认定,缺一不可。equals-hashCode 契约:①若 a.equals(b) 为 true 则 hashCode 必须相等(相等→哈希值必相等);②哈希值相等不要求 equals 相等(允许冲突);核心是①。为什么只重写 equals 会出错:重写 equals 让内容相同的对象相等了,但没重写 hashCode 它们仍用默认(基于地址)哈希值不同,违反契约①、被定位到不同桶、equals 没机会执行、找不到。为什么默认 hashCode 基于地址:Object.hashCode 按对象身份算,改 equals 成按内容相等就必须同步改 hashCode 成按内容算。正确做法:equals 和 hashCode 一起重写、基于相同的字段(用 Objects.equals/Objects.hash、IDE 生成、Lombok、record)。一句话:HashMap 先用 hashCode 定位桶再用 equals 比对;契约要求 equals 相等的对象 hashCode 必相等;只重写 equals 不重写 hashCode 会让相等对象哈希值不同、进不同桶而找不到;二者必须一起重写、基于相同字段。
第二件事:正解——equals 和 hashCode 一起重写,基于相同字段
搞懂了原理,正解就清晰了:equals 和 hashCode 必须一起重写,且基于"相同的字段";用 Objects.equals/Objects.hash 简化,或用 record/Lombok/IDE 自动生成,保证二者一致。
// ====== 正解一: equals 和 hashCode 一起重写, 基于相同字段 ======
class OrderKey {
int userId;
String date;
OrderKey(int userId, String date) { this.userId = userId; this.date = date; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderKey)) return false;
OrderKey k = (OrderKey) o;
return userId == k.userId && Objects.equals(date, k.date); // 用 userId, date 判等
}
@Override
public int hashCode() {
return Objects.hash(userId, date); // ★ 用【相同的字段】算哈希, 与equals一致
}
}
// → 现在: key1和key2字段相同 → equals相等, 且hashCode也相等 → 同一个桶 → map.get(key2)能取到!
// 关键: equals用哪些字段, hashCode就用哪些字段; 二者基于同一组字段, 才保证"equals相等→hashCode相等"。
// ====== 正解二: 用 record(Java 16+, 最省心) ======
record OrderKey(int userId, String date) {}
// → record 自动生成基于所有字段的 equals 和 hashCode(还有toString/构造器), 天然一致, 当key最安全。
# ====== 重写 equals/hashCode 的要点 ======
# 1. 必须成对重写: 重写了equals就必须重写hashCode(反之亦然), 且基于【相同的字段】;
# 2. 用工具简化/避免手误:
# - Objects.hash(f1, f2, ...) 算哈希; Objects.equals(a, b) 判等(自动处理null);
# - IDE一键生成(IntelliJ: Generate → equals() and hashCode());
# - Lombok @EqualsAndHashCode; 或直接用 record(Java16+, 当不可变key首选);
# 3. 选字段要一致: equals判等用的字段 == hashCode算哈希用的字段(漏一个/多一个都会不一致);
# 4. 字段应是"不可变的"(尤其当HashMap的key): key放进map后若字段被改, hashCode变了, 又找不到了;
# 5. 同理影响: HashSet去重、HashMap的key、contains/remove等都依赖equals+hashCode, 错了全乱。
# ====== 一个常被忽略的坑 ======
# - 可变对象当key: 即使正确重写了equals/hashCode, 若把对象放进HashMap后又修改了它的(参与哈希的)字段,
# 它的hashCode就变了, 还在老桶里, 你再也get不到它 → key一旦入map, 别改它的关键字段。
# 核心: 重写equals必须同时重写hashCode、且基于相同字段(保证equals相等则hashCode相等);
# 优先用Objects.hash/record/IDE生成避免手误; key用不可变对象, 入map后别改其参与哈希的字段。
修复的核心,是"equals 和 hashCode 一起重写、基于相同字段"。正解一:一起重写、基于相同字段——equals 用 userId/date 判等,hashCode 就用 Objects.hash(userId, date) 算,二者一致,key1/key2 进同一个桶、get 能取到。正解二:用 record(Java 16+)——自动生成基于所有字段的 equals/hashCode,天然一致,当 key 最安全。要点:必须成对重写且基于相同字段、用 Objects.hash/record/IDE 生成避免手误、选字段要一致、字段应不可变、HashSet/contains/remove 都依赖它。常被忽略的坑:可变对象当 key——入 map 后改了参与哈希的字段,hashCode 变了还在老桶里,再也 get 不到;key 入 map 后别改关键字段。归根结底:重写 equals 必须同时重写 hashCode、且基于相同字段(保证 equals 相等则 hashCode 相等);优先用 Objects.hash/record/IDE 生成避免手误;key 用不可变对象,入 map 后别改其参与哈希的字段。
第三件事:Java 集合与对象相等中其他常见的坑
排查后我把 Java 中和对象相等、集合相关的其他坑也系统梳理了一遍。
Java 对象相等与集合的其他常见坑
# 1. 只重写equals没重写hashCode(本文): HashMap存了取不到。→ 一起重写、基于相同字段。
# 2. 可变对象当key后改了字段: hashCode变了, 找不到。→ key用不可变对象。
# 3. ==比较对象/字符串: ==比引用, 字符串内容比较要用equals(同353篇Integer缓存类似)。→ 内容比较用equals。
# 4. BigDecimal用equals: equals会比较scale(2.0 != 2.00), 比较数值用compareTo。→ 数值比较用compareTo。
# 5. 在Set/Map里放可变对象后改它: 同2, 破坏哈希定位。→ 别改。
# 6. equals不对称/不传递: 父子类equals实现不当, 违反对称性/传递性。→ 谨慎处理继承下的equals。
# 7. 没处理null: equals里直接调字段方法, 字段为null则NPE。→ 用Objects.equals。
# 8. 用数组当key: 数组的equals/hashCode是基于身份的, 不比内容。→ 用List或包装。
# 共同根源: Java里"两个对象是否相等""一个对象的哈希值是什么", 是由equals/hashCode【定义】的;
# 而大量机制(HashMap/HashSet/contains/去重/缓存)都【依赖】这两个方法的正确实现和一致性;
# 一旦equals/hashCode实现错误或不一致, 所有依赖它们的机制都会跟着出错, 且往往很隐蔽。
# 核心: equals和hashCode是对象相等性的基石, 必须成对、一致、基于相同字段地正确实现;
# 依赖它们的集合机制才能正常工作; 优先用record/Objects工具/IDE生成, key用不可变对象。
排查让我把对象相等与集合的其他坑也梳理清了。一、只重写 equals 没重写 hashCode(本文)。二、可变对象当 key 后改字段。三、== 比较对象/字符串。四、BigDecimal 用 equals(比 scale)。五、Set/Map 里放可变对象后改它。六、equals 不对称/不传递。七、没处理 null。八、数组当 key。它们的共同根源是:Java 里"两个对象是否相等""一个对象的哈希值是什么"是由 equals/hashCode 定义的;而大量机制(HashMap/HashSet/contains/去重/缓存)都依赖这两个方法的正确实现和一致性;一旦实现错误或不一致,所有依赖它们的机制都会跟着出错,且往往很隐蔽。核心是:equals 和 hashCode 是对象相等性的基石,必须成对、一致、基于相同字段地正确实现;依赖它们的集合机制才能正常工作;优先用 record/Objects 工具/IDE 生成,key 用不可变对象。下面这张图,是这次 equals/hashCode 坑的成因与解法:
第四件事:hashCode 与 equals 的分工对比表
这次踩坑后,我把 hashCode 和 equals 在 HashMap 查找中的分工对比成一张表。
| 维度 | hashCode() | equals() |
|---|---|---|
| 作用 | 决定去哪个桶找 | 在桶里精确认定相等 |
| 查找步骤 | 第一步(定位) | 第二步(比对) |
| 要求 | 相等对象哈希值必须相等 | 定义"什么算相等" |
| 只重写它会怎样 | (几乎不会只重写它) | 哈希值不同, 进不同桶, 找不到 |
| 关系 | 粗筛(快速缩小范围) | 精判(确认) |
这张表把两者的分工钉清了。核心是:hashCode 和 equals 是一对"分工协作"的搭档——hashCode 负责"粗筛"(用哈希值快速把范围缩小到一个桶),equals 负责"精判"(在桶内精确确认);这是一种"先快速缩小范围、再精确比对"的高效查找策略(否则每次查找都要 equals 比对所有元素, O(n));而这套协作要成立,前提是"粗筛(hashCode)和精判(equals)对'相等'的判断必须一致"——精判认为相等的,粗筛必须把它们筛到一起。它给我的最大启发是:很多高效的系统,都用"两级(或多级)过滤:先粗后精"的策略——先用一个"便宜但粗略"的方法快速缩小范围,再用"昂贵但精确"的方法在小范围内确认(哈希桶+equals、数据库索引+回表精确匹配、布隆过滤器+实际查询、缓存+回源);而这类策略的正确性关键,在于"粗筛绝不能漏掉精判会要的东西"——粗筛可以"多放进来一些"(假阳性,精判会滤掉),但绝不能"把该要的筛掉"(假阴性)。这给了我一种设计"分级过滤"系统的清醒:设计任何"先粗筛再精判"的机制时,要死守一条:"粗筛的标准必须比精判更宽松(或一致), 保证精判要的东西粗筛一定不会漏"——就像 hashCode 必须保证"equals 相等的一定哈希值相等(筛到一起)";"保证粗筛不漏掉精判所需",是一切两级过滤机制正确的基石,违反它(如本文 hashCode 把相等的筛散了)就会"明明有却找不到"。认清 hashCode 粗筛 equals 精判的协作、粗筛绝不能漏掉精判所需——是这个坑带给我的认知。
第五件事:这次事故暴露的"成对的约定"被破坏的危险
这次让我反思更深一层:equals 和 hashCode 是一对"必须同步"的方法,我只动了一个就出了事。我把"成对约定"被破坏的情形整理成表。
| 成对的约定 | 只做一半的后果 |
|---|---|
| equals 与 hashCode | HashMap 存了取不到(本文) |
| malloc 与 free / new 与 delete | 内存泄漏或重复释放 |
| 加锁 lock 与 解锁 unlock | 死锁 |
| 打开资源 与 关闭资源 | 资源泄漏 |
| 序列化 与 反序列化(要兼容) | 读不回/出错 |
这张表道出了一类共性问题。核心是:equals 和 hashCode 是一个典型的"必须成对、保持一致"的约定——它们俩"捆绑"在一起表达同一件事("什么是相等"),你只改其中一个、不改另一个,就破坏了它们之间的一致性,导致依赖这份一致性的机制(HashMap)出错;编程里有大量这种"成对出现、必须同步维护"的东西。它给我的深刻启发是:系统里存在大量"成对的、相互关联、必须保持一致的"元素(equals/hashCode、申请/释放、加锁/解锁、状态的多个副本、文档与代码、接口与实现);它们的正确性, 不在于"每一个单独看对不对", 而在于"它们之间是否保持了应有的一致/同步";而最容易出错的, 正是"改了其中一个, 忘了同步改与它配对的另一个"。这给了我一种维护系统时的清醒:修改任何一个"有配对/有关联"的东西时,要立刻警觉地问:"它有没有'必须同步'的搭档?我改了它, 那个搭档要不要一起改?"——改了 equals 就想到 hashCode、改了数据结构就想到所有读写它的地方、改了接口就想到所有实现和调用方;"识别成对/关联的元素, 修改时同步维护其一致性",是避免"改了一半导致系统不一致"这类 bug 的关键意识。认清成对约定破坏一半就出错、修改有配对的东西时同步维护一致性——是这个 equals/hashCode 坑带给我的工程态度。
第六件事:重写 equals 或用对象当 key 时,我现在的自检习惯
现在每当我要重写 equals、或用一个自定义对象当 HashMap/HashSet 的 key,我都会先按这张图问自己:
这张图的精髓,是"重写 equals 必同步重写 hashCode、基于相同字段、key 用不可变对象"。重写 equals必须同时重写 hashCode、生成用 Objects.hash/record、key用不可变对象别改哈希字段。这套习惯,让我从"重写 equals 就完事"变成了"equals 和 hashCode 成对重写、基于相同字段"——核心始终是:重写 equals 必须同时重写 hashCode 且基于相同字段(保证 equals 相等则 hashCode 相等),优先用 record/Objects 工具生成,key 用不可变对象。
我立下的几条规矩
这场"存进 HashMap 却用相等的 key 取不到"的事故,换来了我写 Java 时,刻进骨子里的几条铁律:
- HashMap 查找分两步:先 hashCode 定位桶,再 equals 在桶内比对。
- 契约:两个 equals 相等的对象,hashCode 必须相等。这是基石。
- 重写 equals 必须同时重写 hashCode,且基于相同的字段。只改一个就出错。
- 只重写 equals 不重写 hashCode,相等的 key 会进不同桶、存了取不到。
- 优先用 record(Java16+)、Objects.hash、Lombok 或 IDE 生成,避免手误。
- HashMap 的 key 用不可变对象,入 map 后别改参与哈希的字段。
- 识别成对/关联的元素(equals/hashCode 等),修改时同步维护一致性。
写在最后
回头看,这场由"只重写 equals 没重写 hashCode"引发的、存了取不到的事故,真正教给我的,远不止"两个方法要一起重写"这一个技巧。它让我对"一个东西能正确工作, 往往依赖'几个部件之间的协调一致', 而非'每个部件单独正确'; 我只把其中一个部件改'对'了, 却破坏了它们之间的协调, 整体反而错了",有了一次刻骨的体会。我栽跟头,是因为我孤立地看待 equals——我想"让两个 key 相等",就只盯着 equals 这一个方法把它改"对"了,觉得"equals 都返回 true 了, 那它俩当然就是同一个 key"。可我没意识到:"两个对象在 HashMap 里是不是同一个 key" 这件事, 不是 equals 一个方法说了算的, 而是 equals 和 hashCode 两个方法协调一致地共同决定的;我把 equals 改对了, 却让它和 hashCode "各说各话"(equals 说相等、hashCode 说不等)——正是这个"不协调", 而非某个方法本身的错误, 导致了故障。这让我领悟到一个关于"局部正确与整体协调"的深刻认知:一个系统的正确,常常是"多个部分协调一致"的涌现属性,而非"每个部分各自正确"的简单相加——"每个零件单独看都没错, 但它们之间的配合错了", 系统照样会失灵;"只优化/修改局部, 却破坏了局部之间的协调一致性", 是一种常见而隐蔽的错误来源。这给了我一种系统思维的清醒:修改或评判一个东西时,不能只看"它自己对不对",更要看"它和与它协作的其他部分, 是否还保持着应有的一致/协调"——改 equals 要顾及 hashCode、优化一个模块要顾及它和上下游的契约、调一个参数要顾及它和其他参数的配合;"从'局部的正确'抬升到'整体的协调一致'去思考",是构建和维护一个真正能正确运转的系统的关键视角。认清系统正确是多部分协调一致的涌现、修改局部要顾及与协作方的一致性——这,是我用一次 equals/hashCode 的事故,换来的、关于 Java、也关于如何从整体协调而非局部正确去思考系统的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次重写 equals 时,手指条件反射般地也去把 hashCode 一起重写了,那我对着那个"存了却取不到"的 null 排查的这段时间,就值了。
—— 别看了 · 2026