Java中hashCode、equals的那些"坑"
1 hashCode和equals的作用
我们知道hashCode是Object中定义的方法,它返回对象的哈希码值。Object方法中对hashCode的方法描述是"通过将对象的内部地址转化为整数而实现的该方法",所以从Object类的描述角度来说,hashCode可以直接反映出两个对象地址是否相同,但它也间接的表达了另一个含义"一个在创建后,如果引用没有发生改变,其hashCode的值应该是终生不变的"。
利用这种"唯一"特性,Java将hashCode应用于散列表中(即我们常说的HashMap)。HashMap是一种双列集合,它是一种"键值"结构("key-value"结构),是一对一的存储结构。在散列表中,key表示键,并且需要在整个散列表中必须保证唯一性,value是key键对应的唯一值,而散列表中的所有value不唯一。
在散列表中,如果我们将一个对象当成了键,那么在整个散列表中,这个对象时唯一的,如果我们再次放入该键和键值,原有的键值就会被覆盖,这是散列表的一个存储特性。而判断对象是否唯一的,hashCode就起到了决定性的作用。
但是,在实际的计算过程中,两个不同地址的对象,依然存在哈希码相等的可能性(如果哈希算法让不同对象的哈希码相等的概率越小,会在哈希查找时体现出更高的效率),所以单一的从hashCode上来判断对象的"唯一性"是不够的。这个时候我们就要引入equals方法,来判断它们是否相等。
equals方法是用来判断对象是否相等。在Object类中,equals的实现是用"=="来判断两个对象是否相同,这是最为极端的相等关系。在Java的等价关系中,不说所有等价关系都依赖于这样极端的情况,所以才延伸出hashCode和equals方法进行等价关系的判断。
上述三者等价关系的判断,从弱到强的关系为:“hashCode < equasl < ==”。从这个关系中可以看出,如果对象a和b的 " == “相等,那么两个对象的hashCode值和equals必然是相等的,反过来hashCode值和equals相等,不一定能够保证”=="是相等。单独拿出hashCode和equals也有这样的关系,即equals相等,hashCode一定相等,反过来则不保证equals能够相等,这也是我们在重写equals方法时,必须重写hashCode的原因。
在很多的比较关系中,我们不需要使用到"=="这样极端相等判断,只要保证equals层关系等价即可。例如String和Integer等封装类,它们都重写了equals方法,它们相等的依据是值相等,就认为两个对象时相等。所以当String s1=“abc"和String s2=new String(“abc”)这两个字符串变量在比较时,s1和s2判断只能通过equals来进行判断,而不能通过”=="判断的原因,另外从比较强弱关系来说,s1和s2的hashCode值一定也是相等的。
我们再将话题搬回到散列表中。在散列表中判断两个对象是否是同一个key时,判断的依据是先判断hashCode,然后再判断equals,如果equals相等就判定两个对象时同一个key。所以在HashMap中,String s1="abc"和String s2=new String(“abc”)这两个字符串变量会被认为同一个key。
2 重写规则
通过等价关系比较,以及等价关系的强弱来看,如果我们重写了equals方法,就必须重写hashCode方法,它们之间的约束是非常紧密的。其次equals方法在重写的时候,必须遵循如下等价关系:
1.自反性:如果x为非null引用,则x.equals(x)都应该返回true。
2.对称性:如果x和y为非null引用,当x.equals(y)为true时,则y.equals(x)也必须返回true。
3.传递性:x、y、z为非null引用,如果 x.equals(y)为true并且y.equals(z)为true,那么x.equals(z)也应 该返回true。
4.一致性:如果x和y为非null引用,只要不修改对象y中的比较信息没有被修改,多次调用x.equals(y)会一致返回true,如果为false,equals会一致返回false。
5.对于任何非null引用的x,x.equals(null)都应该返回false。
而hashCode在重写的时候,需要注意一下约定:
1.在程序运行期间,同一对象上的hashCode应该始终保持一致,但这个唯一性有个前提条件,即equals方法中比较的信息没有发生改变,hashCode就不应该发生变化。
2.如果equals方法返回两个对象是相等的,那么这两个对象的hashCode值必须相等。
3.虽然允许出现不相等的对象产生相同的哈希值,但为不相等的对象生成不同的哈希值是可以提升哈希表性能的。
在Java的标准类库中,有一些类就重写equals和hashCode方法,比较具有代表性的是String类。通过阅读String的源码我们可以发现,String的hashCode是通过String的每一个字符信息计算出来的(String的hashCode与内存地址毫无关系了),而equals方法也是比较两个字符串中的字符。String很好的贯彻了hashCode和equals重写的原则。String还有一个特性是一旦字符串引用被重新赋值,引用的对象也就被改变了,最直观的现象就是String的hashCode和equals都会发生改变,在这点上它符合一致性的前提条件。类似于String和Integer这样的类,它们都是以"值"为核心进行等价判定的,所以这些对象只要值相等即认为是等同的对象。
还有一种类如List这样的集合类,它的hashCode和equals方法也被重写了,它们的实现内容与集合内部的元素相关,只要集合的类型一致,并且集合中元素的顺序和值一致,通过equals方法判断的两个集合也是一致的。
3 重写会引发的问题
在实际的过程中,常规的复合类重写hashCode和equals方法并不是一种常见的操作,而且也不推荐这样做。因为你会发现,当重写equals方法时,你绕不开类中的成员变量,那么hashCode值和equals的实现就会与类的成员变量纠缠不清。一旦成员变量的值发生改变,就意味着哈希值会发生改变,当这个复合类的对象做了HashMap的键时,灾难就接踵而至。例如在下面的示例中,当我们为Person重写了hashCode和equals方法时,如果将Person对象作为HashMap的key键的时候,一旦Person对象发生变化,key也就失效了。
01. import java.util.HashMap;
02.
03. public class Person {
04.
05. private String name;
06. private int age;
07.
08. public Person(String name,int age) {
09. this.name=name;
10. this.age=age;
11. }
12.
13. public String getName() {
14. return name;
15. }
16.
17. public void setName(String name) {
18. this.name = name;
19. }
20.
21. public int getAge() {
22. return age;
23. }
24.
25. public void setAge(int age) {
26. this.age = age;
27. }
28.
29. public int hashCode() {
30. //哈希值由名字的哈希值和年龄组合而成
31. return name.hashCode()+age;
32. }
33.
34. public boolean equals(Object o) {
35. //如果类型不匹配,不是等同对象。
36. if(!(o instanceof Person)) {
37. return false;
38. }
39. Person target=(Person)o;
40. if(target.name==null) return false;
41.
42. if(target.name.equals(this.name) && target.age==this.age) {
43. return true;
44. }else {
45. return false;
46. }
47. }
48.
49. public static void main(String[] args) {
50. Person p1=new Person("小明", 19);
51. Person p2=new Person("小刚", 20);
52.
53. HashMap<Person, String> map=new HashMap<>();
54. map.put(p1, "小明说,我过生日了,要满20岁了");
55. map.put(p2, "小刚说,我刚满20岁");
56. System.out.println("首次获取小明和小刚的留言:");
57. System.out.println("小明说:"+map.get(p1));
58. System.out.println("小刚说:"+map.get(p2));
59. p1.setAge(20);
60. System.out.println("再次获取小明和小刚的留言:");
61. System.out.println("小明说:"+map.get(p1));
62. System.out.println("小刚说:"+map.get(p2));
63. }
64. }
示例运行结果:
首次获取小明和小刚的留言:
小明说:小明说,我过生日了,要满20岁了
小刚说:小刚说,我刚满20岁
再次获取小明和小刚的留言:
小明说:null
小刚说:小刚说,我刚满20岁
造成上述示例运行出问题的原因,就是因为Person对象的数据发生改变时,hashCode也随之改变,在HashMap中无法通过当前对象的哈希数值找到目标value值。
如果我们对比Person和String类,你会发现当它们的"值"发生变化时,HashMap都找不到原有对应的value值。但String的值发生变化时,它变成了一个全新的字符串对象,这对HashMap和使用者来说,是允许的可以被接受的。而Person的值发生变化时,如果解释说Person对象也变成了一个全新的对象,这一点是不符合逻辑,说不通的。
另外一个类List,如果它做为key键会出现和Person一样的情况,但在实际操作中,不会有人无聊到把List当做key键放在HashMap中。所以这也给开发们提个醒,不是所有的对象都能做HashMap中的key键,也不是所有的类可以重写equals和hashCode方法的。
4 合理的等价比较方式
如果只是为了判断对象等价而重写equals方法,代价是比较大的。因为这牺牲了它做哈希表key键的可能,我们可以采用其它的方式来判断连个对象是否相等,例如我们可以使用比较器Comparable或Comparator来实现。这两个接口主要用于排序上的,用来判断对象之间的大小关系。Comparable中的比较方法 compareTo(T o),当一个类实现了该接口后,compareTo方法可以判断当前对象和目标对象o的大小关系,如果小于目标对象返回负值,如果大于指定对象返回正整数,如果相等返回0。另一种比较器Comparator的比较方法是compare(T o1, T o2),它会比较参数o1和o2的关系,如果o1>o2则返回正整数,反之返回负数,相等返回0。
这两个接口都可以来判断对象是否等价,区别在于接口Comparable必须由被比较的类来实现,所以也被称为内比较器。另一个接口不依赖于被比较的类,所以被称为外比较器。在使用的时候,如果不考虑到其它约束条件,尽量使用Comparator接口实现比较等价关系,因为如果被比较对象会派生出子类的时候,使用Comparable接口会发生接口劫持的现象。
关于hashCode和equals方法的讲述,我们就谈到这里。文章中所谈到的问题是面试和开发过程中常遇见的"坑",如果我们真正的了解这些问题后,我们就不会被面试官所套路,也不会在开发过程中出现HashMap无法找到对应元素的"低级错误"。