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; }
}
posted @ 2026-10-28 11:35  呱哒  阅读(5)  评论(0)    收藏  举报