2026-10-28 equals 与 hashCode:少写一个会怎样
写了个
Point当HashSet的元素,把同一个坐标 add 了三次,以为只有一个。结果size()返回 3。查了半天才发现——我只覆写了equals,忘了hashCode。
一、先把现象摆出来
class BadPoint {
final int x, y;
BadPoint(int x, int y) { this.x = x; this.y = y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof BadPoint p)) return false;
return x == p.x && y == p.y;
}
// 故意不写 hashCode
}
=== 1. 只覆写 equals:HashSet 不去重 ===
放入 3 个「相等」的对象,size = 3
两个对象的 equals 是 true
两个对象的 hashCode 是 149928006 / 713338599
equals 明明返回 true,HashSet 里却躺着三个。看最后一行的两个 hashCode——不一样。
二、因为 HashSet 是先比 hashCode 的
哈希表的查找分两步:先算 hashCode() 定位到哪个桶,再在这个桶里用 equals() 逐个比。
BadPoint 没覆写 hashCode,用的是 Object 的默认实现——跟对象身份绑定。三个 new 出来的对象地址不同,hashCode 就不同,于是被分到三个不同的桶里。分到不同桶之后,equals 根本不会被调用,因为压根没机会碰面。
所以不是"equals 没生效",是equals 没被问到。
三、补上 hashCode 就正常了
@Override
public int hashCode() {
return 31 * x + y;
}
=== 2. 两个都覆写:正常去重 ===
放入 3 个「相等」的对象,size = 1
size 变成 1。
31 * x + y 这个写法不是随手写的:31 是奇素数,编译器还会把 31 * x 优化成 (x << 5) - x。用 Objects.hash(x, y) 也行,它内部也是这么算的,只是多一次装箱。
四、更阴的一个坑:可变对象当 key
这件事比忘记覆写更隐蔽。
class MutKey {
int id;
MutKey(int id) { this.id = id; }
@Override public boolean equals(Object o) { return o instanceof MutKey m && id == m.id; }
@Override public int hashCode() { return id; }
}
hashCode 用的是 id,而 id 不是 final。
=== 3. 可变对象当 key:改完字段就找不到了 ===
put 之后 get = 值
containsKey = true
把 id 从 7 改成 8 之后:
get = null
containsKey = false
map.size 还是 1(那个键还在里面,但没人找得到)
放进 HashMap 的时候,它是按 id=7 算出的桶,存在 7 号桶里。改成 8 之后再查,程序会去 8 号桶找——那个桶是空的。原来的键还躺在 7 号桶里,但再也没有任何路径能走到它。
size 还是 1,说明它没被删掉,只是变成了一个够不着的幽灵。这个东西在内存里一直占着,直到整个 map 被回收。
所以我现在的习惯是:能放 map 里当 key 的字段,一律 final。要改就换一个对象,别在原地改。
五、hashCode 全冲突会怎样
这个实验是想说明另一件事:hashCode 的好坏影响的是快慢,不是对错。
class *****Key {
final int id;
@Override public int hashCode() { return 1; } // 全部撞到一个桶
}
=== 4. hashCode 全冲突:对不对 vs 快不快 ===
装着 10000 条,查 2000 次:
hashCode 全返回 1 总 30 ms 每次 15240.6 ns
hashCode 正常 总 0 ms 每次 90.8 ns
查到的值校验:1999000 / 1999000(两边结果一致,说明正确性没受影响)
慢 168 倍,但查出来的值一模一样。
原因还是那个两段式:所有键都挤进同一个桶,桶里退化成一条链表,每次查找都要在链表里挨个 equals——从 O(1) 变成 O(n)。但只要 equals 写对,结果就是对的。
这也解释了 JDK 8 为什么要给桶加红黑树:当一个桶里的元素超过 8 个,链表转成树,把最坏情况从 O(n) 压到 O(log n)。
六、契约其实是四条
写 equals 的时候,约定有这几条(我按自己踩过的顺序排):
自反、对称、传递——这三条是 equals 本身的,平时不太会错。
一致性:只要参与比较的字段没变,equals 的结果就不能变。所以字段要 final,或者至少别拿可变字段去比。
与 hashCode 一致:两个对象 equals 为 true,hashCode 必须相等。反过来不要求——hashCode 相等的两个对象,equals 完全可以为 false(上面那个全返回 1 的例子就是)。
最后一条才是最容易漏的,因为它不报错,只是默默地不工作。
七、结论
equals 和 hashCode 是一对,必须一起覆写。只写一个不会编译失败、不会抛异常、单元测试不覆盖到的话也发现不了,但 HashMap 和 HashSet 从那一刻起就不再可靠。
hashCode 的质量只影响性能不影响正确性——但"只影响性能"这件事在数据量大的时候也很致命,168 倍的差距不是小数。
对象一旦放进哈希容器,参与哈希的字段就不能再变。这不是规矩,是哈希表的实现方式决定的。
附:验证代码
// 1. 只覆写 equals —— 去重失败
static class BadPoint {
final int x, y;
BadPoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof BadPoint p && x == p.x && y == p.y;
}
// 不写 hashCode:三个 new 出来的对象 hashCode 各不相同
}
// 2. 两个都覆写 —— 正常
static class GoodPoint {
final int x, y;
GoodPoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof GoodPoint p && x == p.x && y == p.y;
}
@Override public int hashCode() { return 31 * x + y; }
}
// 3. 可变字段参与哈希 —— 改完就找不到
static class MutKey {
int id; // 不是 final
MutKey(int id) { this.id = id; }
@Override public boolean equals(Object o) { return o instanceof MutKey m && id == m.id; }
@Override public int hashCode() { return id; }
}
// 4. hashCode 全冲突 —— 正确但慢
static class *****Key {
final int id;
@Override public boolean equals(Object o) { return o instanceof *****Key c && id == c.id; }
@Override public int hashCode() { return 1; }
}
浙公网安备 33010602011771号