我给一个类重写了 equals 判断两个对象内容相等,用它做 HashMap 的 key 存了又取,取出来的竟然永远是 null,我对着只重写 equals 不重写 hashCode 这个坑排查了大半天的复盘
这是一个堪称 Java "面试必问、实战必踩"的经典坑。它的隐蔽之处在于:我做的事情看起来天经地义、合情合理——我"明明定义了两个对象在什么情况下相等",可 HashMap 却像没看见我的定义一样,固执地告诉我"找不到"。
事情起于一个缓存需求。我有一个表示坐标点的类 Point,我希望"只要 x、y 相同,两个 Point 就算相等",并用 Point 作为 key,把一些计算结果缓存进 HashMap。为了让"内容相等的 Point 被当成同一个 key",我很认真地重写了 equals 方法:
import java.util.HashMap;
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
// 我认真重写了 equals: 只要 x、y 相同就算相等
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return this.x == p.x && this.y == p.y;
}
// ★★★ 但我【没有】重写 hashCode ★★★
}
public class Main {
public static void main(String[] args) {
HashMap cache = new HashMap<>();
Point p1 = new Point(1, 2);
cache.put(p1, "结果A"); // 存进去
Point p2 = new Point(1, 2); // 内容和 p1 完全一样
System.out.println(p1.equals(p2)); // true ← equals 说它俩相等!
System.out.println(cache.get(p2)); // 💥 null ← 可是 get 不到?!
System.out.println(cache.get(new Point(1,2))); // 💥 还是 null
}
}
我盯着那两行 null,百思不得其解。p1.equals(p2) 明明返回 true——我清清楚楚地告诉了 Java,内容相同的 Point 就是相等的;可我用一个和 p1 "相等"的 p2 去 cache.get(),拿到的却是 null!就好像我把东西存进了一个柜子,明明拿着一模一样的钥匙,却怎么也打不开那个柜子。在真实业务里,这意味着我的缓存完全失效——每次都 get 不到,每次都重新计算,缓存形同虚设;更糟的是,如果用 HashSet 去重,"相等"的对象会被当成不同的全都塞进去,去重也失效了。
第一件事:看清真相——HashMap 先用 hashCode 找桶,再用 equals 比对
我去深入研究了 HashMap 的查找原理,才终于明白这个"明明相等却找不到"的诡异现象——HashMap 定位一个 key,是分两步的:先用 hashCode() 算出该去哪个"桶"找,再在那个桶里用 equals() 逐个比对;而我只重写了第二步的 equals,没重写第一步的 hashCode。
HashMap 查找的两步机制
# HashMap 内部是"数组 + 链表/红黑树"。存取一个 key 分两步:
# 第一步: 调 key.hashCode(), 算出哈希值 → 决定它在哪个"桶"(数组下标)
# 第二步: 到那个桶里, 用 key.equals() 和桶里已有的key逐个比, 找到相等的
# put(p1, "结果A"):
# - 算 p1.hashCode() → 比如得到 桶#5
# - 把 (p1, "结果A") 放进 桶#5
# get(p2): (p2 内容和 p1 相同, equals 为 true)
# - 算 p2.hashCode() → ???
# ★ 问题来了: 我【没重写 hashCode】, 用的是 Object 默认的 hashCode
# - Object.hashCode() 默认基于【对象的内存地址】, 每个 new 出来的对象都不同!
# - 所以 p1.hashCode() 和 p2.hashCode() 【几乎一定不同】(它们是两个不同对象)
# - 假设 p2.hashCode() → 桶#9
# 于是 get(p2) 的过程:
# - 去 桶#9 找 (因为p2的hashCode指向桶#9)
# - 但 p1 在 桶#5! 桶#9 里压根没有 p1
# - → 找不到 → 返回 null!
# (equals 根本没机会被调用——因为第一步就去错了桶)
# 根本矛盾: equals 说"p1、p2 相等", 但 hashCode 说"它俩不一样"(桶不同);
# 这违反了 Java 的核心约定: 【两个相等的对象, 必须有相同的 hashCode】!
# 核心: HashMap 先用hashCode定位桶、再用equals比对; 只重写equals不重写hashCode,
# 会导致相等的对象hashCode不同、落到不同桶, get时去错桶找不到 → 缓存/去重全失效。
真相大白,我恍然大悟。原来 HashMap 找一个 key,是分两步的:第一步调 hashCode() 算出该去哪个"桶",第二步才在那个桶里用 equals() 逐个比对。而我只重写了 equals(第二步),hashCode 还用着 Object 的默认实现——而 Object.hashCode() 默认是基于对象内存地址的,每个 new 出来的对象都不同!所以 p1 和 p2 虽然 equals 为 true,它们的 hashCode 却几乎一定不同:put(p1) 把它放进了桶 #5,而 get(p2) 时算出 p2 的 hashCode 指向桶 #9,于是跑到桶 #9 去找——那里压根没有 p1,自然返回 null(equals 根本没机会被调用,因为第一步就去错了桶)。根本矛盾在于:我的 equals 说"p1、p2 相等",但默认的 hashCode 说"它俩不一样"——这违反了 Java 一条核心约定:两个相等的对象,必须拥有相同的 hashCode。
第二件事:正解——equals 和 hashCode 必须同时重写,且保持一致
搞懂了原理,正解就清晰了:重写 equals 时,必须同时重写 hashCode,且保证"两个 equals 相等的对象,hashCode 也相同"。
// ====== 正解一(手写, 用 Objects.hash): 同时重写 equals 和 hashCode ======
import java.util.Objects;
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y); // ★ 用参与 equals 的【同样字段】算 hashCode
}
// 关键: equals 用了 x、y 来判断相等, hashCode 也必须用 x、y 来算;
// 这样内容相同的两个 Point, hashCode 必然相同 → 落到同一个桶 → 找得到!
}
// 现在: cache.get(new Point(1,2)) → "结果A" ✓ 缓存生效!
// ====== 正解二(推荐, Java 14+ record): 自动生成 equals/hashCode ======
record Point(int x, int y) { }
// record 自动为你生成基于所有字段的 equals、hashCode、toString!
// → 用 record 做不可变数据载体, 再也不会忘记/写错 hashCode。
// ====== 正解三: 用 IDE 自动生成 ======
// IntelliJ/Eclipse 都能一键"Generate equals() and hashCode()",
// 它会成对生成、用同样的字段, 避免手写出错。
// ====== 正解四: Lombok 注解 ======
// @EqualsAndHashCode // Lombok 自动生成成对的 equals 和 hashCode
// class Point { int x, y; }
// ====== equals/hashCode 的约定(必须遵守) ======
// 1. 一致性: a.equals(b)==true => a.hashCode()==b.hashCode() (相等则哈希相同)
// 2. 反过来不要求: hashCode 相同, 不一定 equals 相等(允许哈希冲突)
// 3. 自反/对称/传递性: equals 要满足这些数学性质
// 4. 用于 hashCode 的字段, 必须是 equals 用到的字段的子集(通常用同一组)
// 核心: 重写equals【必须】同时重写hashCode, 且用【相同的字段】计算, 保证"相等的对象哈希相同";
// 优先用 record(Java14+)/IDE生成/Lombok 自动成对生成, 别手写漏掉或写不一致。
修复的核心,是"equals 和 hashCode 成对出现、用相同字段、保持一致"。正解一(手写):用 Objects.hash(x, y)——关键是 hashCode 要用和 equals 相同的字段(x、y)来算,这样内容相同的两个 Point 哈希必然相同、落到同一个桶,就找得到了。正解二(推荐,Java 14+):用 record——record Point(int x, int y) {} 会自动生成基于所有字段的 equals、hashCode、toString,做不可变数据载体时再也不会忘记或写错。正解三:IDE 自动生成(一键成对生成);正解四:Lombok 的 @EqualsAndHashCode。必须牢记 equals/hashCode 的约定:一致性——a.equals(b) 为 true 则 a.hashCode()==b.hashCode();反过来不要求(允许哈希冲突);equals 还要满足自反/对称/传递性;用于 hashCode 的字段通常和 equals 用同一组。归根结底:重写 equals 必须同时重写 hashCode、用相同字段计算;优先用 record/IDE 生成/Lombok 成对生成,别手写漏掉或写不一致。
第三件事:equals/hashCode 及相关的其他常见坑
排查后我把 equals/hashCode 及"对象相等性"相关的其他常见坑也系统梳理了一遍。
equals/hashCode 相关的其他常见坑
# 1. 只重写equals不重写hashCode(本文): HashMap/HashSet失效。→ 成对重写。
# 2. 反过来: 只重写hashCode不重写equals: 也不行。
# → 哈希到同一桶, 但equals用默认(比地址)还是不相等, 照样找不到。
# 3. 用【可变对象】做 HashMap 的 key:
# Point p = new Point(1,2); map.put(p, "x"); p.x = 99; // ★ 改了key的字段!
# → put后hashCode变了, 再也找不到这个entry(它还在老桶里)! → key要用不可变对象。
# 4. equals 用 == 比较字符串/包装类: if (this.name == o.name) // ✗
# → 应该用 .equals()。== 比的是引用, 不是内容。
# 5. equals 参数写成具体类型: public boolean equals(Point o) // ✗ 这是重载不是重写!
# → 必须是 equals(Object o), 加 @Override 让编译器帮你查。
# 6. 继承体系下的 equals: 父类子类混比, 对称性容易被破坏。
# → 优先用组合而非继承; 或用 getClass() 严格判类型。
# 7. hashCode 用了 equals 没用的字段, 或反之: 导致不一致。
# → 两者必须基于同一组字段。
# 共同根源: "相等性"在Java里是由equals(逻辑相等)和hashCode(哈希分桶)【共同定义】的,
# 二者必须协同一致; 任何让它俩"说法不一"的写法, 都会让基于哈希的集合行为异常。
# 核心: equals和hashCode是一对必须协同的契约, 用同一组(不可变)字段成对实现;
# 别只写一个、别用可变字段当key、别把equals(Object)写成重载、加@Override兜底。
排查让我把 equals/hashCode 的其他坑也梳理清了。一、只重写 equals 不重写 hashCode(本文)。二、反过来只重写 hashCode 不重写 equals(同样找不到)。三、用可变对象做 key——put 后改了 key 的字段,hashCode 变了就再也找不到那个 entry,key 要用不可变对象。四、equals 里用 == 比字符串/包装类(应用 .equals)。五、equals 参数写成具体类型(equals(Point o) 是重载不是重写,必须 equals(Object o) 加 @Override)。六、继承体系下 equals 对称性被破坏。七、hashCode 和 equals 用的字段不一致。它们的共同根源是:"相等性"在 Java 里是由 equals(逻辑相等)和 hashCode(哈希分桶)共同定义的,二者必须协同一致;任何让它俩"说法不一"的写法,都会让基于哈希的集合行为异常。核心是:equals 和 hashCode 是一对必须协同的契约,用同一组(不可变)字段成对实现;加 @Override 兜底。下面这张图,是这次 HashMap get 不到的成因与解法:
第四件事:重写情况与 HashMap 行为对照表
这次踩坑后,我把"重写了哪个、HashMap 会怎样"整理成一张表,定义类时对照检查。
| 重写情况 | equals 行为 | HashMap/HashSet 行为 | 结论 |
|---|---|---|---|
| 都不重写 | 比内存地址 | 同内容对象算不同 key | 看需求, 按身份区分时可用 |
| 只重写 equals(本文) | 比内容(对) | get 不到, 去重失效 | ✗ 最危险的错误 |
| 只重写 hashCode | 比地址(错) | 同桶但 equals 不等, 找不到 | ✗ 也是错的 |
| 都重写且一致 | 比内容(对) | 正常工作 | ✓ 正确 |
| 都重写但不一致 | 比内容 | 行为诡异不可预测 | ✗ 隐蔽 bug |
| 用 record | 比所有字段 | 正常工作 | ✓ 推荐 |
这张表把"重写组合 → HashMap 行为"钉死了。核心是:只有"equals 和 hashCode 都重写、且基于相同字段保持一致"(或直接用 record)才是正确的;只重写其一、或两者不一致,都会让基于哈希的集合行为异常。它给我的最大启发是:有些"能力",是由一组必须协同的方法共同提供的,缺一不可、且必须互相一致;你不能只实现其中一部分,就指望整体能正确工作。这其实是面向对象里一个重要的概念——"契约(contract)":Java 在 Object 的文档里明确规定了 equals 和 hashCode 的"契约"(相等的对象必须哈希相同等);这个契约不是建议,而是"整个集合框架(HashMap、HashSet 等)正确运转所依赖的前提";当你重写其中一个方法时,你就进入了这份契约,必须把它完整地履行(同时正确重写另一个),否则就破坏了集合框架赖以工作的假设。这让我意识到:覆盖/实现一个框架的方法时,要去了解"这个方法和哪些方法构成一组契约、框架对它们有什么共同约定",而不能孤立地只看一个方法;很多"诡异的框架行为",根源都是我们在不知情的情况下,破坏了某个隐含的、跨方法的契约。履行完整的契约、而非孤立地实现单个方法——是与框架正确协作的关键。
第五件事:为什么是"相等则哈希必相同",而非反过来
我还特意想透了 equals 和 hashCode 这个约定的"方向性",理解了它就再也不会记错。
| 命题 | 是否必须成立 | 原因 |
|---|---|---|
| equals 相等 ⇒ hashCode 相同 | ✓ 必须 | 否则去错桶, 找不到相等的对象 |
| hashCode 相同 ⇒ equals 相等 | ✗ 不要求 | 允许哈希冲突, 同桶再用 equals 区分 |
| equals 不等 ⇒ hashCode 不同 | ✗ 不要求 | 不同对象可以哈希冲突 |
| hashCode 不同 ⇒ equals 不等 | ✓ 自然成立 | 是第一条的逆否命题 |
这张表把约定的"方向"讲透了。核心是:约定只要求"相等 ⇒ 哈希相同"这一个方向(及其逆否"哈希不同 ⇒ 不相等"),而不要求反过来——因为哈希是允许"冲突"的(不同对象可以哈希相同,落到同一个桶后再用 equals 区分)。理解这个"方向性",关键在于理解 hashCode 的作用:hashCode 的作用是"快速地把对象分到不同的桶,缩小查找范围",它是一个"性能优化的、允许不精确(冲突)的初筛";而 equals 才是"精确的、最终的相等判定";所以约定必须保证"相等的对象一定被分到同一个桶"(否则初筛就把该找的人筛掉了),但不需要保证"同桶的就一定相等"(同桶的可以再用 equals 精筛)。这让我领悟到一个优雅的设计思想:很多高效的查找结构,都采用"快速初筛(可不精确)+ 精确细判"的两级策略——用一个廉价、可能有误差的方法(hashCode)快速缩小范围,再用一个昂贵、精确的方法(equals)在小范围内定论;而这种策略要正确工作的前提,就是"初筛绝不能把真正匹配的目标漏掉(允许多筛进无关的,但不允许漏掉对的)"——这正是"相等 ⇒ 哈希相同"这个单向约定的深层逻辑。从一个约定的"方向性",读懂背后"初筛 + 细判"的设计智慧——是这个经典 Java 坑给我的额外馈赠。
第六件事:定义一个类时,我现在的判断习惯
现在每当我定义一个类、尤其是会被放进集合的类,我都会按这张图先想清楚:
这张图的精髓,是"先问要不要按内容相等,要就 equals/hashCode 成对重写、优先用 record"。对象按身份区分就用默认;内容相同算相等就必须成对重写 equals 和 hashCode,会进集合/做 key 时尤其重要、且 key 要不可变。实现上:纯数据类直接用 record 自动生成,普通类用 IDE/Lombok,手写就用 Objects.hash、equals 和 hashCode 用同组字段、加 @Override。这套习惯,让我定义类时,从"想起来才重写 equals"变成了"先想清楚相等语义、要重写就成对重写"——核心始终是:equals 和 hashCode 是必须协同一致的契约,重写就成对重写、用同组不可变字段,优先 record。
我立下的几条规矩
这场"HashMap get 不到"的事故,换来了我写 Java 时,刻进骨子里的几条铁律:
- 重写 equals 必须同时重写 hashCode。缺一不可,这是铁律中的铁律。
- 两者用相同的字段计算。保证"相等的对象哈希必相同"。
- HashMap 先用 hashCode 找桶、再用 equals 比。理解这个才理解为何要成对。
- 纯数据类优先用 record。自动生成正确的 equals/hashCode/toString。
- HashMap 的 key 用不可变对象。put 后改 key 字段会让它永远丢失。
- equals 参数是 Object,加 @Override。否则写成重载,编译器不报你不知。
- 别孤立实现框架方法。先搞清它和哪些方法构成契约。
附:一段亲眼看清 equals/hashCode 协同的实验
口说无凭。下面这段代码,用"打印 hashCode + 看 HashMap 能不能 get 到",把这个坑和它的修复彻底演示清楚:
import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;
public class EqualsHashCodeDemo {
// 错误版: 只重写 equals
static class BadPoint {
int x, y;
BadPoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
if (!(o instanceof BadPoint)) return false;
BadPoint p = (BadPoint) o;
return x == p.x && y == p.y;
}
// 没重写 hashCode!
}
// 正确版: 成对重写
static class GoodPoint {
int x, y;
GoodPoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
if (!(o instanceof GoodPoint)) return false;
GoodPoint p = (GoodPoint) o;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
public static void main(String[] args) {
System.out.println("=== 错误版: 只重写 equals ===");
BadPoint b1 = new BadPoint(1, 2), b2 = new BadPoint(1, 2);
System.out.println("equals: " + b1.equals(b2)); // true
System.out.println("hashCode 相同? " + (b1.hashCode() == b2.hashCode())); // false!
HashMap bm = new HashMap<>();
bm.put(b1, "A");
System.out.println("get(b2): " + bm.get(b2)); // null ← 找不到!
HashSet bs = new HashSet<>();
bs.add(b1); bs.add(b2);
System.out.println("HashSet size: " + bs.size()); // 2 ← 去重失效!
System.out.println("\n=== 正确版: 成对重写 ===");
GoodPoint g1 = new GoodPoint(1, 2), g2 = new GoodPoint(1, 2);
System.out.println("equals: " + g1.equals(g2)); // true
System.out.println("hashCode 相同? " + (g1.hashCode() == g2.hashCode())); // true ✓
HashMap gm = new HashMap<>();
gm.put(g1, "A");
System.out.println("get(g2): " + gm.get(g2)); // A ← 找到了! ✓
HashSet gs = new HashSet<>();
gs.add(g1); gs.add(g2);
System.out.println("HashSet size: " + gs.size()); // 1 ← 去重生效! ✓
}
}
// 核心: 跑一遍, "只重写equals时hashCode不同、get返回null、HashSet去重失效"
// 和"成对重写后hashCode相同、get找到、去重生效"的对比一目了然, 一次刻进认知。
这段实验代码,是我这次踩坑后特意写下、保存在手边的"相等性校准器"。它用错误版和正确版的鲜明对比,把这个坑的因果链彻底摊开在你眼前:错误版里,b1.equals(b2) 是 true,但 b1.hashCode()==b2.hashCode() 却是 false——正是这个"相等但哈希不同"的矛盾,直接导致 get(b2) 返回 null、HashSet 的 size 是 2(去重失效);而正确版里,成对重写后 hashCode 也相同了,于是 get 找到了、HashSet 的 size 是 1(去重生效)。这正是我想用这段代码,留给每个 Java 开发者的核心方法:当你对"对象相等性"这种"看不见、摸不着、却深刻影响集合行为"的东西将信将疑时,最好的办法,就是把它的内部信号(hashCode() 的值、equals() 的结果)打印出来,再用"HashMap 能不能 get 到、HashSet 会不会去重"这种可观测的行为,去验证它。因为当你亲眼看到那个本该相同却为 false 的 hashCode 比较、那个本该找到却为 null 的 get 结果时,"equals 和 hashCode 必须协同"这个抽象的契约,就会以一种具体而牢固的方式,刻进你的直觉;而当你又看到修复后那个 true、那个 "A"、那个 size=1 时,你对"正确的契约该是什么样",也就有了一个具体的、可信赖的参照。把无形的契约,变成有形的、可打印、可观测的运行现象——这份"用实验把抽象约定坐实"的习惯,是我整个踩坑系列里理解一切"隐性契约/反直觉行为"最可靠的法门。对任何拿不准的契约,别只读文档,写个对比实验把它的内部信号和外部行为都打印出来看。
补充:这个坑给我的更大启发——别和"约定"对赌
除了技术细节,这次踩坑还让我反思了一个更普遍的工程习惯问题:我当初为什么会"只重写 equals"?因为我只想到了我要解决的问题("让内容相同的对象相等"),而没有去查"这件事在 Java 里的标准做法/完整约定是什么"。我凭着"改 equals 就能控制相等判断"的局部直觉,做了一个局部正确、整体错误的修改。
这让我领悟到一个超越这个具体坑的认知:在使用一个成熟的平台/语言/框架时,当你要做某件"看似常见"的事(自定义相等、自定义排序、自定义序列化……),最稳妥的做法,不是凭直觉直接动手,而是先花几分钟去了解"这件事的标准做法、完整约定、官方推荐的姿势是什么"。因为这些常见需求,平台的设计者几乎一定早已考虑过,并制定了一套"正确做它"的约定和工具(比如 Java 的 equals/hashCode 契约、record、Comparable 接口等);你凭直觉的"土法",很可能正好踩进设计者早已用约定标记出来的"雷区"。这其实是一种"谦逊"的工程态度:承认"我面对的这个需求,大概率不是什么新问题,前人和平台设计者很可能已经有了成熟的、考虑周全的答案";于是优先去"查找并遵循已有的最佳实践和约定",而不是急于"用自己的直觉重新发明一个(往往有缺陷的)轮子"。这种态度,能帮我们避开无数前人早已踩平、并立好警示牌的坑。
具体到 equals/hashCode,这个教训凝结成一句我现在常对自己说的话:"当你想覆盖一个框架方法时,先去读它的文档,看看它有没有'必须配套做什么'的约定。"Java 的 Object.equals 文档里,白纸黑字写着"重写 equals 通常需要重写 hashCode";我当初要是花一分钟读一眼,就不会有后来这大半天的折腾。很多坑,不是因为问题有多难,而是因为我们跳过了"读一眼文档、查一下约定"这个最简单、却最容易被急于求成的我们忽略的步骤。对文档和约定保持一份"动手前先看一眼"的敬畏——这是这个经典 Java 坑,在技术之外,给我的最实在的一课。
写在最后
回头看,这场由"只重写 equals 不重写 hashCode"引发的、缓存彻底失效的事故,真正教给我的,远不止"记得成对重写"这一个知识点。它让我对"方法之间的隐性契约",以及"正确地融入一个框架"这件事,有了一次深刻的体会。我栽跟头,是因为我把 equals 和 hashCode 看成了两个独立的方法——我以为我只是想"自定义相等判断",改 equals 就够了;却不知道,在 Java 的世界里,这两个方法被一份无形的契约紧紧绑在一起,共同定义了"对象的相等性"这一件事,而整个集合框架(HashMap、HashSet……)都建立在这份契约之上。我只履行了契约的一半,就破坏了框架赖以运转的根基。这让我领悟到一个深刻的认知:在一个成熟的框架/语言里,很多方法、接口、约定之间,存在着"隐性的、却必须遵守的契约"——它们不是孤立的,而是协同工作的一组;框架的正确运转,默默地依赖着我们去完整地、一致地履行这些契约;而我们之所以会踩坑,常常正是因为只看到了"我想改的那个方法",没看到"它背后那张与其他方法相连的契约网"。这其实给了我一个重要的工程习惯:当你要覆盖、实现、或定制一个框架提供的方法/接口时,不要只盯着那一个点,而要去问:"它属于哪一组契约?框架对这组方法有什么共同的约定和假设?我这个改动,会不会破坏某个我没注意到的一致性?";带着对"契约整体性"的敬畏去定制框架,才能让"我的代码"和"框架的机制"严丝合缝地协作,而不是貌合神离地埋雷。看见方法背后那张无形的契约网,并完整地履行它——这,是我用一次缓存失效的事故,换来的、关于 Java、也关于如何与一切框架严谨协作的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写下 @Override public boolean equals 时,手指顺势就去补上 hashCode,那我对着那个"明明相等却 get 不到"的 HashMap 排查的这大半天,就值了。
—— 别看了 · 2026