我把对象放进 HashMap,转头用一个"一模一样"的 key 却取不出来:Java 里没重写 hashCode/equals 埋下的那颗雷
这个 bug 把我整懵了好一阵。我有一个缓存逻辑,用一个自定义的对象当 HashMap 的 key——比如一个表示"坐标点"的 Point 对象。我先 put 进去一个 Point(1, 2) 对应的值,然后,转头用一个"内容完全一样"的 new Point(1, 2) 去 get,我满心以为能拿到刚才存的值。可结果,get 返回的是 null。
两个 Point,坐标都是 (1, 2),内容一模一样,凭什么存进去能用一个、取出来却用另一个就取不到?我盯着代码看了半天,百思不得其解。我甚至怀疑是不是 HashMap 本身有 bug。直到我把 HashMap 的工作原理、以及 Java 里 hashCode 和 equals 这两个方法的关系,真正搞明白,才一拍大腿——原来,我犯了一个 Java 里极其经典、几乎每个人都会踩一次的错:我用一个自定义对象当 HashMap 的 key,却没有重写它的 hashCode() 和 equals() 方法。于是,在 HashMap 眼里,两个内容相同的 Point(1, 2),被当成了两个完全不同的 key——存进去的是一个,我拿来取的是另一个,自然取不到。
故障现场:两个"相同"的 key,却被当成了两个
我把出问题的代码,简化到最小:
// 一个自定义的坐标点类 —— 注意: 没有重写 hashCode 和 equals!
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
// 既没重写 hashCode(), 也没重写 equals() —— 这就是雷!
}
public class Main {
public static void main(String[] args) {
Map map = new HashMap<>();
// 存进去一个 Point(1, 2)
map.put(new Point(1, 2), "你好");
// 用一个"内容一样"的 Point(1, 2) 去取
String value = map.get(new Point(1, 2));
System.out.println(value); // ← 输出: null !!! 取不到!
// 验证: 两个内容相同的 Point, Java 默认认为它们"不相等"
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2)); // false ! (默认 equals 比的是"地址")
System.out.println(p1.hashCode()); // 比如 1829164700
System.out.println(p2.hashCode()); // 比如 2018699554 (和 p1 完全不同!)
}
}
看着那个 null,以及 p1.equals(p2) 居然是 false、两个 Point 的 hashCode 还完全不同,我大概明白问题出在哪了。问题的核心是:我的 Point 类,既没有重写 hashCode(),也没有重写 equals()。而 Java 里,Object 类提供的默认 hashCode() 和 equals(),判断的根本不是"两个对象的内容是否相同",而是"两个对象是不是同一个对象(即内存地址是否相同)"。于是,new Point(1, 2) 和另一个 new Point(1, 2),虽然内容一模一样,但它们是两个不同的对象、占着两块不同的内存,所以在默认的 hashCode 和 equals 看来,它们是不同的——hashCode 不同,equals 也返回 false。我 put 进去时用的那个 Point,和我 get 时用的那个 Point,在 HashMap 眼里,压根就是两个毫不相干的 key——这就是为什么我存得进去、却取不出来。
第一件事:搞懂 HashMap 是怎么"找 key"的
要彻底理解这个坑,我必须搞懂 HashMap 内部,到底是怎么存取一个 key 的。当我把这个过程弄明白,hashCode 和 equals 的作用,就一目了然了:
// HashMap 存取一个 key 的过程, 用到了 hashCode 和 equals 两个方法:
// put(key, value) 时:
// 1. 调用 key.hashCode(), 算出一个哈希值
// 2. 用这个哈希值, 决定把这个键值对, 放进哪个"桶(bucket)"里
// 3. (如果桶里已有元素, 用 equals 判断是不是同一个 key)
// get(key) 时:
// 1. 调用 key.hashCode(), 算出哈希值 —— 用它找到"应该在哪个桶"
// 2. 在那个桶里, 用 key.equals() 逐个比较, 找到"真正相等"的那个 key
// 3. 返回它对应的 value
// 所以, HashMap 找 key, 是【两步】走的:
// 第一步: 靠 hashCode 定位"桶" (快速缩小范围)
// 第二步: 靠 equals 在桶里精确匹配
// 我的坑:
// put 时, Point(1,2) 的默认 hashCode 算出来是 A, 放进了 A 号桶
// get 时, 另一个 Point(1,2) 的默认 hashCode 算出来是 B (不同!), 跑去找 B 号桶
// → B 号桶里根本没有我的数据! 直接返回 null, 连 equals 那步都走不到!
// 即使 hashCode 碰巧相同, 默认 equals 比地址也会返回 false, 照样找不到。
原理终于清晰了。HashMap 存取一个 key,是分两步走的:第一步,调用 key.hashCode() 算出一个哈希值,用它来决定这个 key 应该放进/位于哪一个"桶(bucket)"里——这一步,是为了快速地缩小查找范围;第二步,在定位到的那个桶里,调用 key.equals() 去逐个精确比较,找到那个"真正相等"的 key。这两步,分别依赖 hashCode 和 equals,缺一不可。而我的坑,就出在第一步上:我 put 进去的 Point(1,2),它的默认 hashCode 算出来是 A,于是被放进了 A 号桶;可我 get 时用的那个另一个 Point(1,2),它的默认 hashCode 算出来却是 B(因为默认 hashCode 基于内存地址,两个对象地址不同)——于是 HashMap 跑去 B 号桶里找,而 B 号桶里压根没有我的数据,直接就返回了 null,连第二步的 equals 比较都没机会走到!就算退一万步,两个 Point 的 hashCode 碰巧相同、定位到了同一个桶,那默认的 equals 比较的是内存地址,也会返回 false,照样找不到。说到底,我那两个"内容相同"的 Point,因为没重写这两个方法,在 HashMap 的两步查找里,从第一步就走岔了。
第二件事:正解——同时重写 hashCode 和 equals,且保持一致
搞懂了根因——"没重写 hashCode/equals,两个内容相同的对象被当成不同的 key"——正解就明确了:当你要把一个自定义对象当作 HashMap 的 key(或放进 HashSet)时,必须同时重写它的 hashCode() 和 equals() 方法,让它们基于对象的"内容"来判断相等,而不是基于内存地址。
// 正解: 同时重写 hashCode() 和 equals(), 基于"内容"判断
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 == null || getClass() != o.getClass()) return false;
Point p = (Point) o;
return x == p.x && y == p.y; // 内容相同, 就相等!
}
@Override
public int hashCode() {
return Objects.hash(x, y); // 基于内容算哈希 (x 和 y)
}
}
// 现在:
Map map = new HashMap<>();
map.put(new Point(1, 2), "你好");
System.out.println(map.get(new Point(1, 2))); // "你好" ✓ 取到了!
// 因为现在:
// 两个 Point(1,2) 的 hashCode 相同(都基于 x=1,y=2 算) → 定位到同一个桶
// 两个 Point(1,2) 的 equals 返回 true(内容相同) → 在桶里精确匹配上!
// 现代写法: 用 record (Java 16+), 自动生成 equals/hashCode/toString!
record PointRecord(int x, int y) {} // 一行搞定, 自带基于内容的 equals/hashCode
这个正解的核心,是让 hashCode 和 equals 都基于对象的"内容(字段值)"来工作,而不是默认的"内存地址"。重写后,两个内容相同的 Point(1,2):它们的 hashCode 都基于 x=1, y=2 算出来,因此相同,会被定位到同一个桶;它们的 equals 因为内容相同而返回 true,在桶里能精确匹配上——于是,存进去能取出来,大功告成。这里有一条至关重要、必须牢记的铁律:hashCode 和 equals 必须同时重写,且必须保持一致——即"两个 equals 相等的对象,它们的 hashCode 也必须相等"。这是 Java 规定的、hashCode 与 equals 之间的"契约"。如果你只重写了 equals、没重写 hashCode,那两个 equals 相等的对象,hashCode 却可能不同,会被定位到不同的桶,照样找不到——这个坑更隐蔽,因为你"明明重写了 equals"却还是不行。所以,要么两个都重写、要么用现代的 record(Java 16+,一行就能自动生成一致的 equals/hashCode),绝不能只重写一个。
下面这张图,对比了"没重写"和"正确重写"两种情况下,HashMap 找 key 的过程:
这张图的对比很清楚:左边红色那条,没重写,两个对象基于地址算出不同的 hashCode,定位到不同的桶,在错误的桶里当然找不到、返回 null;右边绿色那条,正确重写后,基于内容算出相同的 hashCode、定位到同一个桶,再用 equals 精确匹配成功。两条路的根本分野,在于 hashCode 和 equals 是基于"内容"还是基于"地址"。
第三件事:hashCode 与 equals 的"契约",不守会出各种怪事
这次踩坑,让我认真去研究了 hashCode 和 equals 之间那份必须遵守的"契约"。我发现,这份契约一旦没守好,会引发的怪事,远不止"取不到值"一种:
// hashCode 与 equals 的契约(必须遵守):
// 1. 如果 a.equals(b) == true, 那么 a.hashCode() 必须 == b.hashCode()
// (相等的对象, 哈希码必须相等! —— 这条最关键, 违反就找不到 key)
// 2. 如果 a.hashCode() == b.hashCode(), a.equals(b) 不一定为 true
// (哈希码相同的对象, 不一定相等, 这叫"哈希冲突", 是允许的)
// 3. equals 要满足: 自反、对称、传递、一致 (是个等价关系)
// 违反契约引发的各种怪事:
// 怪事1: 只重写 equals, 没重写 hashCode → 取不到 key (本文的隐藏版)
// equals 相等, 但 hashCode 不同 → 定位到不同桶 → 找不到
// 怪事2: HashSet 里出现"重复"元素
Set set = new HashSet<>();
set.add(new Point(1, 2));
set.add(new Point(1, 2));
System.out.println(set.size()); // 没重写: 2 (重复了!); 正确重写: 1
// 怪事3: hashCode 写得不稳定(每次返回不同), key 会"丢失"
// hashCode 必须基于"不变的字段", 别用会变的字段, 否则 put 后字段一变就找不到了
// 怪事4: 把"可变对象"当 key, 放进去后改了它的字段 → 也会找不到!
Point key = new Point(1, 2);
map.put(key, "v");
key.x = 99; // 改了 key 的字段!
System.out.println(map.get(key)); // null! 因为 hashCode 变了, 跑去别的桶了
这一研究,让我对 hashCode/equals 这份"契约"的重要性,有了深刻的认识。这份契约的核心,是第 1 条:"两个 equals 相等的对象,它们的 hashCode 必须相等"——这是保证 HashMap/HashSet 能正常工作的基石,违反它,就会出现"存了取不到"的怪事。而违反这份契约,引发的怪事五花八门:怪事1(只重写 equals 不重写 hashCode)是本文坑的"隐藏版",更难发现;怪事2是 HashSet 里出现本不该有的"重复"元素;怪事3、4则尤其阴险——它们指向一个常被忽视的要点:用作 key 的对象(以及它 hashCode 所依赖的字段),最好是"不可变"的!因为如果你把一个对象 put 进 HashMap 后,又去修改了它那些参与 hashCode 计算的字段,那么它的 hashCode 就变了——它"应该在的桶"也变了,可它其实还待在"原来的桶"里,于是你再用它去 get,就会跑到"新桶"里去找,自然又是一个 null。这些怪事共同说明:hashCode 和 equals 这两个方法,以及它们之间的契约,是 Java 集合框架正确运转的隐形基石;不理解、不遵守它,就会在 HashMap/HashSet 上,踩中各种'存了找不到、该去重却没去重'的、让人摸不着头脑的坑。
第四件事:重写 equals,也藏着不少"魔鬼细节"
正确重写 equals,看起来只是"比较各个字段"那么简单,可我深究后发现,要写一个"正确"的 equals,有不少容易忽略的细节,稍不留神就会违反它必须满足的"等价关系"特性:
// 写一个"正确"的 equals, 必须满足"等价关系"的几条性质:
class Point {
int x, y;
@Override
public boolean equals(Object o) {
// 细节1: 自反性 —— a.equals(a) 必须为 true
if (this == o) return true; // 同一个对象, 先判断, 既正确又高效
// 细节2: 处理 null —— a.equals(null) 必须为 false (不能抛异常!)
if (o == null) return false;
// 细节3: 类型检查 —— 用 getClass() 还是 instanceof? 各有讲究
if (getClass() != o.getClass()) return false; // 严格: 必须同一个类
// (用 instanceof 会让"子类和父类"可能相等, 破坏对称性, 要小心)
// 细节4: 比较字段 —— 注意不同类型字段的比较方式!
Point p = (Point) o;
return x == p.x && y == p.y;
// 基本类型用 ==; 对象用 Objects.equals(a, b) (防 null);
// 浮点用 Double.compare (防 NaN/正负0); 数组用 Arrays.equals
}
// 细节5: 重写了 equals, 就【必须】重写 hashCode! (铁律)
@Override public int hashCode() { return Objects.hash(x, y); }
}
// 对称性反例(常见错误): 父类用 instanceof, 子类一比, a.equals(b) != b.equals(a)
// 一致性: equals 的结果不应依赖"会变的状态"(如时间、随机数)
这些"魔鬼细节",让我对"写一个正确的 equals"有了敬畏。一个正确的 equals,必须满足数学上"等价关系"的几条性质:自反性(a.equals(a) 为真)、对称性(a.equals(b) 和 b.equals(a) 结果一致)、传递性(a=b、b=c 则 a=c)、一致性(多次调用结果不变),以及"和 null 比较返回 false"。这几条看似理所当然,实则处处是坑:细节2(处理 null)——equals(null) 必须返回 false 而非抛异常;细节3(类型检查)——用 getClass() 还是 instanceof 大有讲究,用 instanceof 处理继承时,容易破坏"对称性";细节4(字段比较)——基本类型用 ==、对象字段要用 Objects.equals 防 null、浮点要用 Double.compare 防 NaN 和正负零、数组要用 Arrays.equals;细节5则是那条贯穿始终的铁律——重写了 equals 就必须重写 hashCode。这些细节告诉我:equals 绝不是'简单地比比字段'就完事,它是一个需要严谨地满足一系列数学性质的方法——而这,正是为什么现代 Java 强烈推荐用 IDE 自动生成、用 Objects 工具类、或干脆用 record,来避免手写时踩这些细节坑。把这几条 equals 必须满足的性质整理成一张表:
| 性质 | 含义 | 违反的后果 |
|---|---|---|
| 自反性 | a.equals(a) 为真 | 对象找不到自己 |
| 对称性 | a=b 则 b=a | 集合行为不确定 |
| 传递性 | a=b,b=c 则 a=c | 逻辑混乱 |
| 一致性 | 多次调用结果不变 | 时灵时不灵 |
| 非空性 | a.equals(null) 为假 | NullPointerException |
| 与 hashCode 一致 | 相等对象哈希码相等 | HashMap 找不到 key |
第五件事:把"自定义对象当 key/放集合"的注意点固化下来
这次踩坑,让我把"什么时候必须重写 hashCode/equals、又该怎么做"沉淀成了一份清单,以后写涉及对象比较和集合的代码,照着自查:
// 自定义对象 与 hashCode/equals 的检查清单:
// 1. 对象要当 HashMap key / 放进 HashSet → 必须同时重写 hashCode + equals
// 2. 两者要"一致": equals 相等的对象, hashCode 必须相等
// 3. 优先用工具, 少手写:
// - Java 16+: 直接用 record, 自动生成且永远一致 (首选!)
record Point(int x, int y) {}
// - 用 IDE 自动生成 (Alt+Insert / Generate equals() and hashCode())
// - 用 Objects.hash(...) 和 Objects.equals(...) 简化手写
// 4. hashCode/equals 要基于"不变的、有业务含义的"字段
// - 别用会变的字段 (否则 put 后字段一变就找不到)
// - 用作 key 的对象, 最好整个就是不可变的 (immutable)
// 5. 重写 equals 时, 把 equals 该满足的性质都照顾到 (自反/对称/传递/一致/非空)
// 6. 集合里存自定义对象, 想去重 / contains / 查找 → 都依赖正确的 hashCode/equals
// 7. 用 Lombok 的话, @EqualsAndHashCode 也能生成, 但注意它默认包含哪些字段
// 核心: "两个对象在业务上'相等'意味着什么?" —— 想清楚这个,
// 就知道 equals 该比哪些字段、hashCode 该基于哪些字段。
这份清单的灵魂,是一个需要你先想清楚的根本问题:"对我的业务来说,两个对象'相等',到底意味着什么?"——是字段全都一样才算相等?还是只要某个 ID 相同就算相等?想清楚了这个业务层面的"相等"定义,你就知道 equals 该比较哪些字段、hashCode 该基于哪些字段了。而在"怎么做"这个层面,最重要的一条经验是:能用工具就别手写。手写 hashCode/equals 极易踩前面那些"魔鬼细节"的坑,而现代 Java 给了你多种更安全的选择:Java 16+ 的 record 是首选(一行定义,自动生成永远一致的 equals/hashCode/toString);其次是用 IDE 自动生成、用 Objects.hash/Objects.equals 简化、或用 Lombok 的 @EqualsAndHashCode。此外,清单里我尤其想强调"用作 key 的对象最好是不可变的"这一条——它能从根上避免'put 进去后字段一变就找不到'的那个隐蔽坑。把"该不该重写、怎么重写"的判断要点汇总成一张表:
| 场景 | 要不要重写 | 推荐做法 |
|---|---|---|
| 对象当 HashMap key | 必须(两个都重写) | 用 record / IDE 生成 |
| 对象放 HashSet 去重 | 必须 | 用 record / IDE 生成 |
| 对象做 list.contains | 必须(否则比地址) | 重写 equals |
| 对象只是临时传值 | 可不重写 | — |
| key 字段会变 | 设计有问题 | 改用不可变对象 |
一张"自定义对象要不要重写 hashCode/equals"的决策图
把这次踩坑沉淀成一张图。每当你写一个自定义类时,照着它判断:
这张图的判断主线:对象只要会"按内容"参与相等判断(当 key、放 Set、做 contains),就必须同时重写 hashCode 和 equals;实现优先用 record 或 IDE 生成,基于不变的业务字段,且别让 key 字段在使用中变化。把这套判断变成写每个类时的本能,那个"存了取不到"的坑就再也碰不到你。
我立下的几条 hashCode/equals 规矩
这次"对象当 key 取不到值"的事故后,我给自己立了几条规矩:
- 当 key 必重写两者:对象要当 HashMap key、放 HashSet、做 contains,就必须同时重写
hashCode和equals。 - 两者必须一致:牢记契约——equals 相等的对象,hashCode 必须相等,绝不只重写一个。
- 优先用 record/工具:Java 16+ 直接用
record,老版本用 IDE 生成或Objects.hash/Objects.equals,少手写。 - 基于不变字段:hashCode/equals 基于"不变的、有业务含义的"字段,别用会变的字段。
- key 用不可变对象:用作 key 的对象最好整个不可变,避免 put 后字段一变就找不到。
- equals 照顾全性质:手写 equals 要满足自反、对称、传递、一致、非空,小心 getClass/instanceof、null、浮点、数组。
- 先想清"业务相等":先想清"两个对象在业务上相等意味着什么",再决定 equals 比哪些字段。
这几条里,第一条和第二条是最该刻进肌肉记忆的铁律。而贯穿所有规矩的那条主线,是对"框架运转所依赖的隐性约定"的理解与尊重。我这次栽跟头,根子上是我不知道 HashMap 这个我天天用的工具,它的正常运转,隐性地、默默地依赖着我所存入对象的 hashCode 和 equals 方法——这是一个 HashMap 和它的"使用者"之间,一份不成文、却必须遵守的"约定"。我违反了这个约定(没提供正确的 hashCode/equals),HashMap 没有报错(它没法报错,因为默认方法语法上是合法的),只是默默地、无声地失效了——这种'不报错、却默默失效'的坑,恰恰是最难排查的。很多框架、库、API,它们的正确运转,都隐性地依赖着一些'你必须遵守、它却不会强制检查'的约定;理解并主动遵守这些隐性约定,是用对它们、不踩这类'静默失效'坑的关键。
写在最后:用一个工具前,先懂它"依赖你什么"
这次被 hashCode/equals 坑到的经历,给我一个超越这个具体问题的、深刻的启示:我们用一个工具(库、框架、API)时,常常只关心'它能为我做什么',却很少去想'它反过来,依赖我提供什么、遵守什么';而恰恰是这些被我们忽略的'工具对我们的依赖与要求',一旦没满足,就会让工具'静默地失效',埋下最难排查的雷。HashMap 能为我提供"快速的键值存取"(它能为我做什么),这我很清楚;可它的这个能力,是建立在"我提供的 key 对象,有正确、一致的 hashCode 和 equals"这个前提之上的(它依赖我什么)——而这个前提,我却完全没意识到。我只看到了 HashMap 的"给予",没看到它的"要求",于是在它的"要求"没被满足的地方,栽了跟头。
想通这一点,我对待每一个我所使用的工具,都多了一份"它依赖我什么"的审视。一个工具和它的使用者之间,往往是一种'双向'的关系:工具为你提供能力,但通常也对你有所要求、有所依赖——它可能要求你提供某种正确实现的方法(如 hashCode/equals),要求你遵守某种调用顺序,要求你满足某种前置条件,要求你保证某种不变量。而这些'要求',很多时候是'隐性'的——文档里可能提了一句、可能藏在某个角落,而工具本身,又往往不会在你违反时,给你一个清晰的报错(它做不到)。于是,'满足工具对你的隐性要求',就成了用好它、不踩'静默失效'坑的一个关键、却又容易被忽略的前提。一个成熟的工具使用者,不只会问'这个工具能帮我做什么',更会主动地去搞清楚'这个工具,正确工作的前提是什么、它依赖我遵守哪些约定'。
所以,如果你也想真正用好、用对各种工具,我想把这次踩坑最想说的话送给你:用一个工具之前,除了搞清楚'它能为你做什么',更要花力气去搞清楚'它反过来依赖你什么、要求你遵守什么约定'。用 HashMap 前,搞清楚它依赖 key 的 hashCode/equals;用某个框架前,搞清楚它要求你实现哪些接口、遵守哪些生命周期;用某个 API 前,搞清楚它对参数、对调用顺序、对前置状态有什么要求。因为工具和你之间,从来不是'它单方面服务你'的关系,而是'你遵守它的约定、它才能正确服务你'的双向契约;而很多让人百思不得其解的'工具不灵了'的坑,追根溯源,都是因为我们只享受了工具的'给予',却忽略了、违反了它对我们的'要求'。那个让我存了取不到的 HashMap,最终教给我的,正是这份对"工具之依赖"的洞察——它让我懂得,真正用好一个工具,既要知道它能为你做什么,更要知道它正确工作,需要你为它做什么、守什么约定;唯有摸清这份双向的契约,你才能让手中的工具,稳稳地、不出意外地,为你所用。
—— 别看了 · 2026