重写了equals()真的就一定要重写hashCode()吗?

目录

1、先说结论

2、使用哈希表存储自定义对象时,为什么重写了equals()就一定要重写hashCode()?

2.1、什么是哈希表?

2.2、Java中常见的基于哈希表实现的容器

2.3、重写了equals()就一定要重写hashCode()


​​​​​​​

1、先说结论

最近在复习Java基础相关的内容,无意间又看到了这道高频面试题:

  • 为什么重写了equals()就一定要重写hashCode()?

其实这个问题之前自己也没有完全想清楚,反正八股文嘛,背就完了呗!但是随着工作的这两年以来,不断丰富的人生阅历以及不断增加的代码量,让我对很多事物的认知也悄悄地发生一些改变,作为一个自认为有点追求的人,自己拿来恰饭的东西还是要尽量做到知其然并知其所以然。 

首先先来看看万物之祖Object类中的equals()和hashCode():

/**
 * 比较是否是同一个对象
 */
public boolean equals(Object obj) {
        return (this == obj);
}
/**
 * native方法 大致就是返回对象的内存地址
 */
public native int hashCode();

在Object类的hashCode()上方有一段很长的注释:


Returns a hash code value for the object. This method is supported for the benefit of hash tables such as those provided by java.util.HashMap.
The general contract of hashCode is:

  • Whenever it is invoked on the same object more than once during an execution of a Java application, the hashCode method must consistently return the same integer, provided no information used in equals comparisons on the object is modified. This integer need not remain consistent from one execution of an application to another execution of the same application.
  • If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.
  • It is not required that if two objects are unequal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce distinct integer results. However, the programmer should be aware that producing distinct integer results for unequal objects may improve the performance of hash tables.

这段注释主要告诉了我们有关hashCode()以下两个方面的信息:

  1. hashCode()的主要用途:返回一个对象的哈希值,支持此方法是为了有利于使用哈希表,例如由 java.util.HashMap。
  2. hashCode()的一些官方约束
  • Java程序运行的时候,多次调用同一个对象所产生的hashCode必须相同;
  • 如果两个对象用equals()进行比较是相等的话,那么调用它们的hashCode()也应该产生相同的整数结果;
  • 如果两个对象用equals()进行比较是不想等的话,那么调用它们的hashCode()不一定要产生相同的结果(意思就是可以相同也可以不同),但是程序员应该要知道不相等的对象产生不同的hashCode有利于提高哈希表的性能(降低哈希冲突出现的可能性);

上方我用红色字体标注出了比较关键的部分,由这一部分我们可以总结出以下两点:

  1. 首先,hashCode()的存在是为了让哈希表相关的数据结构和容器提高存储效率;
  2. 其次,如果自定义的对象可能要在哈希表相关的数据结构和容器中进行存储的话,那么就要满足JDK官方给出的约束条件,即通过equals()比较相等则hashCode就必须相等;

那么我们是否可以直接得出以下结论:

  1. 如果自定义的类需要在哈希表相关的数据结构和容器中进行存储的话,那么我们重写了equals()就一定要重写hashCode(),以确保满足JDK官方的约束条件;
  2. 如果完全确定这个自定义类不会跟哈希表沾边的话,我们就可以在重写了equals()的情况下不重写hashCode();

2、使用哈希表存储自定义对象时,为什么重写了equals()就一定要重写hashCode()?

刚才我们已经得出了一个结论,那就是如果判断自定义对象可能会使用哈希表相关的容器进行存储时,我们重写了equals()就一定要重写hashCode()。JDK官方也很明确的在代码注释中写明了这一点,那究竟是为什么呢?

2.1、什么是哈希表?

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表

大致意思就是通过一个函数快速的定位数据在数组中的存储位置,从而提高数据的访问效率,哈希表的查询时间复杂度是常数级别。

2.2、Java中常见的基于哈希表实现的容器

Java集合框架中,我们经常会用到基于哈希表实现的容器:

  • HashMap(基于哈希桶实现)
  • HashSet(内部使用的是HashMap存储数据)
  • HashTable(已经过时,官方不再维护)
  • ConcurrentHashMap(线程安全的HashMap)

2.3、重写了equals()就一定要重写hashCode()

通过代码解释会更好理解一点

先来看看我们最常用的String类里是怎么重写equals()和hashCode():

/**
 * String重写的equals() 比较两个字符串的字面意思 如果字面意思相同 则认为两个对象相等
 */
public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while (n-- != 0) {
                if (v1[i] != v2[I])
                    return false;
                    i++;
            }
            return true;
        }
    }
    return false;
}

/**
 * String重写的hashCode() 用char[]计算hash值 如果两个String对象的字面意思相同 那么它们的     
 * hash值也一定相同
 */
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

由上可知,String类重写的equals()和hashCode(),都与这个字符串的字面意思有关,如果两个字符串的字面意思相同,那么用equals()比较一定相等,hashCode也一定相同,即:

String a = new String("111");
String b = new String("111");
a.equals(b) == true;
a.hashCode() == b.hashCode() == true;

我们再来看看我们最常用的HashMap中的两个关键方法put(K key, V value)、get(K key)以及其中涉及到的方法

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }    

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }


    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }


    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != 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;
    }

简单的描述一下put(K key, V value)的过程(只描述与哈希表相关的部分,数组扩容等操作跳过不谈):

  1. 调用对象本身的hashCode()参与hash值计算;
  2. 通过上一步计算出的hash值与数组长度进行与运算,得出元素在数组中存储的下标;
  3. 查看计算出的数组下标当前是否已经存在链表或者树,如果不存在,则直接添加一个新的链表头;如果已存在,则在链表的尾端或者树的叶子节点上添加该元素;

再简单的描述一下get(K key)的过程:

  1. 调用对象本身的hashCode()参与hash值计算;
  2. 判断当前table数组的长度是否为0,且计算出的元素存储下标是否为空。如果判断为true,那么判断链表头存储的对象是否和传进来的key是同一个对象,或使用key对象的equals()判断链表头节点是否和key对象相等;
  3. 如果链表的头结点(或树的叶子节点)与key对象不相等,那么从头到尾遍历链表(或树),直到找到相等的元素。如果没有找到则返回null;

总结一下,HashMap在存取对象的时候,在存的阶段调用了对象的hashCode(),在取的时候调用了对象的hashCode()和equals()。那么思考一下,如果String类只重写了对象的equals(),但是没有重写hashCode()会出现什么情况?

String a = new String("111");
String b = new String("111");
a.equals(b) == true;

//假如String重写了equals()但是没有重写hashCode()
Map<String, Integer> map = new HashMap<>();
map.put(a, 10);
map.get(b) == null;

上面这段代码在我们日常编码中非常常见,使用字符串作为key保存数据。通过"111"这个字符串存进去的数据,我们当然要可以通过"111"去把它取出来。但如果String重写了equals()没有重写hashCode()的话,我们就没有办法在另外的方法或类中通过构造一个与a字面意思相同的b把数据取出来。

我们再设想一个场景,考试之后老师在系统中录入学生的成绩,学生有两个属性:

  • name - 姓名
  • stdId - 学号

如果两个对象A和B的name和stdId都相同,那么我们就认为这两个对象代表的就是同一个学生。在这个前提下,我们使用对象A存进去的数据,可以通过对象B取出来,这在现实世界中是一个符合大众逻辑的事情。

定义学生类:

public class Student{
    /**
     * 姓名
     */
    private String name;

    /**
     * 学号
     */
    private String stdId;

    /**
     * 对象哈希值
     */
    private int hash;

    public Student(String name, String stdId){
        this.name = name;
        this.stdId = stdId;
    }

    public String getName(){
        return this.name;
    }

    public String getStdId(){
        return this.stdId;
    }

    /**
     * 如果两个学生的姓名和学号相同 那么就定他俩是同一个人
     * @param anObject 另一个学生
     * @return true or false
     */
    @Override
    public boolean equals(Object anObject){
        if(anObject == this){
            return true;
        }
        if(anObject instanceof Student){
            Student anotherStudent = (Student) anObject;
            if(this.name.equals(anotherStudent.getName()) && this.stdId.equals(anotherStudent.getStdId())){
                return true;
            }
        }

        return false;
    }

    /**
     * 计算name这个属性的hash值作为Student对象的hash值
     * @return
     */
    @Override
    public int hashCode(){
        int h = this.hash;
        if(h == 0 && this.name != null){
            h = this.name.hashCode();
            this.hash = h;
        }

    return h;
}

在这个类中我们重写了equals()和hashCode()。根据我们之前讲的逻辑,如果两个对象的姓名和学号相同,那么我们就认定这两个对象是代表同一个学生。

Student a = new Student("吴签", "1001");
Student b = new Student("吴签", "1001");

Map<Student, Integer> map = new HashMap<>();
map.put(a, 100);
(map.get(b) == 100) == true;

如果我们重写了equals()但是没有重写hashCode()会怎么样呢?很明显通过b对象去取a对象存的数据会取不到

Student a = new Student("吴签", "1001");
Student b = new Student("吴签", "1001");

Map<Student, Integer> map = new HashMap<>();
map.put(a, 100);
(map.get(b) == null) == true;

3、如何重写equals()和hashCode()

可以借助Apache Common Lang提供的以下工具类来重写equals()和hashCode():

  • EqualsBuilder
  • HashCodeBuilder
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值