概述
本篇博客简单介绍一下 equals() 方法和 “==” 的区别,顺带解释一下为什么在重写 equals() 方法后还需要重写 hashCode() 方法。
equals() 和 ==
在 java 代码中,一般通过 == 判断两个对象的地址是否相等。如果比较的是常量数据,则比较他们的值是否相等。
equals() 是 Object 类方法,它的源码实际上也是直接调用的 == :
public boolean equals(Object obj) {
return (this == obj);
}
也就是说,当我们调用 equals() 比较两个对象是否相等时,实际上作用和 == 是相似的。
在实际的业务场景中,经常需要比较的不是对象地址,而是业务属性。因此一般重写 java 类的 equals() 方法来实现上述业务需求。下面我们看一个具体代码:
class Node {
private int num;
public Node(int num) {
this.num = num;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Node) {
return ((Node) obj).num == num;
}
return false;
}
}
@Test
public void test() {
Node node1 = new Node(1);
Node node2 = new Node(1);
System.out.println("使用 '==' 比较:" + (node1 == node2));
System.out.println("使用 equals() 比较:" + node1.equals(node2));
}
执行结果:
使用 '==' 比较:false
使用 equals() 比较:true
从执行结果可以看出:"==" 因为比较不同对象的地址返回 false,而 equals() 方法比较对象 num 属性值返回 true。
像上述这种重写 equals() 方法来比较对象属性的方式已经很常见了,我们经常使用的 String 类就通过重写 equals() 方法来比较对象具体的字符串值。
equals() 方法重写规则
在重写 equals() 方法时,一般我们需要遵守以下规则,否则将会带来意想不到的异常:
-
自反性:equals() 方法参数为对象本身时必须返回 true。
-
对称性:a.equals(b) 返回 true 时,b.equals(a) 也必须返回 true,相反也是。
-
传递性:a.equals(b) 返回 true,b.equals© 返回为 true时,a.equals© 也必须返回 true
-
连续性:在对象和方法参数没有做任何改变时,equals() 方法返回结果必须一致
上述四条准则有一个共同的前提:参数对象非 NULL,如果参数为 NULL,则必须返回false。值得一提的是,不存在 NULL 和 NULL 进行 equals() 方法判断是否相等的情况。
一般情况下,重写 equals() 方法为根据属性值判断后,上述四条准则都不会出问题。但如果对象本身和参数对象存在父子级关系时,如果没有做特殊处理,就很容易出现问题:
自反性一般不会出现问题,因为对象和自己本身肯定不存在父子级关系。下面我们先看一个关于对称性的反例:
class NodeSon extends Node {
private int record;
public NodeSon(int num, int record) {
super(num);
this.record = record;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof NodeSon) {
return super.equals(obj) && ((NodeSon) obj).record == record;
}
return false;
}
}
@Test
public void test2() {
Node node1 = new Node(1);
NodeSon node2 = new NodeSon(1, 1);
System.out.println(node1.equals(node2));
System.out.println(node2.equals(node1));
}
执行结果:
true
false
从输出结果我们可以看出,node1 对象调用 equals() 方法时返回为 ture,当我们反过来调用时,返回 false。
输出这样的结果其实也无可厚非,因为 NodeSon 类作为 Node 类的子类,它的对象可以向上转型,而 Node 类对象就不能向下转型为 NodeSon 类对象,因为它可能含有其他很多种子类。因此在通过关键字 instanceof 判断对象类型是否相等时就已经返回 false,实际还没有开始比较属性。
假如现在的业务要求对象 equals() 方法不再受父子类型转型的限制,它认为这两个类属于同根,只要求比较属性,也就是说要 equals() 方法在父子类间满足对称性。要解决该问题也比较简单,只需要将子类的 equals() 方法改为如下即可:
@Override
public boolean equals(Object obj) {
if (obj instanceof NodeSon) {
return super.equals(obj) && ((NodeSon) obj).record == record;
}
return super.equals(obj);
}
运行上述代码后,两者都返回 true。其实原理也很容易理解:在类型转换失败后,直接通过父类的方法进行判断,这样就不会因为对象类型转换出现对称性问题。
虽然上述处理方式可以满足对称性,但不能满足连续性:
@Test
public void test3() {
Node node1 = new Node(1);
NodeSon node2 = new NodeSon(1, 1);
NodeSon node3 = new NodeSon(1, 2);
System.out.println(node1.equals(node2));
System.out.println(node1.equals(node3));
System.out.println(node2.equals(node3));
}
执行结果:
true
true
false
上述代码中,通过 equals() 方法比较,node1 对象等于 node2 和 node3 对象。但 node2 和 node3 对象本身不相等,也就违背了 equals() 方法的连续性。
出现这种结果其实也无可厚非,因为 NodeSon 类中引入新的属性,而 node2 对象和 node3 对象的新属性值也不相同,因此返回 false 也属实正常。
总结一下,当出现以下两种场景时容易不满足 equal() 特性:
-
父类子类混合比较
-
子类引入新属性时,根据父类做桥接但新熟悉不相等时
对于上述连续性问题,我认为没有解决方案。当然你可以通过以下特殊处理让输出结果都为 false。这样从输出结果来看是满足连续性问题了, 但没有任何实际价值。
class NodeSonNoError {
Node node;
int count;
public NodeSonNoError(int num, int count) {
this.node = new Node(num);
this.count = count;
}
public boolean equals(Object obj) {
if (obj instanceof NodeSonNoError) {
NodeSonNoError temp = (NodeSonNoError) obj;
return node.equals(temp.node) && count == temp.count;
}
return false;
}
}
通过上述类运行会发现结果都是 false,也就实现了连续性和自反性。不过我觉得大可不必,这种类在实际业务场景中起不到任何作用。
深入理解 equals() 重写规则
有了上面的基础,我们再来看看如果 equals() 方法不遵守规则时,会带来哪些异常:
class Node {
@Override
public boolean equals(Object obj) {
return obj instanceof Node;
}
}
class NodeSon extends Node {
@Override
public boolean equals(Object obj) {
return obj instanceof NodeSon;
}
}
@Test
public void test() {
List<Node> list = new ArrayList<>();
Node node1 = new Node();
Node node2 = new NodeSon();
System.out.println("只添加对象 node2 时:");
list.add(node2);
System.out.println("集合是否包含node1:" + list.contains(node1));
System.out.println("集合是否包含node2:" + list.contains(node2));
list.clear();
System.out.println("只添加对象 node1 时:");
list.add(node1);
System.out.println("集合是否包含node1:" + list.contains(node1));
System.out.println("集合是否包含node2:" + list.contains(node2));
}
执行结果:
只添加对象 node2 时:
集合是否包含node1:true
集合是否包含node2:true
只添加对象 node1 时:
集合是否包含node1:true
集合是否包含node2:false
从输出结果可以看出,当只添加对象 node1 时,输出结果正常。如果只添加 node2 对象,那么集合会认为两个对象都被添加。这显然是有问题的,下面我们来看一下 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;
}
从源码可以看出,最终在集合中遍历通过 equals() 方法判断,如果 equals() 方法返回 true,就说明集合中存在当前元素。而根据前面的介绍,node2 对象是根据 Node 类子类创建的,而且我们重写之后的 equals() 方法直接根据对象类型判断,因此当集合添加 node2 对象时,会认为 node1 对象也被添加。而 node1 对象作为父类,不能向下转型,因此集合不会因为 node1 对象判断 node2 对象也在集合中。
造成上述结果的根本原因是由于我们在重写 equals() 方法时,没有保证对称性。实际上除了 List 之外,其他很多集合也会在 equals() 重写不满足上述特性出出现问题。
equals() 和 hashCode()
hashCode() 也是一个 Object 类方法,根据它获取对象的 hash 值。该方法经常被用在 Map 集合中,通过它快速定位对象处于哪个散列,方便后面操作。可以抽象的把 hashCode() 方法的作用理解为目录,通过目录就可以大概知道想要查询的内容属于哪一页,根据页数快速定位。
在正式介绍 equals() 和 hashCode() 方法前,我们先通过一个简答的案例来了解 hashCode() 方法:
@Test
public void test() {
String s1 = "test";
String s2 = "test";
StringBuffer stringBuffer1 = new StringBuffer(s1);
StringBuffer stringBuffer2 = new StringBuffer(s2);
System.out.println("s1 对象的 hash 值:" + s1.hashCode());
System.out.println("s2 对象的 hash 值:" + s2.hashCode());
System.out.println("stringBuffer1 对象的 hash 值:" + stringBuffer1.hashCode());
System.out.println("stringBuffer2 对象的 hash 值:" + stringBuffer2.hashCode());
}
执行结果:
s1 对象的 hash 值:3556498
s2 对象的 hash 值:3556498
stringBuffer1 对象的 hash 值:1688376486
stringBuffer2 对象的 hash 值:2114664380
从输出结果我们会发现 String 对象值相同时,它们的 hash 值也是相同的。而 StringBuffer 对象的 hash 值就相对比较无序。
导致这种结果的原因其实也比较简单,因为 String 对象重写了 hashCode() 方法。下面我们看一下重写之后的 String 类的 hashCode() 方法源码:
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;
}
上述代码中,hash 是 String 对象的 hash 值,value 是保存字符串的字符数组。从代码可以很清晰的看出,String 对象的 hash 值是根据字符串算出来的,因此当字符值相同时,计算出来的 hash 值也相同。
Object 类的 hashCode() 方法是 native 类型的,它的值根据对象的地址计算,也就是说如果这个 Object 对象所在内存地址没有发生变化,它的 hash 值也不会发生变化。
关于 hashCode() 方法的作用显而易见:尽可能让对象散开一点提高效率。
怎么理解这句话呢:假设现在存在 hashMap 集合,长度为10,现在我们要用它添加100个元素。如果我们的 hashCode() 方法计算出的结果尽可能均匀,这100个对象将会被平均分配到这10个槽点,后面无论是查询、修改、删除操作都只需要遍历十个左右的对象即可。如果 hashCode() 方法不均匀,所有的对象都被分配到第一个槽点,那么无论我们做任何操作,都需要遍历所有的对象。关于 hashMap 集合的原理,后面我们出博客专门介绍。
从上面的解释可以看出,hashCode() 方法的主要作用是将对象元素分类,方便查询等操作。既然涉及到查询,一定存在判断是否相等操作,而判断是否相等的方法上面我们介绍了很多,即 equals()。下面我们看一下 Set 集合中是如何判断对象是否存在:
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
从源码可以清晰的看出,在 Set 集合中,要判断一个对象是否存在,首先根据 hash 值定位到槽点,然后在槽点集合中根据 equals() 方法判断是否存在。
也就是是说,要想在 Set() 集合中判断对象是否存在,还需要首先拥有相同的 hash 值。这也是为什么一般重写 equals() 方法后,还需要重写 hashCode() 方法的主要原因。和equals() 方法相同,下面我们总结下 hashCode() 方法需要遵守的约定:
-
equals() 返回 true 的两个对象,他们的 hashCode() 结果必须相同
-
equals() 返回 false 的两个对象,他们的 hashCode() 结果可以不相等
和 equals() 方法类似,如果一个类重写完 hashCode() 方法后没有满足上述条例,那么就会在 Map 集合中出现奇奇怪怪的异常信息。这里我使用Set方便验证,看过源码的同学一定知道,Set底层用的就是Map,只不过没有存 value 值。
class Node {
int num;
public Node(int num) {
this.num = num;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Node) {
return ((Node) obj).num == num;
}
return false;
}
}
@Test
public void test2() {
HashSet<Node> set = new HashSet<>();
Node node1 = new Node(1);
Node node2 = new Node(1);
set.add(node1);
System.out.println("node1.equals(node2) ->" + node1.equals(node2));
System.out.println("set 集合是否包含node1:" + set.contains(node1));
System.out.println("set 集合是否包含node2:" + set.contains(node2));
}
执行结果:
node1.equals(node2) ->true
set 集合是否包含node1:true
set 集合是否包含node2:false
从输出结果可以看出,虽然 node1 对象和 node2 对象是相等的(根据equals()方法判断),但是 set 集合只认为它包含有 node1 对象,而不包含有 node2 对象。
造成这种现象的原因很简单,因为 node1 对象和 node2 对象的 hashCode() 值是不相等的。因此首先会被定位到不同的槽位,如果槽位都不相同,还没有到 equals() 那一步已经返回 false。
要解决上述问题很简单,只需要重写该类的 hashCode() 方法即可。重写的方法有很多种,这里我列出最简单也相对比较均衡并且和对象属性有关的一种:
@Override
public int hashCode() {
return new Integer(num).hashCode();
}
将 Node 类的 hashCode() 方法重写为如上后,再次运行代码:
执行结果:
node1.equals(node2) ->true
set 集合是否包含node1:true
set 集合是否包含node2:true
从输出结果可以看出,这次没有再出现上面的问题。
重写 equals() 方法的建议
- 判断前在方法中将参数对象强转为相应类型的对象
- 判断是否同意对象时用“==”,直接比较地址
- 判断参数对象是否为NULL,如果是直接返回 false
- 存在父子类关系时,如果业务上这两个类是相同的,用 instanceof 方法判断,否则通过 getClass() 判断是否同一类型
- 根据属性比较时,基础类型比较用 “==”,对象类型用 equals() ,两这都返回 true 时才返回 true。
- 子类重写 equals() 方法时,根据业务场景选择是否在类型不匹配时调用 super.equals(xxx),返回 true时,必须先调用 super.equals(xxx)。
- 重写 equals() 方法后,一定不能忘记重写 hashCode(),否则在 Map 集合中容易出错。