以前面試的時候被面試官問到過這樣一個問題:
你有沒有重寫過 hashCode 方法?
心里想着我沒事重寫哪玩意干啥,能不寫就不寫。嘴上當然沒敢這么說,只能略表遺憾的說抱歉,我沒寫過。
撇了面試官一眼,明顯看到他對這個回答不滿意,但是這已經觸及到我的知識盲點了,我也很慚愧,可是確實沒有重寫過,咱也不能胡扯不是。
然后他又問到另外一個問題:
你在用 HashMap 的時候,鍵(Key)部分,有沒有放過自定義對象?
我說我放過,很自信的說我放過(其實我忘了我有沒有放過),但是不能慫啊,第一個都不會了,第二個再說不會哪不是直接拜拜要走人了嗎?
面試官狡猾的笑了,說是你既然沒有重寫過 hashCode 方法,你怎么把自定義對象放進去的?
我勒個去,原來你在這等着我呢,沒想到這還是個連環炮,惹不起惹不起,認慫三連
不過不會就學,不懂就問,這一直都是咱程序猿優秀的素養,今天就干脆從 Hash 表學起,講述 HashMap 的存取數據規則,由此來搞定上述問題的答案。
通過 Hash 算法來了解 HashMap 對象的高效性
我們先復習數據結構里的一個知識點:
在一個長度為 n(假設是100)的線性表(假設是 ArrayList)里,存放着無序的數字;如果我們要找一個指定的數字,就不得不通過從頭到尾依次遍歷來查找,這樣的平均查找次數是 n / 2(這里是50)。
我們再來觀察 Hash 表(這里所說的 Hash 表純粹是數據結構上的概念,和 Java 無關)。
哈希表就是一種以 鍵-值(key-indexed) 存儲數據的結構,我們只要輸入待查找的值即 key,即可查找到其對應的值。
它的平均查找次數接近於 1,代價相當小。
使用哈希查找有兩個步驟:
-
使用哈希函數將被查找的鍵轉換為數組的索引:在理想的情況下,不同的鍵會被轉換為不同的索引值,但是在有些情況下我們需要處理多個鍵被哈希到同一個索引值的情況。所以哈希查找的第二個步驟就是處理沖突
-
處理哈希碰撞沖突:有很多處理哈希碰撞沖突的方法,本文后面會介紹拉鏈法和線性探測法。
既然哈希查找第一步就是使用哈希函數將鍵映射成索引,那我們就先假設一個 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 值,此時我們就需要了解一下解決哈希碰撞的幾種常見方式:
開放地址法
使用某種探查(亦稱探測)技術在散列表中形成一個探查序列。沿此序列逐個單元地查找,直到找到給定的關鍵字,或者碰到一個開放的地址(即該地址單元為空)為止(若要插入,在探查到開放的地址,則可將待插入的新結點存入該地址單元)。
按照形成探查序列的方法不同,可將開放定址法區分為線性探查法、線性補償探測法以及隨機探測等。限於篇幅,我們此處只討論線性探查法。
線性探查法
該方法基本思想是:
將散列表 T[0..m-1] 看成是一個循環向量,若初始探查的地址為d(即h(key)=d),則最長的探查序列為:
d,d+l,d+2,…,m-1,0,1,…,d-1
即 : 探查時從地址 d 開始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循環到 T[0],T[1],…,直到探查到 T[d-1] 為止。 探查過程終止於三種情況:
-
若當前探查的單元為空,則表示查找失敗(若是插入則將 key 寫入其中);
-
若當前探查的單元中含有 key,則查找成功,但對於插入意味着失敗;
-
若探查到 T[d-1] 時仍未發現空單元也未找到 key,則無論是查找還是插入均意味着失敗(此時表滿)。
利用開放地址法的一般形式,線性探查法的探查序列為:
hi = (h(key)+i)%m 0≤i≤m-1 // 即di=i
用線性探測法處理沖突,思路清晰,算法簡單,但存在下列缺點:
-
處理溢出需另編程序。一般可另外設立一個溢出表,專門用來存放上述哈希表中放不下的記錄。此溢出表最簡單的結構是順序表,查找方法可用順序查找。
-
按上述算法建立起來的哈希表,刪除工作非常困難。假如要從哈希表 HT 中刪除一個記錄,按理應將這個記錄所在位置置為空,但我們不能這樣做,而只能標上已被刪除的標記,否則,將會影響以后的查找。
-
線性探測法很容易產生堆聚現象。所謂堆聚現象,就是存入哈希表的記錄在表中連成一片。按照線性探測法處理沖突,如果生成哈希地址的連續序列愈長 ( 即不同關鍵字值的哈希地址相鄰在一起愈長 ) ,則當新的記錄加入該表時,與這個序列發生沖突的可能性愈大。因此,哈希地址的較長連續序列比較短連續序列生長得快,這就意味着,一旦出現堆聚 ( 伴隨着沖突 ) ,就將引起進一步的堆聚。
在使用了上述線性探查法的情況下,則 7 和 8 在存儲的時候,因為兩者哈希后得到的索引一致,並且 7 已經存到了哈希表中,哪么 8 在找到索引 4 的時候會發現已經有值了,則它繼續開始往后查找,此時找到索引為 5 的位置發現為空,它就會把 8 放到索引為 5 的位置上,如下:
鏈地址法
拉鏈法解決沖突的做法是:將所有關鍵字為同義詞的結點鏈接在同一個單鏈表中。若選定的散列表長度為 m,則可將散列表定義為一個由 m 個頭指針組成的指針數 組 T[0..m-1]。凡是散列地址為 i 的結點,均插入到以 T[i] 為頭指針的單鏈表中。T 中各分量的初值均應為空指針。在拉鏈法中,裝填因子 α 可以大於 1,但一般均取 α≤1。
與開放定址法相比,拉鏈法有如下幾個優點:
-
拉鏈法處理沖突簡單,且無堆積現象,即非同義詞決不會發生沖突,因此平均查找長度較短;
-
由於拉鏈法中各鏈表上的結點空間是動態申請的,故它更適合於造表前無法確定表長的情況;
-
開放定址法為減少沖突,要求裝填因子 α 較小,故當結點規模較大時會浪費很多空間。而拉鏈法中可取 α≥1,且結點較大時,拉鏈法中增加的指針域可忽略不計,因此節省空間;
-
在用拉鏈法構造的散列表中,刪除結點的操作易於實現。只要簡單地刪去鏈表上相應的結點即可。而對開放地址法構造的散列表,刪除結點不能簡單地將被刪結 點的空間置為空,否則將截斷在它之后填人散列表的同義詞結點的查找路徑。這是因為各種開放地址法中,空地址單元(即開放地址)都是查找失敗的條件。因此在 用開放地址法處理沖突的散列表上執行刪除操作,只能在被刪結點上做刪除標記,而不能真正刪除結點。
使用拉鏈法的時候 7 和 8 的時候具體的做法是:為所有 Hash 值是 i 的對象建立一個同義詞鏈表。假設我們在放入 8 的時候,發現 4 號位置已經被占,那么就會新建一個鏈表結點放入 8。同樣,如果我們要找 8,那么發現 4 號索引里不是 8,那會沿着鏈表依次查找。
存儲位置如下:
Java 中的 HashMap 對象采用的是鏈地址法的解決方案。
雖然我們還是無法徹底避免 Hash 值沖突的問題,但是 Hash 函數設計合理,仍能保證同義詞鏈表的長度被控制在一個合理的范圍里。這里講的理論知識並非無的放矢,大家能在后文里清晰地了解到重寫 hashCode 方法的重要性。
2 為什么要重寫 equals 和 hashCode 方法
當我們用 HashMap 存入自定義的類時,如果不重寫這個自定義類的 equals 和 hashCode 方法,得到的結果會和我們預期的不一樣。
我們來看一個例子,定義一個 HashMapKey.java 的類,這個類只有一個屬性 id :
public class HashMapKey { private Integer id; public HashMapKey(Integer id) { this.id = id; } public Integer getId() { return id; } }
測試類如下:
public class TestHashMap { public static void main(String[] args) { HashMapKey k1 = new HashMapKey(1); HashMapKey k2 = new HashMapKey(1); HashMap<HashMapKey, String> map = new HashMap<>(); map.put(k1, "程序猿雜貨鋪"); System.out.println("map.get(k2) : " + map.get(k2)); } }
在 main 函數里,我們定義了兩個 HashMapKey 對象,它們的 id 都是 1,然后創建了一個 HashMap 對象,緊接着我們通過 put 方法把 k1 和一串字符放入到 map里,最后用 k2 去從 HashMap 里得到值,因為 k1 和 k2 值是一樣的,理論上我們是可以用這個鍵獲取到對應的值的,看似符合邏輯,實則不然,它的執行結果是:
map.get(k2) : null
其實出現這個情況的原因有兩個:
-
沒有重寫 hashCode 方法
-
沒有重寫 equals 方法。
當我們往 HashMap 里放 k1 時,首先會調用 HashMapKey 這個類的 hashCode 方法計算它的 hash 值,隨后把 k1 放入 hash 值所指引的內存位置。
但是我們沒有在 HashMapKey 里重寫 hashCode 方法,所以這里調用的是 Object 類的 hashCode 方法,而 Object 類的 hashCode 方法返回的 hash 值其實是 k1 對象的內存地址(假設是 0x100)。
如果我們隨后是調用 map.get(k1),那么我們會再次調用 hashCode 方法(還是返回 k1 的地址 0x100),隨后根據得到的 hash 值,能很快地找到 k1。
但我們這里的代碼是 map.get(k2),當我們調用Object類的 hashCode方法(因為 HashMapKey 里沒定義)計算 k2 的 hash值時,其實得到的是 k2 的內存地址(假設是 0x200)。由於 k1 和 k2 是兩個不同的對象,所以它們的內存地址一定不會相同,也就是說它們的 hash 值一定不同,這就是我們無法用 k2 的 hash 值去拿 k1 的原因。
接下來我們在類 HashMapKey 中重寫 hashCode 方法
@Override public int hashCode() { return id.hashCode(); }
此時因為 hashCode 方法返回的是 id 的 hash值,所以此處 k1 和 k2 這兩個對象的 hash 值就變得相等了。
但是問題還沒有結束,我們再來更正一下存 k1 和 取 k2 的動作。存 k1 時,是根據它 id 的 hash 值,假設這里是 103,把 k1 對象放入到對應的位置。而取 k2 時,是先計算它的 hash 值(由於 k2 的 id 也是 1,這個值也是 103),隨后到這個位置去找。但運行結果還是會出乎我們意料:
map.get(k2) : null
明明 103號位置已經有 k1,但打印輸出結果依然是 null。
其實原因就是沒有重寫 HashMapKey 對象的 equals 方法。
HashMap 是用鏈地址法來處理沖突,也就是說,在 103號位置上,有可能存在着多個用鏈表形式存儲的對象。它們通過 hashCode 方法返回的 hash 值都是 103。
當我們通過 k2 的 hashCode 到 103號位置查找時,確實會得到 k1。但 k1 有可能僅僅是和 k2 具有相同的 hash值,但未必和 k2 相等,這個時候,就需要調用 HashMapKey 對象的 equals 方法來判斷兩者是否相等了。
由於我們在 HashMapKey 對象里沒有定義 equals 方法,系統就不得不調用 Object 類的 equals 方法,同理由於 Object 的固有方法是根據兩個對象的內存地址來判斷,所以 k1 和 k2 一定不會相等,這就是為什么通過 map.get(k2) 依然得到 null 的原因。
為了解決這個問題,我們繼續重寫 equals 方法,在這個方法里,只要兩個對象都是 Key 類型,而且它們的 id 相等,它們就相等。
@Override public boolean equals(Object o) { if (o == null || !(o instanceof HashMapKey)) { return false; } else { return this.getId().equals(((HashMapKey) o).getId()); } }
至此,問題已經解決。
總結
我們平時在項目開發中經常會用到 HashMap,雖然很多時候我們都會盡可能避免去在鍵值存放自定義對象,但是正因為如此,一旦碰到需要存放自定義對象了就容易出問題,重申一遍:如果你需要要在 HashMap 的“鍵”部分存放自定義的對象,一定要重寫 equals 和 hashCode 方法。
其實 這個問題本身不難,只要我們平時稍微注意以下就可以避免,本文也是大概總結了以下,避免大家以后碰到了踩坑,希望對你有所幫助,保不齊下次面試也有人問你同樣的問題。