Skip to content
Go back

hashCode与equals的契约——为什么重写equals必须重写hashCode?

hashCode 与 equals:只重写一个,HashMap 就崩了

一句话结论(30s)

equals 与 hashCode 是哈希表「先定位桶、再精确匹配」的两步契约,因为 HashMap 的 get 先用 hashCode() 定位桶、再用 equals() 在桶内比对,所以 equals 相等的对象 hashCode 必须相等,否则会查错桶返回 null;反过来只重写 hashCode 会让 equals 相等的对象被当成不同 key 各存一份。权衡:hashCode 越分散查找越接近 O(1),分布差会退化成 O(n),这就是「重写 equals 必须重写 hashCode」的根本原因。

核心原理(2min)

主流程:get(key) 分两步——先 hashCode() 定位桶(O(1)),再在桶内用 equals() 遍历链表/树精确匹配(最坏 O(log n))。Object 默认实现是引用比较(==)加基于内存地址的 hashCode,一旦按内容重写 equals,就必须配套重写 hashCode,使「equals 相等 ⇒ hashCode 相等」成立,并让不同对象尽量散列到不同桶。实现上用 Objects.hash(...) 或 31 系数的手写散列,31 是质数且 JIT 可把 31*i 优化成 (i<<5)-i。往下读之前先自己回答两个问题:为什么必须先 hashCode 再 equals,顺序能不能反过来?如果 equals 相等的两个对象 hashCode 不同,get 会在哪一步出错?后面所有反例,演示的都是这两个问题。

底层深入(5-10min)

Object 的默认实现

// Object 默认实现
public boolean equals(Object obj) { return this == obj; }       // 引用比较
public int hashCode() { return identityHashCode(this); }        // 基于内存地址

哈希表的两个步骤

hashMap.get(key):
  1. hashCode() → 确定桶位置(O(1))
  2. 在桶内 equals() 遍历链表/树 → 精确匹对(O(log n) 最坏)

这里值得追问一句:为什么第一步必须靠 hashCode、第二步必须靠 equals,不能反过来?想清楚这个顺序就理解了整份契约——hashCode 是 O(1) 的整数计算,能一步把范围缩到某个桶;equals 要逐字段比较、代价高,只能用在桶内那少数几个候选上。顺序一旦颠倒,equals 就得遍历全表,退化成 O(n)。JDK 里这两步就写在 get 和 getNode 里:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(key)) == null ? null : e.value;
}
final Node<K,V> getNode(Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & (hash = hash(key))]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

「先 hash 后 equals」在代码里一目了然:tab[(n - 1) & (hash = hash(key))] 先用 hashCode 算出扰动后的 hash、再与掩码相与定位桶(第一步),随后在桶内先用 == 快速短路、再用 key.equals(k) 精确匹配(第二步)。如果只重写 equals 不重写 hashCode,两个 equals 相等的对象 hash 不同,第一步就定位到了不同桶,第二步根本没机会执行,getNode 直接返回 null——这就是「重写 equals 必须重写 hashCode」的源码级证据。

只重写 equals() 不重写 hashCode()

先自己预测一下:只重写 equals、hashCode 仍按 Object 默认的内存地址来算,map.get(u2) 会返回什么?想好答案再往下看运行结果。

class User {
    String name;
    User(String name) { this.name = name; }
    public boolean equals(Object o) {
        return name.equals(((User) o).name);  // 按内容比较
    }
    // hashCode 仍是 Object 默认 → 按内存地址
}

User u1 = new User("Alice");
User u2 = new User("Alice");
System.out.println(u1.equals(u2));  // true ← equals 正确

Map<User, String> map = new HashMap<>();
map.put(u1, "data");
map.get(u2);  // null! ← hashCode 不同,查了不同桶

两个 equals 相等的对象,hashCode 不同 → HashMap 把它们放到不同桶 → get(u2) 在错误桶里查找 → 找不到 → 返回 null。

只重写 hashCode() 不重写 equals()

反过来再预测一次:如果 hashCode 全部返回同一个值、equals 却仍是引用比较,又会发生什么?这次的问题不在「查错桶」,而在「怎么判定是不是同一个 key」。

class User {
    public int hashCode() { return 1; }     // 所有对象落在同一个桶
    // equals 仍是 Object 默认 → 引用比较
}

User u1 = new User("Alice");
User u2 = new User("Alice");
map.put(u1, "data");
map.put(u2, "other");  // hashCode 相同 → 同桶 → 但 equals 是引用比较 → u1 != u2 → 存入第二份
System.out.println(map.size());  // 2! 相同内容的两个对象被当成不同 key

💭 思考:为什么「只重写 equals」和「只重写 hashCode」两种坑的表现完全相反?——把哈希表的两步再拆开看:第一步靠 hashCode 定位桶,第二步靠 equals 判 key。只重写 equals 时,两个内容相同的对象 hashCode 不同,第一步就查错了桶,get 直接返回 null;只重写 hashCode 时,两个对象挤进同一个桶(第一步对了),但 equals 仍是引用比较,第二步判「是不是同一个 key」失败,于是相同内容被当成两个 key 各存一份。一个错在第一步、一个错在第二步,正好说明这套契约是两条腿走路,缺了哪条,就倒在哪一步。

hashCode 的编写契约

  1. equals 相等的两个对象,hashCode 必须相等(否则 HashMap 崩)
  2. equals 不相等的两个对象,hashCode 尽量不相等(否则退化到 O(n))
  3. 同一个对象,多次调用 hashCode 必须返回相同的值(保证确定性)

💭 思考:为什么第一条契约是「必须相等」,第二条却只是「尽量不相等」?——因为 hashCode 返回的 int 只有 32 位,最多表示 2^32 个值,而对象可以源源不断地 new 出来,数量远超这个上限。鸽笼原理决定了「不同对象撞出同一个 hashCode」无法避免,所以契约只能锁死「相等 ⇒ 相等」这条底线,反向只能退化成「尽量分散」。别小看这条「尽量」——如果所有对象都返回同一个 hashCode,第一条照样成立(契约没破),但所有 key 挤进一个桶,查找退化成 O(n)。「正确」和「高效」是两回事,第二条管的就是高效。

如何正确实现

@Override
public int hashCode() {
    return Objects.hash(name, age, email);  // JDK 7+ 推荐
}

// 手动版本(避免 Objects.hash 的数组分配)
@Override
public int hashCode() {
    int result = 17;  // 非零初值
    result = 31 * result + (name != null ? name.hashCode() : 0);
    result = 31 * result + age;
    result = 31 * result + (email != null ? email.hashCode() : 0);
    return result;
}

为什么系数是 31?31 是质数,且 JIT 可优化 31 × i 为 (i << 5) - i(位移替代乘法)。31 不是随便选的——它是质数 + 可被 JIT 优化的罕见组合。

💭 思考:为什么偏偏是 31,而不是更「顺手」的 32 或 30?——反推一下:32 是 2 的幂,32 * i 等价于 i << 5,只是机械左移,高位被移出丢弃、低位补零,连续相乘后低位被系统性清空,而桶下标恰好取的是散列值的低位,于是撞桶率飙升;30 是偶数,乘出的数恒为偶数,低位同样出现系统性偏置。31 是质数又是奇数,相乘时每一位连同进位都参与进来,扰动更充分,还恰好能被 JIT 优化成 (i << 5) - i。所以「质数」不是迷信,是散列均匀性的工程选择。

章末提问

  1. hashCode 和 equals 是什么关系?为什么重写 equals 必须重写 hashCode? 结论:二者是「先定位桶、再精确匹配」的两步契约。因为 HashMap 的 get 先靠 hashCode 定位桶、再靠 equals 在桶内比对,一旦 equals 相等而 hashCode 不同,就会查错桶返回 null,契约被破坏。

  2. 为什么 hashCode 要尽量分散,而不是都返回同一个值? 结论:为了保住 O(1) 的查找。因为所有 key 挤在同一个桶时,get 就退化成对该桶链表的 O(n) 遍历;散列越分散,桶分布越均匀,查找才接近 O(1)。

  3. 为什么手写 hashCode 常用 31 做系数,而不是 30 或 32? 结论:31 是质数且能被 JIT 优化。因为质数能减少乘积的公约数、让散列更均匀;同时 31*i 可被 JIT 优化成 (i<<5)-i,用位移替代乘法,计算也快。

  4. 只重写 hashCode、不重写 equals 会怎样? 结论:内容相同的对象会被当成不同 key 各存一份。因为 hashCode 相同只会让它们进同一个桶,但 equals 仍是引用比较,判定「是不是同一个 key」时返回 false,于是重复插入。

  5. Object 默认的 equals 和 hashCode 是怎么实现的? 结论:equals 是引用比较(==),hashCode 基于对象身份(内存地址)。因为默认只认「是不是同一个对象」,所以只有当你按内容重写 equals 时,才需要同步重写 hashCode 来维持这条契约。


Share this post on:

Previous Post
享元模式——IntegerCache与String常量池的共享之道
Next Post
String、StringBuilder、StringBuffer 深入对比:从字节码到 JMH 性能