1. 一个几乎必问的面试题
在面试 Java初级开发的时候,经常会问的一个问题是:你有没有重写过 hashcode方法
?不少候选人直接说没写过。或许真的是没写过,于是还可以再通过一个问题确认:你在用HashMap的时候,键( Key)部分,有没有放过自定义对象
?而这个时候,候选人说放过,于是两个问题的回答就自相矛盾了。
其实很多人这个问题普遍回答得都不大好,于是在本文里,就干脆 从 hash表
讲起,讲述HashMap
的存数据规则,由此大家就自然清楚上述问题的答案了。
2. 再过一遍Hash算法
先复习一下数据结构里的一个知识点:在一个长度为n
(假设是 10000
)的线性表(假设是ArrayList)里,存放着无序的数字;如果我们要找一个指定的数字,就不得不通过从头到尾依次遍历来查找。
我们再来观察Hash表(这里的Hash表纯粹是数据结构上的概念,和Java无关)。它的平均查找次数接近于1
,代价相当小,关键是在Hash表里,存放在其中的数据和它的存储位置是用Hash函数关联的。
我们假设一个Hash函数是x*x%5
。当然实际情况里不可能用这么简单的Hash
函数,这里纯粹为了说明方便,而Hash
表是一个长度是11
的线性表。如果我们要把6
放入其中,那么我们首先会对6
用Hash函数计算一下,结果是1
,所以我们就把 6
放入到索引号是1
这个位置。同样如果我们要放数字7
,经过Hash函数计算,7
的结果是4
,那么它将被放入索引是4
的这个位置。这个效果如下图所示。
这样做的好处非常明显。比如我们要从中找6
这个元素,我们可以先通过Hash函数计算6
的索引位置,然后直接从1
号索引里找到它了。
不过我们会遇到“Hash值冲突”
这个问题。比如经过Hash
函数计算后, 7
和 8
会有相同的Hash值,对此Java
的HashMap
对象采用的是"链地址法
"的解决方案。效果如下图所示
具体的做法是,为所有Hash
值是 i的对象建立一个同义词链表。假设我们在放入8
的时候,发现4
号位置已经被占,那么就会新建一个链表结点放入8
。同样,如果我们要找8
,那么发现 4号索引里不是 8
,那会沿着链表依次查找。
虽然我们还是无法彻底避免Hash
值冲突的问题,但是Hash
函数设计合理,仍能保证同义词链表的长度被控制在一个合理的范围里。这里讲的理论知识并非无的放矢,大家能在后文里清晰地了解到重写hashCode方法的重要性
。
3. 为毛要重写equals和hashCode方法
当我们用HashMap
存入自定义的类时,如果不重写这个自定义类的equals
和hashCode
方法,得到的结果会和我们预期的不一样。我们来看WithoutHashCode.java
这个例子。
在其中,我们首先定义了一个Key
类;在其中定义了唯一的一个属性id
。当前我们先注释掉equals
方法和hashCode
方法。
import java.util.HashMap;
class Key {
private Integer id;
public Integer getId(){
return id;
}
public Key(Integer id){
this.id = id;
}
// 故意先注释掉equals和hashCode方法
// public boolean equals(Object o) {
// if (o == null || !(o instanceof Key)){
// return false;
// }
// else{
// return this.getId().equals(((Key) o).getId());
// }
// }
// public int hashCode() {
// return id.hashCode();
// }
}
public class WithoutHashCode {
public static void main(String[] args){
Key k1 = new Key(1);
Key k2 = new Key(1);
HashMap<Key,String> hm = new HashMap<Key,String>();
hm.put(k1, "Key with id is 1");
System.out.println(hm.get(k2));
}
}
在main
函数里的前2
行,我们定义了两个 Key
对象,它们的id
都是1
,就好比它们是两把相同的都能打开同一扇门的钥匙。
之后我们通过泛型创建了一个HashMap
对象。它的键部分可以存放Key
类型的对象,值部分可以存储String
类型的对象。
接着,我们通过put
方法把k1
和一串字符放入到hm
里;然后,我们想用k2
去从HashMap
里得到值;这就好比我们想用k1
这把钥匙来锁门,用k2
来开门。这是符合逻辑的,但从当前结果看, 26行的返回结果不是我们想象中的那个字符串,而是 null。
原因有两个:一是没有重写hashCode方法,二是没有重写equals方法。
当我们往HashMap
里放k1
时,首先会调用Key
这个类的hashCode
方法计算它的hash
值,随后把k1
放入hash
值所指引的内存位置。
关键是我们没有在Key
里定义hashCode
方法。这里调用的仍是 Object
类的hashCode
方法(所有的类都是Object
的子类),而 Object
类的hashCode
方法返回的hash
值其实是k1
对象的 内存地址(假设是1000
)。
如果我们随后是调用hm.get(k1)
,那么我们会再次调用hashCode
方法(还是返回k1
的地址1000
),随后根据得到的 hash
值,能很快地找到k1
。
但我们这里的代码是hm.get(k2)
,当我们调用Object
类的hashCode
方法(因为Key
里没定义)计算k2
的hash
值时,其实得到的是k2
的内存地址(假设是2000
)。由于k1
和k2
是两个不同的对象,所以它们的内存地址一定不会相同,也就是说它们的hash
值一定不同,这就是我们无法用k2
的hash
值去拿k1
的原因。
当我们把hashCode
方法的注释去掉后,会发现它是返回id
属性的 hashCode
值,这里k1
和k2
的 id
都是1
,所以它们的hash
值是相等的。
我们再来更正一下存k1
和取k2
的动作。存k1
时,是根据它id
的 hash
值,假设这里是100
,把 k1
对象放入到对应的位置。而取k2
时,是先计算它的hash
值(由于k2
的id
也是1
,这个值也是 100
),随后到这个位置去找。
但结果会出乎我们意料:明明100
号位置已经有k1
,但第26
行的输出结果依然是null
。其原因就是没有重写Key
对象的equals
方法。
HashMap
是用链地址法来处理冲突,也就是说,在100
号位置上,有可能存在着多个用链表形式存储的对象。它们通过hashCode
方法返回的hash
值都是100
。
当我们通过k2
的hashCode
到100
号位置查找时,确实会得到 k1。但k1
有可能仅仅是和k2
具有相同的 hash值,但未必和k2
相等( k1
和k2
两把钥匙未必能开同一扇门),这个时候,就需要调用Key
对象的equals
方法来判断两者是否相等了。
由于我们在Key
对象里没有定义equals
方法,系统就不得不调用 Object
类的equals
方法。由于Object
的固有方法是根据两个对象的内存地址来判断,所以k1
和k2
一定不会相等,这就是为什么依然在 26行通过hm.get(k2)
依然得到null
的原因。
为了解决这个问题,我们需要打开equals
方法的注释。在这个方法里,只要两个对象都是Key
类型,而且它们的id
相等,它们就相等。
4. 最后强调
最后再强调一下:如果大家要在HashMap
的 “键
” 部分存放自定义的对象
,一定要在这个对象里用自己的equals
和hashCode
方法来覆盖Object
里的同名方法。