这个并不是一个通用性编程问题,只属于Java领域内对类对比操作的专有问题。
这其实是个复杂问题,涉及到一些底层,也体现了Java语言的设计者是如何看待类的对比操作。

复杂是因为如下几个概念很相似却又不同:

  • 实例
  • 实例引用地址
  • 不同属性的实例

看待这个问题,建议从宏观的角度看待会比较明朗。


equals()为什么要改写?

在超级父类 Object 中已经写好了 equals() 方法,所有继承他的类,都可以用这个方法比较两个实例的引用地址是否相同。

public boolean equals(Object obj) {
    return (this == obj);
}

什么是引用地址?看代码介绍:

public class AddressDemo {
    public static void main(String[] args) {
        // 每次 new 都会创建新对象,获得新地址
        Person p1 = new Person("Alice");
        Person p2 = new Person("Alice"); // 又一个新对象!
        Person p3 = p1; // 指向同一个对象

        System.out.println("p1 == p2: " + (p1 == p2)); // false
        System.out.println("p1 == p3: " + (p1 == p3)); // true
        System.out.println("p2 == p3: " + (p2 == p3)); // false
        System.out.println("p1.equals(p2): " + (p1.equals(p2))); // false
        System.out.println("p1.equals(p3): " + (p1.equals(p3))); // true
        System.out.println("p2.equals(p3): " + (p2.equals(p3))); // false
    }
}

class Person {
    String name;
    public Person(String name) { this.name = name; }
}

一般来说equals都够用,但是一些业务流程需要这样处理:

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 没有重写 equals,使用 Object 的默认实现
}

// 测试
Person p1 = new Person("张三", 25);
Person p2 = new Person("张三", 25); // 内容完全相同,但是不同对象

System.out.println(p1.equals(p2)); // false ❌
System.out.println(p1 == p2);      // false

想要p1对比p2,如果类的实例内容相同,就认为他们是同样的对象,即使他们的地址不同。
那就需要子类改写equals()了。

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object obj) {
        // 比较内容而不是引用地址
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return age == person.age && Objects.equals(name, person.name);
    }
}

// 测试
Person p1 = new Person("张三", 25);
Person p2 = new Person("张三", 25); // 内容相同

System.out.println(p1.equals(p2)); // true ✅
System.out.println(p1 == p2);      // false

那为什么又要连hashCode一起改写?

@IntrinsicCandidate  //改标记的方法表明hashCode() 实现确实是一个 native 方法,它的具体实现细节因 JVM 而异。
public native int hashCode();

随便举例一个 JVM hashCode 的实现方式:

// 策略 :函数地址 + 随机数
hashCode = (object_address ^ random_seed);

用代码看看默认的hashCode效果:

public class AddressDemo {
    public static void main(String[] args) {
        // 每次 new 都会创建新对象,获得新地址
        Person p1 = new Person("Alice");
        Person p2 = new Person("Alice"); // 又一个新对象!
        Person p3 = p1; // 指向同一个对象

        System.out.println("p1.hashCode(): " + (p1.hashCode())); // 1854731462
        System.out.println("p2.hashCode(): " + (p2.hashCode())); // 2036368507
        System.out.println("p3.hashCode(): " + (p3.hashCode())); // 1854731462
    }
}

class Person {
    String name;
    public Person(String name) { this.name = name; }
}

可以看出默认的hashCode和默认的equals所输出的结果是一致性的。

那回归我们的问题,为什么我们的自定义的子类改写了equals,需要连hashCode一起改写?

如果你的子类需要在HashMap、HashSet等类使用的时候(使用对比相等性操作),会发生行为异常!

如果你的类需要在集合中使用,HashMap、HashSet等类会调用hashCode()去作为对比的参考。
比如下面的问题——找不到值:

import java.util.*;

public class Test {
    public static void main(String[] args) {
        Map<Person, String> map = new HashMap<>();
        
        Person p1 = new Person("Alice", 25);
        Person p2 = new Person("Alice", 25); // 业务逻辑上认为是同一个人
        
        map.put(p1, "经理");
        
        // 问题来了!
        System.out.println(map.get(p2)); // 输出:null
        // 虽然 p1 和 p2 内容相同,但哈希值不同,所以找不到!
    }
}

还有,重复值问题:

Set<Person> set = new HashSet<>();
set.add(new Person("Bob", 30));
set.add(new Person("Bob", 30)); // 逻辑上应该去重

System.out.println(set.size()); // 输出:2 - 竟然有两个!

这就解释了,为什么必须同时重写 equals 和 hashCode 方法,因为你修改了equals改变了对比实例的逻辑,但是hashCode还是默认的逻辑,在其他对比方法调用的时候就会引发BUG、风险、漏洞、灾难!

这也是Java希望使用者遵循规则:相等的对象必须有相等的哈希值。

完整的配套重写案例:

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return age == person.age && Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

// 现在可以正常工作了
System.out.println("集合中是否包含李四: " + personSet.contains(p2));
// 输出:true ✅

最后,我们来看看官方在代码中给予的建议:

     /**
     * Indicates whether some other object is "equal to" this one.
     *
     * @apiNote
     * It is generally necessary to override the `hashCode` method whenever this
     * method is overridden, so as to maintain the general contract for the `hashCode`
     * method,  which states that equal objects must have equal hash codes.
     */
    public boolean equals(Object obj) {
        return (this == obj);
    }

总结

感觉这两个方法设计得比较耦合,为后续的不理性使用埋下了灾难的风险。
所以对equals或hashCode任意一个方法重写时,都需要配套的一起都改写了,但一起改写不是目的,他们返回的逻辑的一致性才是最终目的。

posted on 2024-08-26 11:39  Mysticbinary  阅读(434)  评论(0)    收藏  举报