两个对象值相同(x.equals(y) == true),但是可能存在hashCode不同吗?

84 阅读6分钟

面试题:两个对象值相同(x.equals(y) == true),但是可能存在hashCode不同吗?

涉及到的技术知识

(x.equals(y)==true),这段代码,看起来非常简单,但其实里面还是涉及了一些底层知识点的,首先我们基于equals这个方法进行探索。

equals这个方法,在每个对象中都存在,以String类型为例,其方法定义如下:

public boolean equals(Object anObject) {
    if (this == anObject) { 
        return true;
    }
    if (anObject instanceof String) { //判断对象实例是否是String
        String anotherString = (String)anObject; //强转成string类型
        int n = value.length;
        if (n == anotherString.value.length) { //如果两个字符串相等,那么它们的长度自然相等。
            //遍历两个比较的字符串,转换为char类型逐个进行比较。
            char v1[] = value;  
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i]) //采用`==`进行判断,如果不相同,则返回false
                    return false;
                i++;
            }
            return true; //否则返回true。
        }
    }
    return false;
}

首先来分析第一段代码,判断传递进来的这个对象和当前对象实例this是否相等,如果相等则返回true

if (this == anObject) { 
    return true;
}

==号的处理逻辑是怎么实现的呢?

了解==判断

在java语言中==操作符号,这个比较大家都知道,是基于引用对象的比较。他比较的是两个对象的内存地址,如果==返回true,说明内存地址相同。

String.equals源码

继续分析equals中的源码,剩余部分源码的实现逻辑是

  1. 比较两个字符串的长度是否相等,如果不相等,直接返回false
  2. 把两个String类型转换为char[]数组,并且按照数组顺序逐步比较每一个char字符,如果不相等,同样返回false
public boolean equals(Object anObject) {
    //省略
    if (anObject instanceof String) { //判断对象实例是否是String
        String anotherString = (String)anObject; //强转成string类型
        int n = value.length;
        if (n == anotherString.value.length) { //如果两个字符串相等,那么它们的长度自然相等。
            //遍历两个比较的字符串,转换为char类型逐个进行比较。
            char v1[] = value;  
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i]) //采用`==`进行判断,如果不相同,则返回false
                    return false;
                i++;
            }
            return true; //否则返回true。
        }
    }
    return false;
}

==和equals

通过上面的分析,我们知道,在 Java 中比较两个对象是否相等主要是通过 ==号,比较的是他们在内存中的存放地址。Object 类是 Java 中的超类,是所有类默认继承的,如果一个类没有重写 Object 的 equals方法,那么通过equals方法也可以判断两个对象是否相同,因为它内部就是通过==来实现的。

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

这里的相同,是说比较的两个对象是否是同一个对象,即在内存中的地址是否相等。而我们有时候需要比较两个对象的内容是否相同,即类具有自己特有的“逻辑相等”概念,而不是想了解它们是否指向同一个对象。

例如比较如下两个字符串是否相同String a = "Hello"String b = new String("Hello"),这里的相同有两种情形,是要比较 a 和 b 是否是同一个对象(内存地址是否相同),还是比较它们的内容是否相等?这个具体需要怎么区分呢?

如果使用 == 那么就是比较它们在内存中是否是同一个对象,但是 String 对象的默认父类也是 Object,所以默认的equals方法比较的也是内存地址,所以我们要重写 equals方法,正如 String 源码中所写的那样。

  1. 先比较内存地址
  2. 再比较value值
public boolean equals(Object anObject) {
    //省略
    if (anObject instanceof String) { //判断对象实例是否是String
        String anotherString = (String)anObject; //强转成string类型
        int n = value.length;
        if (n == anotherString.value.length) { //如果两个字符串相等,那么它们的长度自然相等。
            //遍历两个比较的字符串,转换为char类型逐个进行比较。
            char v1[] = value;  
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[i]) //采用`==`进行判断,如果不相同,则返回false
                    return false;
                i++;
            }
            return true; //否则返回true。
        }
    }
    return false;
}

这样当我们 a == b时是判断 a 和 b 是否是同一个对象,a.equals(b)则是比较 a 和 b 的内容是否相同,这应该很好理解。

JDK 中不止 String 类重写了equals 方法,还有数据类型 Integer,Long,Double,Float等基本也都重写了 equals 方法。所以我们在代码中用 Long 或者 Integer 做业务参数的时候,如果要比较它们是否相等,记得需要使用 equals 方法,而不要使用 ==

因为使用 ==号会有意想不到的坑出现,像这种数据类型很多都会在内部封装一个常量池,例如 IntegerCache,LongCache 等等。当数据值在某个范围内时会直接从常量池中获取而不会去新建对象。

如果要使用==,可以将这些数据包装类型转换为基本类型之后,再通过==来比较,因为基本类型通过==比较的是数值,但是在转换的过程中需要注意 NPE(NullPointException)的发生。

问题解答

问题:两个对象值相同(x.equals(y) == true),但是可能存在hashCode不同吗?

理论情况下,x.equals(y)==true,如果没有重写equals这个方法,这两个对象的内存地址是是相同的,也就意味着hashCode必然也相等。

那有没有可能hashCode不同呢?如果一定要做,也是可以实现的,我们来看下面这个例子。

public class App 
{
    public static void main( String[] args ) {
        A a = new A();
        B b = new B();
        System.out.println(a.equals(b));
        System.out.println(a.hashCode() + "," + b.hashCode());
    }
}

class A {
    @Override
    public boolean equals(Object obj) {
        return true;
    }
}

class B {
}

运行结果如下

true
692404036,1554874502

从结果可以看到:equals返回true,但是hashCode不同。

虽然我们模拟了这个可能性,但是原则上是错误的,因为这样违反了hashCode的通用规定,可能会导致该类无法结合所有基于散列集合一起正常工作,比如HashMap、HashSet等。

public class App {
    public static void main( String[] args ) {
        Person p1=new Person("mic",18);
        Person p2=new Person("mic",18);

        HashMap<Person,String> hashMap=new HashMap<>();
        hashMap.put(p1,"mic");
        System.out.println(hashMap.get(p2));
    }
}
class Person {
    private String name;
    private int age;

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

    //省略getter/setter

    @Override
    public boolean equals(Object obj) {
        if(this==obj){
            return true;
        }
        if(obj instanceof Person){
            if(this.getName()==((Person) obj).getName()&&this.getAge()==((Person) obj).getAge()){
                return true;
            }
        }
        return false;
    }
}

在上述代码中,重写了equals方法,但是没有重写hashCode方法,当调用Person类的hashCodo方法时,默认就是调用父类Object的hashCode方法,根据随机数返回一个整数值。在equals方法中,我们是根据nameage进行判断两个对象是否相等。

main方法中构建了两个对象p1p2,我们用HashMap存储存储,将对象作为key。把p1存入到hashMap中,再通过p2来获取,在原则上,由于p1和p2相等,所以理论上是能够拿到结果的,但是实际运行结果如下:

null

Process finished with exit code 0

熟知`HashMap 原理的同学应该知道,HashMap 是由数组 + 链表的结构组成,这样的结果就是因为它们 hashCode 不相等,所以放在了数组的不同下标,当我们根据 Key 去查询的时候结果就为 null。

得到的结果我们肯定不满意,这里的 p1p2 虽然内存地址不同,但是它们的逻辑内容相同,我们认为它们应该是相同的。

为了避免这类问题的存在,所以约定了一条原则重写equals方法的同时也需要重写hashCode方法。这是一种通用约定,这个约定包含以下几个方面。

  • 方法都必须始终返回同一个值。
  • 如果两个对象根据equals方法比较是相等的,那么调用这两个对象中的hashCode方法都必须产生同样的整数结果。
  • 如果两个对象根据equals方法比较是不相等的,那么调用者两个对象中的hashCode方法,则不一定要求hashCode方法必须产生不同的结果。但是给不相等的对象产生不同的整数散列值,是有可能提高散列表(hash table)的性能。

从理论上来说如果重写了equals方法而没有重写hashCode方法则违背了上述约定的第二条,相等的对象必须拥有相等的散列值。但是规则是大家默契的约定,如果我们就喜欢不走寻常路,在重写了 equals方法后没有覆盖hashCode方法,就会造成严重的后果。

问题总结

综合分析下来,对于该问题的正确解答如下

  1. 如果两个对象值相同,有可能存在不同的hashCode。具体的实现方法是,只重写equals方法,不重写hashCode
  2. 这种处理方式会存在风险,在实际开发中,必须遵循重写equals方法的同时也需要重写hashCode方法这一原则。否则在Java散列集合类操作中,会存在null的问题。