29 面试官问你为什么重写 equals 就要重写 hashCode,他其实在考契约

发布时间:2026/11/1 1:47:04
29 面试官问你为什么重写 equals 就要重写 hashCode,他其实在考契约

面试现场。

面试官:"写个类,如果重写了 equals,要不要重写 hashCode?"

候选人A:"要,这是规范。"

面试官:"为什么?不重写会有什么后果?"

候选人A:"会……不一致?"

面试官:"那我举个具体的。两个对象,内容一样,equals 返回 true,但我把它们当 key 放进 HashMap,put 了两个,get 的时候还取不到,你觉得问题出在哪?"

候选人A答不上来了。

候选人B:"出在 hashCode。HashMap 找 key 是先按 hashCode 算桶位置的,两个对象 equals 相等但 hashCode 不同,被分到了两个桶,各存一份;get 的时候也只算一个桶,自然只看得到其中一个。"

面试官:"那反过来,只重写 hashCode 不重写 equals 呢?"

候选人B:"那就落同一个桶,但桶内比较时 equals 还是引用比较,还是认成两个,照样存两份,只是都挤在一个桶里,性能还退化了。"

面试官:"行,你继续。"


一、两种 equals,两种 hashCode

先说默认行为。Object 里的这两个方法,默认都不是按内容来的。

以下源码来自 JDK 8 java.lang.Object

public boolean equals(Object obj) { return (this == obj); // 默认就是引用比较 } public native int hashCode(); // 默认由 JVM 生成,与对象身份相关

equals默认就是this == obj,比的是"是不是同一个对象"。hashCode是 native 方法,默认值和对象身份绑在一起,不同对象通常得到不同的值。

这里有个常见的误区,得先掰正:很多人以为Object.hashCode()返回的就是对象的内存地址。JDK 早期确实和地址有一定关系,但 HotSpot 后来默认用的是别的算法,所以别把"地址"当标准答案。你只用记住两点——默认实现和对象身份绑定,同一个对象多次调用返回同一个值,不同对象大概率不同。

所以一个自定义类,如果你什么都不写,两个内容相同的实例,equals会返回 false,hashCode也几乎肯定不同。想要按内容比较,就得自己重写equals;重写之后再重写hashCode,两者一起动,才符合契约。只动一个,就是给自己埋雷。


二、契约:equals 的五条,hashCode 的三条

Object的源码注释里写得很清楚,这些是约定,不是可选的建议。

equals要满足:

- 自反性:x.equals(x)为 true

- 对称性:x.equals(y)和y.equals(x)结果一致

- 传递性:x.equals(y)且y.equals(z),则x.equals(z)

- 一致性:只要参与比较的状态没变,多次调用结果不变

- 对 null:x.equals(null)返回 false

hashCode要满足:

- 一致性:对象状态没变,多次调用返回同一个值

-equals 相等,则 hashCode 必须相等

- equals 不等,hashCode 不要求不同(但应尽量分散)

第二条就是命门,也是这道面试题的全部要害。equals 说这俩相等,hashCode 就必须给出同样的值。反过来 hashCode 相等,equals 不一定相等——这叫哈希冲突,是允许的。

对称性那条,现实里翻车的方式特别典型。假设一个类重写了 equals,里面写的是if (o instanceof MyType),而它的子类也重写了自己的 equals;父类实例和子类实例互相一比,就可能出现parent.equals(child)为 true、而child.equals(parent)为 false 的情况,对称性当场就破了。这也正是上面那份手写模板里用getClass() != o.getClass()而不是instanceof的原因——getClass要求两边类型完全一致,对称性更容易守住。

契约的完整清单,看图:


三、放进 HashMap:坑是怎么踩出来的

现在把契约放到 HashMap 里,看看后果。HashMap 找 key 分两步:先定位桶,再在桶内找。

第一步定位桶,用的是 key 的 hash:

以下源码来自 JDK 8 java.util.HashMap

static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } // 定位桶下标 int index = (n - 1) & hash;

hash()拿 key 的hashCode(),再把自己的高 16 位异或到低 16 位(这叫扰动函数,让高位也参与运算),然后用(n - 1) & hash算出桶下标。

第二步在桶内找,是这么比的:

if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) // 找到了

注意比较顺序:先比hash,再用==比引用,都比过了才轮到equals比内容。三者有一个不满足,就继续往链表或红黑树后面找。

现在把只重写equals、没重写hashCode的情形放进去:两个对象内容一样,equals为 true,但hashCode用的是默认值、彼此不同。于是hash()不同,算出来的index不同,两个对象被塞进了不同的桶。put 的时候互相看不见,成了两条独立记录。get 的时候按 key 的 hash 找到其中一个桶,另一个永远找不到。

表现出来就是:明明 put 进去两个相等的 key,size 却是 2;用其中一个对象 get,只拿得到自己存的那份,另一个"丢"了。

反过来只重写hashCode不重写equals:两个对象 hashCode 相同,落在同一个桶里,但桶内比较的equals默认还是引用比较,认成两个不同的 key,照样存两份,只是都挤在一个桶里;一旦桶里堆多了,查找性能就退化,超过阈值还会触发链表转红黑树。

把这两级记住:HashMap 判定"是不是同一个键"是分两步走的——桶由 hashCode 决定,桶内的身份由 equals 决定。两级必须配合,任何一级失效,整个查找都会出问题。

两种情况,看图:

结论很清楚:equals 和 hashCode 必须用同一批字段来算。你的 equals 用哪几个字段判断相等,hashCode 就应该用哪几个字段来生成。只算一半,契约立刻崩。

把这个坑写成能跑的代码,更直观:

class Key { String id; Key(String id) { this.id = id; } @Override public boolean equals(Object o) { if (!(o instanceof Key)) return false; return id.equals(((Key) o).id); } // 故意不重写 hashCode } Map<Key, String> map = new HashMap<>(); map.put(new Key("A"), "一"); map.put(new Key("A"), "二"); System.out.println(map.size()); // 2,而不是 1 System.out.println(map.get(new Key("A"))); // 很可能取到 null

两个new Key("A")内容相同、equals也为 true,却因为默认 hashCode 不同,进了不同的桶,put 两次成了两条独立记录;查询时按新算出来的 hash 找桶,很可能一个都命不中。这就是"存了两份还取不到"的现场。


四、怎么写才不出错

手写容易漏字段,实践里一般这么办:

@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(name, user.name) && age == user.age; } @Override public int hashCode() { return Objects.hash(name, age); }

几个细节。先比this == o(自己跟自己比直接过),再比 null 和类型,然后逐字段比较。用Objects.equals是为了兼容字段为 null 的情况;用Objects.hash是把多个字段折成一个哈希值。IDE 生成、或者 Lombok 的@EqualsAndHashCode,都会按你选的字段成对生成。

有一点要提前知道:Objects.hash内部是可变参数,每次调用会创建一个数组、把基础类型装箱,所以它比手动算要慢一些。对性能敏感、又被频繁当 key 用的类,可以自己用31 * result + ...的手写方式,字段别漏。

再点几个常见的坑。用 Lombok 的@EqualsAndHashCode时,默认会把这个类所有非静态、非瞬态的字段都算进去;如果某个字段本不该参与判等(比如自增 id、缓存字段),得用@EqualsAndHashCode.Exclude排掉,不然 equals 和 hashCode 会一起跑偏。还有,用可变字段参与计算最危险:对象当了 HashMap 的 key 之后,那个字段一改,hashCode 跟着变,桶位置就变了,你再也 get 不到原来那个键。所以当 key 的对象,参与 equals/hashCode 的字段最好都是不可变的。


🎯 面试官真正想听的答案

问"为什么重写 equals 就要重写 hashCode",他想听的是你能不能用契约解释后果,而不是背一句"这是规范"。

第一层给结论:"因为 Object 的契约规定,equals 相等则 hashCode 必须相等。重写 equals 是为了按内容判等,一旦两个对象内容相等、被判为 equals,它们的 hashCode 就必须一致。只重写 equals,默认 hashCode 还跟着对象身份走,契约当场就破了。"

第二层讲后果,落到 HashMap:hashCode 决定桶位置,equals 决定桶内是不是同一个键。两个相等的对象 hashCode 不同,会被分进两个桶,各存一份,get 只能命中其中一个,表现为 put 进去两份、取不出来。反向的坑是 hashCode 相同而 equals 不等,桶内退化、性能下降。

第三层给工程做法:equals 和 hashCode 必须用同一批字段,用Objects.equals、Objects.hash或 IDE/Lombok 成对生成;用不可变字段参与计算,避免对象进集合后状态变化导致 hash 漂移,再也 get 不到。


下篇预告

下一篇聊集合家族:ArrayList、LinkedList、Vector。面试官爱问它们的区别——是数组还是链表、扩容怎么扩。但他真正想听的是fail-fast:为什么你用 for-each 遍历 ArrayList 的时候顺手删了个元素,程序就抛ConcurrentModificationException?那个modCount到底在数什么?还有CopyOnWriteArrayList又是怎么绕开这个问题的。


唠点键盘之外的 · 第 29 篇