一個幾乎必問的面試題
在面試 Java初級開發的時候,經常會問的一個問題是:你有沒有重寫過 hashcode
方法?不少候選人直接說沒寫過。或許真的是沒寫過,於是還可以再通過一個問題確認:你在用HashMap的時候,鍵( Key
)部分,有沒有放過自定義對象?而這個時候,候選人說放過,於是兩個問題的回答就自相矛盾了。
其實很多人這個問題普遍回答得都不大好,於是在本文里,就干脆 從 hash
表講起,講述HashMap的存數據規則,由此大家就自然清楚上述問題的答案了。
再過一遍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方法的重要性。
為毛要重寫equals和hashCode方法
當我們用 HashMap存入自定義的類時,如果不重寫這個自定義類的equals和hashCode方法,得到的結果會和我們預期的不一樣。我們來看 WithoutHashCode.java
這個例子。
在其中的第 2
到第 18
行,我們定義了一個 Key
類;在其中的第 3
行定義了唯一的一個屬性 id
。當前我們先注釋掉第 9
行的 equals
方法和第 16
行的 hashCode
方法。
-
1 import java.util.HashMap;
-
2 class Key {
-
3 private Integer id;
-
4 public Integer getId()
-
5 { return id; }
-
6 public Key(Integer id)
-
7 { this.id = id; }
-
8 //故意先注釋掉equals和hashCode方法
-
9 // public boolean equals(Object o) {
-
10 // if (o == null || !(o instanceof Key))
-
11 // { return false; }
-
12 // else
-
13 // { return this.getId().equals(((Key) o).getId());}
-
14 // }
-
15
-
16 // public int hashCode()
-
17 // { return id.hashCode(); }
-
18 }
-
19
-
20 public class WithoutHashCode {
-
21 public static void main(String[] args) {
-
22 Key k1 = new Key(1);
-
23 Key k2 = new Key(1);
-
24 HashMap<Key,String> hm = new HashMap<Key,String>();
-
25 hm.put(k1, "Key with id is 1");
-
26 System.out.println(hm.get(k2));
-
27 }
-
28 }
在 main
函數里的第 22
和 23
行,我們定義了兩個 Key
對象,它們的 id
都是 1
,就好比它們是兩把相同的都能打開同一扇門的鑰匙。
在第 24
行里,我們通過泛型創建了一個HashMap對象。它的鍵部分可以存放 Key
類型的對象,值部分可以存儲String類型的對象。
在第 25
行里,我們通過 put
方法把 k1
和一串字符放入到 hm
里;而在第 26
行,我們想用 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
的原因。
當我們把第 16
和 17
行的 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
的原因。
為了解決這個問題,我們需要打開第 9
到 14
行 equals
方法的注釋。在這個方法里,只要兩個對象都是 Key
類型,而且它們的 id
相等,它們就相等。
再次強調
由於在項目里經常會用到HashMap,所以在面試的時候幾乎一定會問這個問題:你有沒有重寫過 hashCode
方法?你在使用HashMap時有沒有重寫 hashCode
和 equals
方法?你是怎么寫的?
最后再強調一下:如果大家要在HashMap的 “鍵” 部分存放自定義的對象,一定要在這個對象里用自己的 equals
和 hashCode
方法來覆蓋 Object
里的同名方法。