Java中hashCode、equals的那些"坑"

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无法找到对应元素的"低级错误"。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值