在开发过程中,我们经常会遇到要重写equals方法和重写hashCode方法的情况,那么,我们为什么要重写这个两个方法呢?重写这个两个方法有什么实际的作用吗?先别急,我们一一来分析,先看equals方法。
一、equals方法
我们先从超类Object里截取equals方法:
注意看,在Object类中,如果直接调用equals方法的话,是用的“==”来比较的,也就是说,它是默认比较的两个对象的内存地址值。理解了这一点,我们来看以下代码:
public class Test {
public static void main(String args[]) {
String s1 = new String("1111");
String s2 = new String("1111");
boolean result = s1.equals(s2);
System.out.println(result); // 输出结果为:true
}
}
我们都知道,第四行和第五行代码的意思是在堆内存中创建了两个对象,因此,s1和s2两个变量保存的是指向这个两个对象的内存地址,输入两个对象的内容是一样的,但是它们的内存地址值是不一样的。如下图所示:
我们知道任何类都是默认继承Object类的,String类当然也不例外,从上述的输出结果推断,如果String没有重写equals方法的话,那么s1和s2比较的一定是两者的内存地址,显然是不相等的,应该为false。但实际的输出结果为true,因此,我们可以肯定的是String类中一定重写了equals方法,我们点进去看一下String的源码:
果然,如果重写了equals方法,那么按照类继承的规则,String类调用的是自身的equals,s1跟s2两者比较的规则则是按照String类重写后的equals方法来比较,很显然,String类的比较规则是按照值来比较的,因此结果会输出true。
为了验证我们的想法,那我们试着自定义一个Student类(注意:是没有重写equals方法的)
public class Student {
String username;
String password;
public Student() {
}
public Student(String username, String password) {
this.username = username;
this.password = password;
}
}
测试:
public static void main(String args[]) {
Student s1 = new Student("张三","123");
Student s2 = new Student("张三","123");
boolean result = s1.equals(s2);
System.out.println(result);
}
输出结果为:false
这是为啥呢?因为,Student类默认是继承Object的,而Student没有重写equals方法,那么默认就会调用父类也就是Object类的equals方法,而Object的equals是比较两者之间的内存地址,s1和s2两个对象都是new出来的,内存地址肯定是不一样的,因此结果为false。如果我们在student类中重写了equals方法,那么输出的结果就变成了true了。
二、hashCode方法
我们先看一个现象:在做开发的时候,定义一个实体类,往往需要重写hashCode方法以及equals方法。而且不管是我们自己定义的类,sun公司定义的类诸如String、Integer等等都重写了equals方法,以及hashCode方法,那么这时为什么呢?
先说结论:这是为了提高存储载体的查询效率。举个例子:如果你存储的载体底层是采用了哈希表的数据结构。例如set集合,那么你将一个重写了hashCode方法的元素存进去后,再通过查询获取到这个元素的效率会很高。这是为什么呢?要理解这个,我们得先知道什么是哈希表?哈希算法又是什么鬼?
2.1、哈希表
散列表[Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。(摘抄自百度百科)
以上是比较官方的定义,相信没有多少人能看懂,其实我开始也看不太懂。事实上,哈希表的构成其实很简单。之前我们用过的数组已经链表相信都知道,数组的查询效率较高,但增删效率较低,链表的查询效率较低,但增删效率较高。可能你没有一个概念,因为你平常遇到的场景都是数据量比较小的,如果遇到那种千万级别的数据量,底层数据结构的选取对于系统性能的提升是很明显的。
哈希表其实本质上就是一个数组跟链表的结合体,它综合了两者之间的优点,它的查询效率比链表高,增删效率比数组高,相当于是一个折中的方案。它大概长这个德行:
一个数组,然后每个数组项下挂一个单向链表。
2.2、set集合的存储原理
(这里我们以set集合为例,因为它底层是通过哈希表来实现的)我们来看以下代码:
public class Test {
public static void main(String args[]) {
Map<Integer,String> map = new HashMap<>();
map.put(1, "张三");
map.put(2, "李四");
map.put(2,"王五");
System.out.println(map.size()); // 2
String s1 = map.get(1);
String s2 = map.get(2);
System.out.println(s1); // 张三
System.out.println(s2); // 王五
}
}
map.put()实现原理
第一步,先将key和value封装到一个节点对象中
第二步:底层会调用key的hashCode方法得到哈希值,然后通过哈希算法,将哈希值转换成数组的下标,如果下标位置上没有任何元素,就把节点对象添加到这个位置上。如果说下标的位置上对应有链表,那么就会调用节点key的equals方法去与链表上每一个节点的key进行比较,如果所有的equals方法的方法结果都是false,那么这个新节点就将被添加到链表的末尾,如果有其中一个返回true,那么这个节点就会被覆盖掉。这就是了为什么set集合是不可重复的底层原因。
map.get()实现原理
先调用key的hashCode方法得出哈希值,再通过哈希算法转换成数组下标,通过数组下标快速的定位到数组的某一个位置上。如果这个位置上怎么也没有,那么会拿着参数key和单向链表的key进行equals,如果equals方法返回的是false,代表没有搜索到,此时get方法返回的就是null,如果equals方法返回的是true,代表搜索到了,此时get方法获取到的就是key对应的value值。
分析
上面的map集合,key是Integer类型,value是String类型两者其实都重写了hashCode方法和equals方法,按照以上过程存储之后的哈希表结构如下:
如果再存储一个新的数据赵六,按照上述步骤,那么就会挂载到李四节点的下面,形成一个单向链表
回到原点,如果我们要存储的元素没有重写hashCode方法和equals方法,那么根据上述的存储过程,元素会调用父类的equals方法以及hashCode方法。如下代码:
public class HashMapTest02 {
public static void main(String[] args) {
// Student类没有重写hashCode方法
Student s1 = new Student("张三");
Student s2 = new Student("张三");
Set<Student> students = new HashSet<>();
students.add(s1);
students.add(s2);
System.out.println(students.size()); // 输出2
System.out.println("s1的哈希码为:" + s1.hashCode()); // 输出:356573597
System.out.println("s2的哈希码为:" + s2.hashCode());// 输出:1735600054
}
}
输出的结果竟然是2
仔细分析过程:首先,s1变量和s2变量两者存储的是对象的内存地址,因此调用hashCode方法得到的哈希码肯定是不一致的。那么由哈希码通过哈希算法得出的数组下标则不一致,因此,两个元素都会被存储到哈希表中。如下图所示:
因此,set集合中会存储两个相同的值,这也是为什么集合的长度为2的原因。
三、总结
在实际开发过程中,我们之所以要重写hashCode方法,就是为了使底层为哈希表的存储载体如集合等的存储查询效率更高。我们最常用的存储载体就是集合了,看完上面的分析过程,对集合的一些总结概要相信你一个很容易理解了:
1、为什么HashMap集合是无序不可重复的?
因为哈希算法是随机的,因此无法确定算出的数组下标是否是同一个,即无法确定挂载到哪个单向链表上。 equals方法来保证HashMap集合的key不可重复。如果key重复了,value会覆盖掉。
2、哈希表HashMap使用不当时无法发挥性能
假设将所有的hashCode()方法返回值固定为某个值,那么会导致底层哈希表变成了 纯单向链表。这种情况我们称之为:散列分布不均匀。
什么是散列分布均匀? 假设有100个元素,10个单向链表,那么每个单向链表上有10个节点,这是最好的,是散列分布均匀的。
假设将所有的hashCode()方法返回值都设定为不一样的值,可以吗,有什么问题?不行,因为这样的话导致底层哈希表就成为一维数组了,没有链表的概念了。也是散列分布不均匀。因此散列分布均匀需要你重写hashCode()方法时有一定的技巧。
3、放在HashMap集合key部分的元素,以及放在HashSet集合中的元素,需要同时重写hashCode和equals方法。
4、放在HashMap集合key部分的元素,以及放在HashSet集合中的元素,需要同时重写hashCode和equals方法。
5、HashMap集合的默认初始化容量是16,默认加载因子是0.75,这个默认加载因子是当HashMap集合底层数组的容量达到75%的时候,数组开始扩容。
6、HashMap集合初始化容量必须是2的倍数,这也是官方推荐的,这是因为达到散列均匀,为了提高HashMap集合的存取效率,所必须的。
7、向Map集合中存,以及从Map集合中取,都是先调用key的hashCode方法,然后再调用equals方法!equals方法有可能调用,也有可能不调用。
拿put(k,v)举例,什么时候equals不会调用?k.hashCode()方法返回哈希值,哈希值经过哈希算法转换成数组下标。数组下标位置上如果是null,equals不需要执行。拿get(k)举例,什么时候equals不会调用?k.hashCode()方法返回哈希值,哈希值经过哈希算法转换成数组下标。数组下标位置上如果是null,equals不需要执行。
8、哈希碰撞
对于哈希表数据结构来说:如果o1和o2两个对象的hash值相同,一定是放到同一个单向链表上。当然如果o1和o2的hash值不同,但由于哈希算法执行结束之后转换的数组下标可能相同,此时会发生“哈希碰撞”。但这种概率及其微小。