Java基础(四)—— HashCode和Equals

本文深入探讨了hashCode与equals方法的重要性及其实现细节,包括如何遵守JDK规范避免潜在错误,以及这些方法在集合类如HashSet中的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

正如标题所言,今天我们来讲讲hashCode和equals。或许有些人会奇怪了,这两个东西为什么要放在一起来讲呢?这是因为按照JDK规范:

如果两个对象根据equals方法比较是相等的,那么调用这两个对象中的hashCode方法都必须产生相同的整数结果。

所以为了遵守这个约定,就必须在重写equals时同样重写hashCode方法。如果不这样的话,就会违反该约定。

违反约定的后果

如果违反了这个约定,会出现什么后果呢?我们来一起探讨一下。最简单的一个分析,如果这种违反了约定的对象插入到HashSet中会怎么样呢?

首先,我们应该知道,HashSet的底层实现是使用的HashMap。代码如下:

public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable
{
    static final long serialVersionUID = -5024744406713321676L;

    private transient HashMap<E,Object> map;

 	private static final Object PRESENT = new Object();

    public HashSet() {
        map = new HashMap<>();
    }

    public HashSet(Collection<? extends E> c) {
        map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
        addAll(c);
    }
    
    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }
    
    public boolean remove(Object o) {
        return map.remove(o)==PRESENT;
    }

    // 略
}

从源码中可以看出,在构造HashSet时会初始化内部的map对象,然后addremove等对set的操作其实就是对内部map的操作。add的对象会作为map的key,PRESENT这个Object的对象会作为map的value,这两者作为一个键值对put到map中。

那么问题来了,HashMap的put流程是怎么样的呢?这里我先简要说下:主要是根据key的hash值算到对应的槽,如果对应槽位有值,则比较槽位值的key与插入key是否相等(hashCode,==,equals都为true),如果为true的则槽位的值会被覆盖,否则遍历判断该槽位下的链表,如果都不相等则链表链接新值。主要流程图如下:

到这里其实我们就能了解到几点:

  1. HashSet去重用的是HashMap的key如果相等会覆盖value的特性,而相等首先是hash之后会进入同一个槽,然后再通过hashCode和equals等判断是否为true,这才保证是相等的。

  2. 如果HashSet的add的对象equals为true,但是hashCode不是相等的值,那么就可能会出现add第二个值时,导致第二个对象也被HashMap存储,以至于HashSet的去重特性被打破。

可以使用以下代码验证:

@AllArgsConstructor
public class HashEqualsTest {

    private String name;

    private Integer age;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof HashEqualsTest)) return false;
        HashEqualsTest that = (HashEqualsTest) o;
        return name.equals(that.name) && age.equals(that.age);
    }

    public static void main(String[] args) {

        HashEqualsTest a = new HashEqualsTest("aischen", 3);
        HashEqualsTest b = new HashEqualsTest("aischen", 3);
        System.out.println(a.equals(b));

        Set<HashEqualsTest> set = new HashSet<>();
        set.add(a);
        set.add(b);
        System.out.println(set);
    }
}

输出结果:

true

[org.aischen.HashEqualsTest@5b2133b1, org.aischen.HashEqualsTest@77459877]

可以看到,结果确实如我们所料,因为违反了hashCode和equals的约定,所以HashSet可以插入多个相互之间equals为true的对象,那这种对象的到底算不算重复对象,就见仁见智了。就实际业务上来说,这种对象是算重复对象的,毕竟相同名字,相同身份证号的两条数据,不能就算是两个人吧。

hashCode一些特性

我们已经知道hashCode一般是用于散列寻址和前置判断使用,那么hashCode可以随意生成吗?怎么生成比较好呢?

还是以HashMap举例,如果我们的hashCode生成算法不够优雅,生成的hashCode值碰撞概率高,以极端情况来看,hash之后所有的元素全部在一个hash槽中,那就完全成了一个链表或者红黑树了。所有的查询都得基于链表和红黑树来查。而纯以计算来说,逻辑越多,计算越多,那效率必然就越低,所以经过了那么多前置的计算和判断之后还是用链表的数据结构,那相对于单纯使用链表效率必然是比不上的。

我们来看这样一段代码:

public static int hashCode(Object a[]) {
    if (a == null)
        return 0;

    int result = 1;

    for (Object element : a)
        result = 31 * result + (element == null ? 0 : element.hashCode());

    return result;
}

这其实是JDK中的Objects.hashCode方法,具体逻辑就不讲了,很直观。

首先说明下为什么需要使用乘法。有乘法的话,就使得散列值依赖于传入参数的顺序,如果一个类包含了多个相似的域,这样的乘法运算就会产生一个更好区分的散列值。

其次为什么要选择31这样的数。引用自Effective Java:

之所以选择31,是因为它是一个奇素数。如果乘法是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。使用素数的好处并不是很明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,即使用移位和减法来代替乘法,可以得到更好的性能:31×i==(i<<5) - 1。现代的虚拟机可以自动完成这种优化。

如此,我们知道,选择31的原因主要还是性能。不过实际上我们使用时,也只需要调用Objects.hashCode的方法即可,无需再重复造轮子。

平常对象或者String的hashCode都是一些比较长的数字。但是Integer或者Short等这种整型的类,他们的hashCode就等于他们的value,这点在实际使用时可以注意下。

equals一些特性

我们已经知道,equals是用来比较对象是否相等的一个函数,如果没有重写的话,那默认是使用"=="。而我们常用的一些类其实JDK的开发人员已经帮我们重写过了,比如String,比如Integer。

在重写equals方法时,必须要遵守它的通用约定。下面是约定的内容,来自Object的规范:

  1. 自反性:对于任何非null的引用值x,x.equals(x)必须返回true。

  2. 对称性:对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。

  3. 传递性:对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。

  4. 一致性:对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致的返回true或者一致的返回false。

  5. 对于任何非null的引用值x,x.equals(null)时必须返回false。

约定看起来很多,也好像很复杂的样子,但是却是我们必须遵守的一些约定,否则就可能会导致系统出现非常严重的后果,甚至崩溃,而且你很难找到根源。John Donne说过:没有哪个类是孤立的,一个类的实例通常会被频繁的传递给另一个类的实例。

不过这些约定虽然看起来比较复杂,但实际上并非如此,一旦理解了,遵守它们也并不困难。

自反性

很难想象会怎么无意识违反这一约定。假如违背这一条的话,那么把类加到集合实例中,再调用集合的contains方法时将返回false,告诉你该集合不包含刚刚添加的实例。

对称性

这个要求是说,任何两个对象对于"它们是否相等"的问题都必须保持一致。这种违反的情况其实不难想象。一般是用于equals不同的类导致的。比如有段代码如下:

public class IgnoreCaseString {

    private final String s;

    public IgnoreCaseString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o instanceof IgnoreCaseString)
            return s.equalsIgnoreCase(((IgnoreCaseString) o).s);
        if (o instanceof String)
            return s.equalsIgnoreCase((String) o);
        return false;
    }
}

这个意图很明显,是想能和普通的字符串进行互操作。 但是这样会明显的违反对称性:

    public static void main(String[] args) {
        String s = "aTad";
        IgnoreCaseString ics = new IgnoreCaseString("atad") ;
        System.out.println(ics.equals(s));
        System.out.println(s.equals(ics));
    }

结果不出所料:第一个返回true,第二个返回false

true

false

传递性

这点是要求我们在第一个对象等于第二个对象,第二个对象等于第三个对象时,第一个对象一定等于第三个对象。这个要求无意识违反的话也是不难想象的,比如类继承的的情况下,扩展了属性,那么需不需要将扩展属性也加入到比较中呢?

如果不加的话那么显然是不会违反equals约定的,但是新加的信息被比较时忽略掉也是无法接受的。那么就需要将扩展信息加入到比较中。此时就会出现对称性问题。

例如这两个类:

public class Point {

    private int x;

    private int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }
}

class Point3D extends Point {

    private int z;

    public Point3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point3D)) return o.equals(this);
        if (!super.equals(o)) return false;
        Point3D point3D = (Point3D) o;
        return z == point3D.z;
    }
}

这两个类的equals都是很常见的通过ide生成的。但是很明显它们违反了对称性。如果要解决该问题也很简单,将代码if (!(o instanceof Point3D)) return false;调整为if (!(o instanceof Point3D)) return o.equals(this); 即可。但是这样虽然可以保证对称性,但是确违反了传递性。

事实上,这种解法还有可能导致无限递归问题,假设Point有两个子类,且它们各自都带有一个equals方法,那么两个子类对象之间的equals将导致它们互相调用对方的equals方法,然后将抛出StackOverflowError

事实上,这种问题是无解的。我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定,除非愿意放弃面向对象的抽象所带来的优势。

一致性

这个要求是,如果两个对象相等,它们就必须始终保持相等,除非它们中有一个对象发生了变更。

无论类是否可变,都不要使equals依赖于不可靠的资源。如果违反了这一原则,要想满足一致性就十分困难了,例如JDK中的URL类,因为它的equals是依赖于IP地址的比较,而IP又是需要访问网络的,随着时间的推移,就不能确保会产生相同的结果。遗憾的是,因为兼容性要求,这一行为无法被改变。为了避免这种问题,equals方法应该对驻留在内存中的对象执行确定性的计算。

最后再留下几个告诫:

  1. 重写equals时需要重写hashCode

  2. 不要企图让equals方法过于智能。

  3. 不要将equals声明中的Object替换成别的类,否则就不是重写equals方法而是重载了。

写在最后

到此我们的分享就告一段落了,虽然篇幅不算短,但是总感觉还有很多东西其实是没有说清楚了,只是说的很粗略,很大概。可能也是因为我最近实在是太忙了,每天都要到凌晨到家,只有周天的这一点点时间可以挪出来写点东西,但是又因为太累需要补血,以至于都不能保证一整天的时间有输出。另外就是也忙得连书都没法看了,希望过了这段忙碌期能够好起来,上周又欠了一片文章,到现在算下来的话已经欠了两篇的。只能在后面找时间给补上了。

最后,希望大家能好好学习,天天向上~

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值