前几天在回顾Java基础知识的时候注意到了equals和hashCode这两个方法,仔细研究了一下瞬间就被为什么equals相等hashCode一定相等,为什么hashCode相等equals不一定相等,重写equals到底要不要一起重写hashCode等问题给整懵了,在仔细分析并通过代码实践以后终于彻底搞明白了,如果有什么错误和不足欢迎指正。
文章目录
一、首先先来看一下原生的equals和hashCode方法。
1.1、equals
Object中的equals方法和“==”是相同的,如下代码,比较的都是内存地址。
public boolean equals(Object obj) {
return (this == obj);
}
1.2、hashCode
原生的hashCode方法返回的是一个根据内存地址换算出来的一个值。它的定义是这样的:
public native int hashCode();
可见这是一个native方法,因为native方法是并不是由Java语言来实现的,所以这个方法的定义中也没有具体的实现。根据jdk文档,该方法的实现一般是**“通过将该对象的内部地址转换成一个整数来实现的”**,这个返回值就作为该对象的哈希码值返回。
1.3、总结
所以,在不重写equals和hashCode的情况下:
(1)两个对象如果equals相等的情况下,hashCode一定相等。因为equals默认是用“==”来比较,比较的是内存地址,而hashCode是根据内存地址得到哈希值,内存地址一样的话,得到的哈希值肯定是一样的。
(2)两个对象hashCode相等的情况下,equals不一定相等。这是为什么呢,首先我们来说一下哈希表,哈希表结合了直接寻址和链式寻址两个方式,简单来说就是先计算出要插入的数据的哈希值,然后插入到相应的分组当中去,因为哈希函数返回的是一个int类型,所以最多也就只有2的32次方个分组,对象多了,总有分组不够用的时候,这个时候,不同的对象就会产生相同的哈希值,也就是哈希冲突现象,此时就可以通过链地址法把分组用链表来代替,同一个链表上的对象hashCode肯定是相等的,因为是不同的对象,所以内存地址不同,所以他们的equals肯定是不相等的。这的hashCode就相当于是人名,equals就相当于身份证号,同名的人多了去了,但都不是同一个人。
二、重写equals和hashCode的情况
2.1、都不重写
import java.util.*;
public class Test {
public static void main(String[] args) {
Person p1 = new Person();
p1.name = "张三";
Person p2 = new Person();
p2.name = "李四";
Person p3 = new Person();
p3.name = "张三";
Set set = new HashSet();
set.add(p1);
set.add(p2);
set.add(p3);
for (Iterator iter=set.iterator(); iter.hasNext();) {
Person p = (Person)iter.next();
System.out.println("name=" + p.name );
}
System.out.println("p1.hashCode=" + p1.hashCode());
System.out.println("p2.hashCode=" + p2.hashCode());
System.out.println("p3.hashCode=" + p3.hashCode());
System.out.println();
System.out.println("p1 equals p2," + p1.equals(p2));
System.out.println("p1 equals p3," + p1.equals(p3));
}
}
class Person {
String name;
}
output:
equals不同 hashCode不同
可以看到,在都不重写的情况下,插入了重复的数据。原因是不重写的情况下默认根据内存地址生成的哈希值来进行比较,内存地址不同就生成了不同的哈希值(不考虑哈希冲突),所以就插入了重复的数据。
2.2、只重写equals
import java.util.*;
public class Test {
public static void main(String[] args) {
Person p1 = new Person();
p1.name = "张三";
Person p2 = new Person();
p2.name = "李四";
Person p3 = new Person();
p3.name = "张三";
Set set = new HashSet();
set.add(p1);
set.add(p2);
set.add(p3);
for (Iterator iter=set.iterator(); iter.hasNext();) {
Person p = (Person)iter.next();
System.out.println("name=" + p.name );
}
System.out.println("p1.hashCode=" + p1.hashCode());
System.out.println("p2.hashCode=" + p2.hashCode());
System.out.println("p3.hashCode=" + p3.hashCode());
System.out.println();
System.out.println("p1 equals p2," + p1.equals(p2));
System.out.println("p1 equals p3," + p1.equals(p3));
}
}
class Person {
String name;
//覆盖 equals
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj instanceof Person) {
Person p = (Person)obj;
return this.name.equals(p.name);
}
return false;
}
}
output:
equals相同 hashCode不同
以上代码在重写equals以后虽然能够比较出对象的相同但是仍然插入了重复数据,是因为两个对象的hashCode不同所造成的,所以为了避免插入重复数据必须重写hashCode。
2.3、只重写hashCode
import java.util.*;
public class Test {
public static void main(String[] args) {
Person p1 = new Person();
p1.name = "张三";
Person p2 = new Person();
p2.name = "李四";
Person p3 = new Person();
p3.name = "张三";
Set set = new HashSet();
set.add(p1);
set.add(p2);
set.add(p3);
for (Iterator iter=set.iterator(); iter.hasNext();) {
Person p = (Person)iter.next();
System.out.println("name=" + p.name );
}
System.out.println("p1.hashCode=" + p1.hashCode());
System.out.println("p2.hashCode=" + p2.hashCode());
System.out.println("p3.hashCode=" + p3.hashCode());
System.out.println();
System.out.println("p1 equals p2," + p1.equals(p2));
System.out.println("p1 equals p3," + p1.equals(p3));
}
}
class Person {
String name;
//覆盖 hashCode
public int hashCode() {
return (name==null) ? 0:name.hashCode();
}
}
output:
equals不同 hashCode相同
可见以上代码还是插入了重复的数据,是因为在插入数据时,首先会比较hashCode,如果相同,再比较equals,只有equals也相同的时候,才会认为重复,因为equals不同,所以不认为是相同对象,再一次没有避免重复插入。
2.4、同时重写equals和hashCode
import java.util.*;
public class Test {
public static void main(String[] args) {
Person p1 = new Person();
p1.name = "张三";
Person p2 = new Person();
p2.name = "李四";
Person p3 = new Person();
p3.name = "张三";
Set set = new HashSet();
set.add(p1);
set.add(p2);
set.add(p3);
for (Iterator iter=set.iterator(); iter.hasNext();) {
Person p = (Person)iter.next();
System.out.println("name=" + p.name );
}
System.out.println("p1.hashCode=" + p1.hashCode());
System.out.println("p2.hashCode=" + p2.hashCode());
System.out.println("p3.hashCode=" + p3.hashCode());
System.out.println();
System.out.println("p1 equals p2," + p1.equals(p2));
System.out.println("p1 equals p3," + p1.equals(p3));
}
}
class Person {
String name;
//覆盖 hashCode
public int hashCode() {
return (name==null) ? 0:name.hashCode();
}
//覆盖 equals
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj instanceof Person) {
Person p = (Person)obj;
return this.name.equals(p.name);
}
return false;
}
}
output:
equals不同 hashCode不同
大功告成。只插入了一个张三。
2.5、总结
如果equals方法和hashCode方法同时被重写,则需满足hashCode 的常规协定:
(1)在 Java 应用程序执行期间,在对同一对象多次调用 hashCode 方法时,必须一致地返回相同的整数,前提是将对象进行 equals 比较时所用的信息没有被修改。从某一应用程序的一次执行到同一应用程序的另一次执行,该整数无需保持一致。
(2)如果根据 equals(Object) 方法,两个对象是相等的,那么对这两个对象中的每个对象调用 hashCode 方法都必须生成相同的整数结果。
(3)如果根据 equals(java.lang.Object) 方法,两个对象不相等,那么对这两个对象中的任一对象上调用 hashCode 方法不要求一定生成不同的整数结果。但是,程序员应该意识到,为不相等的对象生成不同整数结果可以提高哈希表的性能。
所以,在重写方法的时候,有以下结论:
两个对象equals相等,则它们的hashcode必须相等,反之则不一定。
(4)重写equals一定要重写hashCode吗
答案是不一定的,如果你仅仅是为了比较两个对象是否相等只重写equals就可以,但是如果你使用了hashSet、hashMap等容器,为了避免加入重复元素,就一定要同时重写两个方法。
在学习的过程中,特别是学习集合的时候,equals
和hashCode
一直是经常出现的方法,而且在面试题中,也经常出现equals和==的区别等问题,现在我们就从底层详细的了解一下equals
和hashCode
这两个方法。
1、概述
首先我们要知道 equals
和 hashCode
两个方法属于 Object 基类的方法:
public boolean equals(Object obj) {
return (this == obj);
}
public native int hashCode();
从源码中我们可以看出 equals
方法默认比较的是两个对象的引用是否指向同一个内存地址。而 hashCode
是一个native 本地方法(所谓的本地方法就是指不是用Java语言编写的,而是使用其他语言编写的程序,比如C/C++,一般是为了更快的与机器进行交互),其实默认的 hashCode
方法返回的就是对象对应的内存地址(注意是默认
)。这一点我们通过 toString
方法也可以间接了解,我们都知道 toString 返回的是「类名@十六进制内存地址」,由源码可以看出内存地址与 hashCode()
返回值相同。
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
面试题目:
hashCode
方法返回的是对象的内存地址么? 答: Object 基类的hashCode
方法默认返回对象的内存地址,但是在一些场景下我们需要重写hashCode
函数,比如需要使用Map
来存放对象的时候,重写后hashCode
就不是对象的内存地址了。
2、equals 详解
equals
方法是基类 Object
的方法,所以我们创建的所有的对象都拥有这个方法,并有权利去重写这个方法。例如 :
String str1 = "abc";
String str2 = "abc";
str1.equals(str2);
//结果为:true
显然 String
类一定重写了 equals
方法,否则两个 String
对象内存地址肯定不同。我们看下 String
类的 equals
方法:
public boolean equals(Object anObject) {
//首先判断两个对象的内存地址(引用)是否相同
if (this == anObject) {
return true;
}
// 判断两个对象是否属于同一类型。
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
//长度相同的情况下逐一比较 char 数组中的每个元素是否相同
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;
}
从源码中我们也可以看出, equals
方法已经不单单是调用 this==obj
来判断对象是否相同了。事实上所有 Java 现有的引用数据类型都重写了该方法。当我们自己定义引用数据类型的时候我们应该依照什么原则去判定两个对象是否相同,这就需要我们自己来根据业务需求来把握。但是我们都需要遵循以下规则:
- 自反性(reflexive)。对于任意不为 null 的引用值 x,
x.equals(x)
一定是 true。 - 对称性(symmetric)。对于任意不为 null 的引用值 x 和 y ,当且仅当
x.equals(y)
是 true 时,y.equals(x)
也是true。 - 传递性(transitive)。对于任意不为 null 的引用值x、y和z,如果
x.equals(y)
是 true,同时y.equals(z)
是 true,那么x.equals(z)
一定是 true。 - 一致性(consistent)。对于任意不为null的引用值x和y,如果用于equals比较的对象信息没有被修改的话,多次调用时
x.equals(y)
要么一致地返回 true 要么一致地返回 false。 - 对于任意不为 null 的引用值 x,
x.equals(null)
返回 false。
2.1 equals 和 ==
equals经常被拿来与==相区别。
我们都知道 Java 数据类型可分为 基本数据类型 和 引用数据类型。基本数据类型包括 byte, short, int , long , float , double , boolean ,char
八种。对于基本数据类型来说, == 就是比较的他们的值。
而对于引用类型来说, == 比较的就是它们所指向对象的内存地址。
int a = 10;
int b = 10;
float c = 10.0f;
System.out.println("(a == b) = " + (a == b));//true
System.out.println("(b == c) = " + (b == c));//true
String s1 = "123";
String s2 = "123";
System.out.println(s1==s2);//true
equals 与 == 操作符的区别总结如下:
- 若 == 两侧都是基本数据类型,则判断的是左右两边操作数据的值是否相等
- 若 == 两侧都是引用数据类型,则判断的是左右两边操作数的内存地址是否相同。若此时返回 true , 则该操作符作用的一定是同一个对象。
- Object 基类的 equals 默认比较两个对象的内存地址,在构建的对象没有重写 equals 方法的时候,与 == 操作符比较的结果相同。
- equals 用于比较引用数据类型是否相等。在满足equals 判断规则的前体系,两个对象只要规定的属性相同我们就认为两个对象是相同的。
来一道经典的面试题:
String s1 = "abc";
String s2 = "abc";
System.out.println(s1==s2);//true
System.out.println(s1.equals(s2));//true
String s3 = new String("100");
String s4 = new String("100");
System.out.println(s3==s4);//false
System.out.println(s3.equals(s4));//true
3、hashCode 方法
hashCode
方法并没有 equals
方法使用的那么频繁,说hashCode 方法就不得不结合 Java 的 Map 容器,类似于 HashMap
这种使用了哈希算法的容器会根据对象的hashCode
返回值来初步确定对象在容器中的位置,然后内部再根据一定的 hash 算法来实现元素的存取。
3.1 hash 算法简介
hash 算法,又被成为散列算法,基本上,哈希算法就是将对象本身的键值,通过特定的数学函数运算或者使用其他方法,转化成相应的数据存储地址的。而哈希算法所使用的数学函数就被称为 『哈希函数』,又可以称之为散列函数。
我们通过一个例子来说明:
如果我们要在存放了元素{0,3,6,10,48,5}
的数组中找到数值等于 10 的值的索引,我们就需要遍历一遍数组才能拿到对应的索引。这样在数组非常大的时候,遍历数组是比较低效率的,这样会很影响程序执行的效率。
如果我们能在数组存放的时候就按一定的规则放入元素,在我们想找某个元素的时候在根据之前定好的规则,就可以很快的得到我们想要的结果了。换句话说之前我们在数组中存放元素的顺序可能是依照添加顺序进行的,但是如果我们是按照一种既定的数学函数运算得到要放入元素的值和数组下标的映射关系的话。那么我们在想取某个值的元素的时候就可以使用映射关系,快速的找到对应的元素。
在常见的 hash 函数中有一种最简单的方法叫「除留余数法」,操作方法就是将要存入数据除以某个常数后,使用余数作为索引值。 下面看个例子:
将 323 ,458 ,25 ,340 ,28 ,969, 77 使用「除留余数法」存储在长度为11的数组中。我们假设上边说的某个常数即为数组长度11。 每个数除以11以后存放的位置如下图所示:
试想一下我们现在想要拿到 77 在数组中的位置,是不是只需要 arr[77%11] = 77
就可以了。
但是上述简单的 hash 算法,缺点也是很明显的,比如 77 和 88 对 11 取余数得到的值都是 0,但是下标为 0 位置上已经存放了 77 这个数据,那88就不知道该去哪里了。上述现象在哈希法中有个名词叫碰撞:
碰撞:若两个不同的数据经过相同哈希函数运算后,得到相同的结果,那么这种现象就做碰撞。
于是在设计 hash 函数的时候我们就要尽可能做到:
- 降低碰撞的可能性
- 尽量将要存入的元素经过 hash 函数运算后的结果,尽量能够均匀的分布在指定的容器(我们在称之为桶)。
不过,碰撞始终是避免不了的,所以在用到hashCode的地方,都需要再通过其他的方法解决碰撞问题。
3.2 hashCode 方法 与 hash 算法的关系
Java 中拥有 hashCode 方法的类就包含了 hash 算法,比如我们可以看一下 String 提供给我们的 hashCode 算法:
public int hashCode() {
int h = hash;//默认是0
if (h == 0 && value.length > 0) {
char val[] = value;
// 字符串转化的 char 数组中每一个元素都参与运算
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
前文说了 hashCode 方法与 java 中使用散列表的集合类息息相关,我们拿 Set 来举例,我们都知道 Set 中是不允许存放重复的元素的。那么我们凭借什么来判断已有的 Set 集合中是否有要存入的元素重复的元素呢?有人可能会说我们可以通过 equals 来判断两个元素是否相同。那么问题又来,如果 Set 中已经有 10000个元素了,那么之后在存入一个元素岂不是要调用 10000 次 equals 方法。显然这不行的,效率太低。那要怎么办才能保证即高效又不重复呢?答案就在于 hashCode 这个函数。
经过之前的分析我们知道 hash 算法是使用特定的运算来得到数据的存储位置的,那么 hashCode 方法就充当了这个特定的函数运算。这里我们可以简单认为调用 hashCode 方法后得到数值就是元素的存储位置(其实集合内部还做了进一步的运算,以保证尽可能的均匀分布,并且不同的类中可能使用的hash算法不同)。
当 Set 需要存放一个元素的时候,首先会调用 hashCode 方法去查看对应的地址上有没有存放元素,如果没有则表示 Set 中肯定没有相同的元素,直接存放在对应位置就好,但是如果 hashCode 的结果相同,即发生了碰撞,那么我们在进一步调用该位置元素的 equals 方法与要存放的元素进行比较,如果相同就不存了,如果不相同就需要进一步散列其它的地址。这样我们就可以尽可能高效的保证了无重复元素的方法。
面试题: hashCode 方法的作用和意义 答: 在 Java 中 hashCode 的存在主要是用于提高容器查找和存储的快捷性,如 HashSet, Hashtable,HashMap 等,hashCode是用来在散列存储结构中确定对象的存储地址的。
3.3 hashCode 和 equals 方法的关系
Object 类对于 equals 方法的注释上有这么一条:
请注意,当这个方法被重写时,通常需要覆盖{@code hashCode}方法,以便维护{@code hashCode}方法的一般契约,该方法声明相等对象必须具有相等的哈希码.
可以看到如果我们出于某种原因重写了 equals 方法,那么我们需要按照约定去重写 hashCode 方法,并且使用 equals 比较相同的对象,必须拥有相等的哈希码。
Object 对于 hashCode 方法也有几条要求:
- 在 Java 应用程序执行期间,在对同一对象多次调用 hashCode 方法时,必须一致地返回相同的整数,前提是将对象进行 equals 比较时所用的信息没有被修改。从某一应用程序的一次执行到同一应用程序的另一次执行,该整数无需保持一致。
- 如果根据 equals(Object) 方法,两个对象是相等的,那么对这两个对象中的每个对象调用 hashCode 方法都必须生成相同的整数结果。
- 如果根据 equals(java.lang.Object) 方法,两个对象不相等,那么对这两个对象中的任一对象上调用 hashCode 方法 不要求 一定生成不同的整数结果。但是,程序员应该意识到,为不相等的对象生成不同整数结果可以提高哈希表的性能。
结合 equals 方法,我们可以做出如下总结:
- 调用 equals 返回 true 的两个对象必须具有相等的哈希码。
- 如果两个对象的 hashCode 返回值相同,调用它们 equals 方法不一定返回 true 。
我们先来看下第一个结论:调用 equals 返回 true 的两个对象必须具有相等的哈希码。为什么这么要求呢?比如我们还拿 Set 集合举例,Set 首先会调用对象的 hashCode 方法寻找对象的存储位置,那么如果两个相同的对象调用 hashCode 方法得到的结果不同,那么造成的后果就是 Set 中存储了相同的元素,而这样的结果肯定是不对的。所以就要求 调用 equals 返回 true 的两个对象必须具有相等的哈希码。
那么第二条为什么 hashCode
返回值相同,两个对象却不一定相同呢?这是因为,目前没有完美的 hash 算法能够完全的避免 「哈希碰撞」,既然碰撞是无法完全避免的所以两个不相同的对象总有可能得到相同的哈希值。所以我们只能尽可能的保证不同的对象的 hashCode
不相同。事实上,对于 HashMap
在存储键值对的时候,就会发生这样的情况,在 JDK 1.7 之前,HashMap
对键的哈希值碰撞的处理方式,就是使用所谓的‘拉链法’。 具体实现会在之后分析 HashMap
的时候说到。