为什么重写equals后要重写hashCode

时间:2024-12-13 15:05:37

equals和hashCode的关系

要搞清楚题目中的问题就必须搞明白equals方法和hashCode方法分别是什么,和诞生的原因,当搞明白了这一点其实题目就不算是个问题了,下面我们来探讨分别探讨一下两者代表的意义。

hashCode

笔者看到很多地方都对hashCode有两个误解

  • 对象默认的hashCode是对象的地址。
  • 默认的equals会先比较对象的hashCode,如果hashCode相同则代表两个对象是同一个对象。

在这里笔者先给出这两个问题的结论,后面会给出证明。

  1. hashCode并不是对象的地址。
  2. 默认的equals比较的是对象的地址,与hashCode无关。

事实上想求证hashCode是不是对象的地址这件事情说容易也容易,说难也难。其实笔者在网上有很多不知出处与权威性的文章都写hashCode就是对象的地址,从这点上来说,想找到真实答案也挺不容易的,“谎言重复千遍便是真理”说的大概就是这个意思。之所以说容易是因为只要通过阅读Oracle的JavaAPI注释便可知道正确答案,所以其实学习一个东西最好的办法还是看官方的文档。但因为Oracle的API是英文的,对母语不是英文的我们来说或许会有些痛苦,即时你能看懂英文文档,为了容易我们也可能选择找中文的文章来看,不幸的是大多数软件的文档没有中文的。

OracleAPI中对hashCode()的注释如下:

  1. Returns a hash code value for the object. This method is supported for the benefit of hash tables such as those provided by HashMap.
  2. As much as is reasonably practical, the hashCode method defined by class Object does return distinct integers for distinct objects. (This is typically implemented by converting the internal address of the object into an integer, but this implementation technique is not required by the JavaTM programming language.)

第2句话的意思是说,不同的Object对象的返回不同的hashCode,这通常通过将对象地址进行某种转换映射为一个integer,但并不限制具体的实现方法。换句话说,hashCode的生成策略是由jdk的实现决定的。这已经能够说明hashCode并不等于对象的物理地址,虽然实现方式与其有关,但绝不意味着相等。其实通过下面的代码我们也可以从某种程度上推测证明两者并不严格相等。

public class B {
public static void main(String[] args) {
B b1 = new B();
B b2 = new B();
System.out.println(b1.hashCode());
System.out.println(b2.hashCode());
}
}
> 356573597
> 1735600054

这个代码非常简单,从一开始启动虚拟机到b1和b2的内存分配之间并没有任何其他的过多干扰,换句话说,堆内存的空闲是很多的,并不存在内存分配中的指针碰撞或者需要维护不连续的内存空闲列表,因此b1和b2的内存分配是相当连续的。如果hashCode代表着内存地址,那么两者应该相差不大,但事实上两者看不出任何内存分布上的联系。

在来解释第一句话,这句话的意思是说一些基于hash的数据结构如HashMap等会受益于此方法,这就可以做出推测,hashCode的出现是为一些基于hash的数据结构服务的。后面我们会分析HashMap是如何根据hashCode去提升性能的,这里必须提到JVM的一个细节:java对象在内存分配之后,hashCode存在于对象头中,但这个值并不是内存分配完成之后就有的,当第一次调用对象的hashCode方法,对象的hashCode值就会存放在对象头中。

至此,关于hashCode的第一个误解已经解决了,下面我们证明第二个,来看下面的代码。

public class B {
@Override
public int hashCode() {
return 1;
}
public static void main(String[] args) {
B b1 = new B();
B b2 = new B();
System.out.println(b1.equals(b2));
System.out.println(b1 == b2);
}
}
> false
> false

如上所示,b1和b2拥有相同的hashCode,但是不管是equals还是==比较,都返回了false,这至少证明了Object的equals方法与hashCode并无任何关联,查看Object的equals方法源码便知。

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

equals

equals比hashCode好理解的多,它的设计初衷是为了让编程人员自己定义两个对象是否相等,这与地址无关。因为对于java虚拟机来讲,只有两个引用指向同一个对象,两个对象才能看作是相等的。当然,这个原因也不是笔者凭空猜测的,OracleAPI中有这两句话如下:

public boolean equals(Object obj)

Indicates whether some other object is "equal to" this one.

The equals method for class Object implements the most discriminating possible equivalence relation on objects; that is, for any non-null reference values x and y, this method returns true if and only if x and y refer to the same object (x == y has the value true).

但其实下面还有一句话如下:

Note that 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.

这句话告诉我们当一个对象的hashCode方法被重写的时候,为了保持hashCode的常规协定,建议重写hashCode方法,这里所指的hashCode常规协定如下:

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.

这条contract告诉我们,如果两个对象equals,则他们要有相同的hashCode,这并不是必须满足的条件,事实上我们很可能经常不遵守这个协定,比如下面的代码:

public class B {
@Override
public boolean equals(Object obj) {
return true;
}
public static void main(String[] args) {
B b1 = new B();
B b2 = new B();
System.out.println(b1.equals(b2));
System.out.println(b1.hashCode() == b2.hashCode());
}
}

既然这个协定不是必须要遵守的,为什么Java建议我们如果重写了equals方法就要重写hashCode方法,还告诉我们如果两个对象equals要有相同的hashCode呢?

equals & hashCode & HashSet

前文提到,Java中的hashCode主要是为了一些使用hash的数据结构而存在的。这里以HashSet举例,Set中是不允许对象有重复的,这里的重复就是相等的元素,注意:这里要分是两个元素是物理地址上的相等,还是通过equals比较的相等。从逻辑上来说,如果两个元素被用户定义了的equals方法比较的结果为true,那么不管两个对象hashCode值是否相等,它们能应该被定义为“重复”,但事实上如果不重写hashCode,两个equals的对象输出的hashCode不同,它仍然被当作不同的元素被HashSet, HashMap等一系列hash的数据结构对待。

public class B {
@Override
public boolean equals(Object obj) {
return true;
}
public static void main(String[] args) {
B b1 = new B();
B b2 = new B();
HashSet<Object> set = new HashSet();
set.add(b1);
set.add(b2);
System.out.println(set.size());
}
}
> 2

以上代码set中的元素为两个,尽管b1.equals(b2) = true这在逻辑上就与元素存放不同对象相违背了(这里以HashSet举例,实际上任何类似的使用hash的数据结构都可以如此推导),因此Java告诉我们,如果重写了equals方法,请务必重写hashCode方法,使得两个equals的对象拥有相同的hashCode,可以被hash的集合类当作相同的元素看待。

我们顺便来看一下set.add(E e)方法的内容:

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

这里调用了HashMap的put方法(HashSet就是用HashMap实现的),我们继续跟进去:

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;
// 先比较hash, 在比较地址,最后调用equals
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 {
....
}
if (e != null) { // existing mapping for key
....
}
}
...
}

需要说明的是这里的hash并不是对象的hashCode,而是通过下面的方式处理后的结果

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

但由于相同的输入有相同的输出,这里姑且把hash当作hashCode处理,当hashSet调用add方法时,会判断HashMap中hash对应的bucket中是否有元素,如果有,判断两个元素的hash值是否相同,如果不同,HashMap会直接当作不同(逻辑上的)的元素处理,如果相同,还会比较equals和地址是否相同来判定该对象是否真的相同。

逻辑听起来好像有点绕,简单来说就是HashMap认为,如果两个对象hashCode不同,那这两个对象就不相等,如果hashCode相同,则根据地址和equals判定。

综上所述,正式因为这些基于hash的数据结构,才使得我们在重写equals时要重写hashCode,否则在这些集合类中关于两个对象是否相等的判定会在语义上变得不严谨,除此之外,equals和hashCode再无任何关联。