面試官:為什么要重寫hashcode和equals方法?


 一個幾乎必問的面試題 

在面試 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函數計算后, 78會有相同的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. 1 import java.util.HashMap;

  2. 2 class Key {

  3. 3 private Integer id;

  4. 4 public Integer getId()

  5. 5 { return id; }

  6. 6 public Key(Integer id)

  7. 7 { this.id = id; }

  8. 8 //故意先注釋掉equals和hashCode方法

  9. 9 // public boolean equals(Object o) {

  10. 10 // if (o == null || !(o instanceof Key))

  11. 11 // { return false; }

  12. 12 // else

  13. 13 // { return this.getId().equals(((Key) o).getId());}

  14. 14 // }

  15. 15

  16. 16 // public int hashCode()

  17. 17 // { return id.hashCode(); }

  18. 18 }

  19. 19

  20. 20 public class WithoutHashCode {

  21. 21 public static void main(String[] args) {

  22. 22 Key k1 = new Key(1);

  23. 23 Key k2 = new Key(1);

  24. 24 HashMap<Key,String> hm = new HashMap<Key,String>();

  25. 25 hm.put(k1, "Key with id is 1");

  26. 26 System.out.println(hm.get(k2));

  27. 27 }

  28. 28 }

main函數里的第 2223行,我們定義了兩個 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里沒定義)計算 k2hash值時,其實得到的是 k2的內存地址(假設是 2000)。由於 k1k2是兩個不同的對象,所以它們的內存地址一定不會相同,也就是說它們的 hash值一定不同,這就是我們無法用 k2hash值去拿 k1的原因。

當我們把第 1617行的 hashCode方法的注釋去掉后,會發現它是返回 id屬性的 hashCode值,這里 k1k2id都是1,所以它們的 hash值是相等的。

我們再來更正一下存 k1和取 k2的動作。存 k1時,是根據它 idhash值,假設這里是 100,把 k1對象放入到對應的位置。而取 k2時,是先計算它的 hash值(由於 k2id也是 1,這個值也是 100),隨后到這個位置去找。

但結果會出乎我們意料:明明 100號位置已經有 k1,但第 26行的輸出結果依然是 null。其原因就是沒有重寫 Key對象的 equals方法。

HashMap是用鏈地址法來處理沖突,也就是說,在 100號位置上,有可能存在着多個用鏈表形式存儲的對象。它們通過 hashCode方法返回的 hash值都是100。

當我們通過 k2hashCode100號位置查找時,確實會得到 k1。但 k1有可能僅僅是和 k2具有相同的 hash值,但未必和 k2相等( k1k2兩把鑰匙未必能開同一扇門),這個時候,就需要調用 Key對象的 equals方法來判斷兩者是否相等了。

由於我們在 Key對象里沒有定義 equals方法,系統就不得不調用 Object類的 equals方法。由於 Object的固有方法是根據兩個對象的內存地址來判斷,所以 k1k2一定不會相等,這就是為什么依然在 26行通過 hm.get(k2)依然得到 null的原因。

為了解決這個問題,我們需要打開第 914equals方法的注釋。在這個方法里,只要兩個對象都是 Key類型,而且它們的 id相等,它們就相等。


 再次強調 

由於在項目里經常會用到HashMap,所以在面試的時候幾乎一定會問這個問題:你有沒有重寫過 hashCode方法?你在使用HashMap時有沒有重寫 hashCodeequals方法?你是怎么寫的?

最后再強調一下:如果大家要在HashMap的 “鍵” 部分存放自定義的對象,一定要在這個對象里用自己的 equalshashCode方法來覆蓋 Object里的同名方法。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM