通俗解释equals和hashCode的关系和作用

前言

为何重写equals方法就得重写hashCode方法?

这是一个熟悉又很基础的面试题。
随着工作时间的增加,很多面试八股理论,慢慢在工作中的实践中得以验证。但也会有些理论,过了面试阶段之后,似乎就永远躺在理论里。
这篇文章就用日常开发中很容易遇到的一个开发场景,来验证这个理论。而不是像很多讲这个问题的文章那样通篇理论。

场景

日常开发中常见的一个业务操作:比较两个对象集合,找出这两个集合的交并集,或者单纯的数据去重。
如果自己写逻辑,实现细节少不了遍历集合,比较对象是否一致的操作。
一般有两种思路:

  1. 对象存储在List中,使用contains判重
  2. 对象存储在set中自动去重

此时重写对象的equals方法是必不可少的操作。
【我们假设在在这个demo业务中,两个对象的一致的标准是名字和性别】。

代码验证

demo1:对象存储在List中,使用contains判重

//测试对象,只写重写equals
public class StudentEntity {
    private String name;
    private Integer age;
    private Boolean sex;
    private Date birthDay;

    public StudentEntity() {
    }

    public StudentEntity(String name, Integer age, Boolean sex, Date birthDay) {
        this.name = name;
        this.age = age;
        this.sex = sex;
        this.birthDay = birthDay;
    }
    //getter and setter...
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        StudentEntity that = (StudentEntity) o;
        return Objects.equals(name, that.name) &&Objects.equals(sex, that.sex);
    }

//测试方法

//1. 存储在List中,使用contains判重
public void m1() {
    List list = new ArrayList<>();
    list.add(new StudentEntity("zs",2,true,null));
    list.add(new StudentEntity("zs",3,false,null));
    list.add(new StudentEntity("ls",1,true,null));

    StudentEntity s = new StudentEntity("zs",2,false,null);
    if (list.contains(s)) {
        System.out.println("------yes-----");//预期输出
    } else {
        System.out.println("------no-----");
    }
}

输出

------yes-----

和预期一致。

demo2:对象存储在set中自动去重

//存储在set中自动去重
public void m2() {
    Set set = new HashSet<>();
    set.add(new StudentEntity("zs",2,true,null));
    set.add(new StudentEntity("zs",3,false,null));
    set.add(new StudentEntity("ls",1,true,null));

    set.add(new StudentEntity("zs",2,false,null));//添加一个重复对象
    System.out.println("------over-----size:" + set.size());//预期size=3
}

demo2输出:

------over-----size:4

说明此时set并没有按照我们的预期去重。

重写hashCode方法

我们把hashCode方法也重写一下

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

怎么写hashCode?
直接使用IDEA自带的工具方法直接生成即可,包括前面equals也是这么重写的。注意重写的标准也是name+sex。

demo2输出:

------over-----size:3

此时HashSet终于正常实现去重功能了。

解释

通过前面的常见开发案例,可以认识到,这个简单开发建议确实是有实用价值,否则就会产生bug。下面就来分析一下原因。

ArrayList的contains方法源码

public boolean contains(Object o) {
    return indexOf(o) >= 0;
}

public int indexOf(Object o) {
    if (o == null) {
        for (int i = 0; i < size; i++)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = 0; i < size; i++)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}

可以看到contains最终的判断依据是对象的equals方法。所以第一个示例中,我们只重写了equals方法后,就可以使得contains方法正确执行。

HashSet的add方法

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

可以看到HashSet的底层使用的HashMap。
至此,终于讲到了本文的重点。

很多文章一上来就拿HashMap来举例子。但实际开发中有多少重写equals,hashCode方法的情况是为了让对象作为Map的key的场景呢?
事实上,把复杂对象作为key的场景,在实际生产中都很少使用。毕竟key在我们的直观印象里,就是一个小小的索引,往往都是字符串或数值类型。

HashSet如何实现的去重效果

简单逻辑

  • HashSet底层由HashMap实现,数据存在HashMap中的key中。
  • 由于HashMap中不会出现两个一样的key(如果key重复了,那么后一个value就覆盖了前一个value)。
  • 所以HashSet内存的值也就不会重复了。

HashMap数据结构

HashMap就是通过key的hashCode在数组上散列存储value数据的。
如果通过HashCode散列到了同一个位置,并且通过equals判断发现key确实不一样,那么再在这个位置上挂链表或红黑树结构。

value被存在数组上或者挂在链表上。那key放在哪了?
答:和value一起封装在Node对象中。所以找到value就找到key了。

不太懂HashMap数据结构基本原理?
见HashMap源码分析

equals和hashCode对HashMap的影响

根据前面的数据结构可知:hashcode虽然决定了在数组上散列几个位置。但并不会因此让数据“消失”。数据只是被挂在了链表上。
做一个极端的例子感受一下: 把hashCode固定为一个常数。

@Override
public int hashCode() {
    return 1;
}

demo2输出:

------over-----size:3

小结

hashCode和equals在HashMap中就像两个调节开关,可以调的很严格(最严格的就是直接比较对象的存储地址,以及对象的默认hashCode)。
HashMap/HashSet能存多少对象,取决于这两个开关中最严格的那个(木桶原理的反向应用)。所以在demo中,当我们不重写hashCode。就相当于hashCode开关处于最严格的状态。两个对象稍有不一致,就会被散列到数组的不同位置,根本轮不到equals来判断。
但hashCode调节的也不能太松,比如上面的极端情况,直接把值固定了。那么此时HashMap就退化成链表或者红黑树了(数组永远只用一个位置,所有的值都挂在这个位置后面)。此时虽然不影响存储数据(由equals兜底判重)。但效率会降低(链表构成的树结构效率再高也超不过数组散列后直接寻址)。

前面我们都是在调节hashCode(最松:固定值。 最严:不写,使用默认值)。
如果根据我们总结的原理,把这两个开关都调到最松:把hashcode固定值,并且把equals重写为“通通一致”。会有什么后果呢?
答:HashMap/HashSet只能存一个对象,后一个会不断覆盖前一个。

结论

  • HashSet依赖HashMap的机制实现数据去重功能
  • HashCode和equals共同为HashMap提供对象判重标准,以最严格的那个为准
  • hashCode除了判重,更重要的功能是为了通过散列,提高存取效率
  • 当使用HashSet来实现去重的功能的时候,除了重写equals,别忘了重写hashCode,因为hashCode也有一部分数据去重的作用。如果不重写,将会按默认最严格执行。如果忽略了它的这部分功能,将会让程序产生bug(无法正确去重)。
  • 生产中,当我定义一个Entity,并不知道(自己或别人)以后会拿这个对象干什么(也许这个对象以后就用于存在Set中去重了)。所以最好的办法就是按照那句谏言:重写equals的时候一定要重写hashCode。让equals和hashCode的判重标准一致

准确的讲,判重严格程度:hashCode < equals = 业务要求
hashCode比equals松,会产生一点性能降低。但如果hashCode比equals严格,就会产生bug。
理论上,hashCode 不可能等于 equals,hash函数设计的再好,也不可能百分百避免撞车(没有理论依据,我猜的)

其他

我们知道HashMap的数据结构是:数组上挂链表(如果有hashCode冲突才会挂链表或红黑树)。那么HashMap如何控制把对象散列在数组范围内的呢?(超出就会导致IndexOutOfBoundsException)
关键还在HashMap的put方法里:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node[] tab; Node 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 e; K k;
            ...

关键点:(n - 1) & hash
n:HashMap的数组长度
hash:对象的hashCode

以初始长度16为例,n-1就是15,二进制就是0000 1111
经过&运算之后,结果一定小于16
由于每次扩容都是2的倍数

...
//HashMap的扩容代码
newThr = oldThr << 1; // 位运算:左移一位,就是乘以2的意思
...

所以n-1永远都是000..00111...11这样的“二进制工具数”,刚好可以把&运算结果控制在数组n范围内,也就是不会有数组越界的问题了。

参考

HashMap源码分析

你可能感兴趣的:(java,java)