为何重写equals方法就得重写hashCode方法?
这是一个熟悉又很基础的面试题。
随着工作时间的增加,很多面试八股理论,慢慢在工作中的实践中得以验证。但也会有些理论,过了面试阶段之后,似乎就永远躺在理论里。
这篇文章就用日常开发中很容易遇到的一个开发场景,来验证这个理论。而不是像很多讲这个问题的文章那样通篇理论。
日常开发中常见的一个业务操作:比较两个对象集合,找出这两个集合的交并集,或者单纯的数据去重。
如果自己写逻辑,实现细节少不了遍历集合,比较对象是否一致的操作。
一般有两种思路:
此时重写对象的equals方法是必不可少的操作。
【我们假设在在这个demo业务中,两个对象的一致的标准是名字和性别】。
//测试对象,只写重写equals
public class StudentEntity {
private String name;
private Integer age;
private Boolean sex;
private Date birthDay;
public StudentEntity() {
}
public StudentEntity(String name, Integer age, Boolean sex, Date birthDay) {
this.name = name;
this.age = age;
this.sex = sex;
this.birthDay = birthDay;
}
//getter and setter...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
StudentEntity that = (StudentEntity) o;
return Objects.equals(name, that.name) &&Objects.equals(sex, that.sex);
}
//测试方法
//1. 存储在List中,使用contains判重
public void m1() {
List list = new ArrayList<>();
list.add(new StudentEntity("zs",2,true,null));
list.add(new StudentEntity("zs",3,false,null));
list.add(new StudentEntity("ls",1,true,null));
StudentEntity s = new StudentEntity("zs",2,false,null);
if (list.contains(s)) {
System.out.println("------yes-----");//预期输出
} else {
System.out.println("------no-----");
}
}
输出
------yes-----
和预期一致。
//存储在set中自动去重
public void m2() {
Set set = new HashSet<>();
set.add(new StudentEntity("zs",2,true,null));
set.add(new StudentEntity("zs",3,false,null));
set.add(new StudentEntity("ls",1,true,null));
set.add(new StudentEntity("zs",2,false,null));//添加一个重复对象
System.out.println("------over-----size:" + set.size());//预期size=3
}
demo2输出:
------over-----size:4
说明此时set并没有按照我们的预期去重。
我们把hashCode方法也重写一下
@Override
public int hashCode() {
return Objects.hash(name, sex);
}
怎么写hashCode?
直接使用IDEA自带的工具方法直接生成即可,包括前面equals也是这么重写的。注意重写的标准也是name+sex。
demo2输出:
------over-----size:3
此时HashSet终于正常实现去重功能了。
通过前面的常见开发案例,可以认识到,这个简单开发建议确实是有实用价值,否则就会产生bug。下面就来分析一下原因。
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;
}
可以看到contains最终的判断依据是对象的equals方法。所以第一个示例中,我们只重写了equals方法后,就可以使得contains方法正确执行。
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
可以看到HashSet的底层使用的HashMap。
至此,终于讲到了本文的重点。
很多文章一上来就拿HashMap来举例子。但实际开发中有多少重写equals,hashCode方法的情况是为了让对象作为Map的key的场景呢?
事实上,把复杂对象作为key的场景,在实际生产中都很少使用。毕竟key在我们的直观印象里,就是一个小小的索引,往往都是字符串或数值类型。
HashMap就是通过key的hashCode在数组上散列存储value数据的。
如果通过HashCode散列到了同一个位置,并且通过equals判断发现key确实不一样,那么再在这个位置上挂链表或红黑树结构。
value被存在数组上或者挂在链表上。那key放在哪了?
答:和value一起封装在Node对象中。所以找到value就找到key了。
不太懂HashMap数据结构基本原理?
见HashMap源码分析
根据前面的数据结构可知:hashcode虽然决定了在数组上散列几个位置。但并不会因此让数据“消失”。数据只是被挂在了链表上。
做一个极端的例子感受一下: 把hashCode固定为一个常数。
@Override
public int hashCode() {
return 1;
}
demo2输出:
------over-----size:3
hashCode和equals在HashMap中就像两个调节开关,可以调的很严格(最严格的就是直接比较对象的存储地址,以及对象的默认hashCode)。
而HashMap/HashSet能存多少对象,取决于这两个开关中最严格的那个(木桶原理的反向应用)。所以在demo中,当我们不重写hashCode。就相当于hashCode开关处于最严格的状态。两个对象稍有不一致,就会被散列到数组的不同位置,根本轮不到equals来判断。
但hashCode调节的也不能太松,比如上面的极端情况,直接把值固定了。那么此时HashMap就退化成链表或者红黑树了(数组永远只用一个位置,所有的值都挂在这个位置后面)。此时虽然不影响存储数据(由equals兜底判重)。但效率会降低(链表构成的树结构效率再高也超不过数组散列后直接寻址)。
前面我们都是在调节hashCode(最松:固定值。 最严:不写,使用默认值)。
如果根据我们总结的原理,把这两个开关都调到最松:把hashcode固定值,并且把equals重写为“通通一致”。会有什么后果呢?
答:HashMap/HashSet只能存一个对象,后一个会不断覆盖前一个。
准确的讲,判重严格程度:hashCode < equals = 业务要求
hashCode比equals松,会产生一点性能降低。但如果hashCode比equals严格,就会产生bug。
理论上,hashCode 不可能等于 equals,hash函数设计的再好,也不可能百分百避免撞车(没有理论依据,我猜的)
我们知道HashMap的数据结构是:数组上挂链表(如果有hashCode冲突才会挂链表或红黑树)。那么HashMap如何控制把对象散列在数组范围内的呢?(超出就会导致IndexOutOfBoundsException)
关键还在HashMap的put方法里:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
...
关键点:(n - 1) & hash
n:HashMap的数组长度
hash:对象的hashCode
以初始长度16为例,n-1就是15,二进制就是0000 1111
经过&运算之后,结果一定小于16
由于每次扩容都是2的倍数
...
//HashMap的扩容代码
newThr = oldThr << 1; // 位运算:左移一位,就是乘以2的意思
...
所以n-1永远都是000..00111...11
这样的“二进制工具数”,刚好可以把&运算结果控制在数组n范围内,也就是不会有数组越界的问题了。
HashMap源码分析