HashMap踩坑實錄——誰動了我的乳酪
說到HashMap
,hashCode
和 equals
,想必絕大多數人都不會陌生,然而你真的瞭解這它們的機制麼?本文將通過一個簡單的Demo還原我自己前不久在 HashMap
上導致的線上問題,看看我是如何跳進這個坑裡去的。
起因
在重構一段舊程式碼的時候發現有個 HashMap
的key物件沒有重寫 hashCode
和 equals
方法,使用IDEA自動重構工具生成後引發線上問題,因為實際重構的舊程式碼複雜,所以我抽出了一個關於乳酪(Cheese)的Demo還原踩坑場景,看看究竟誰動了我的乳酪
。
一個乳酪的例子
首先,我們有一個乳酪(Cheese)類
/** * @author nauyus */ public class Cheese { /** * 大小 */ private Integer size; /** * 價格 */ private BigDecimal price; /** * 製造者 */ private String creator; //節約篇幅省略get/set/構造方法 }
然後,我們製造一個乳酪並且把它放到 HashMap
中去
Cheese cheese = new Cheese(7, new BigDecimal(20), "nauyus");
Map<Cheese, String> map = new HashMap<>();
map.put(cheese, "something not important");
好了,這時候我收到了阿里程式碼掃描外掛的嚴正警告:如果自定義物件做為Map的鍵,那麼必須重寫hashCode和equals。
看到此警告,加上自己從前的經驗,那當然就是改啊,開啟Cheese類 Command+N
迅速生成程式碼然後add,commit,push一氣呵成,然後,釋出後線上出現了一個大BUG……
HashMap原理淺析
拋開BUG原因,我們先想一想為什麼程式設計規約中強制要求了關於 hashCode
和 equals
的如下規則?
這要簡單說下 HashMap
原理, HashMap
底層資料結構為在 jdk1.7 中為陣列+連結串列, jdk1.8 中為陣列+連結串列+紅黑樹,大概就長這個樣子:
然後我們看看 HashMap
如何將資料存入又如何取出的。
首先看下 put
方法
/** * Implements Map.put and related methods. * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> 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<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
具體細節可以仔細閱讀原始碼,簡單說來,就是首先對 key 進行 hash
計算,hash
是一個 int 型別的本地方法,也就將 key 的 hashCode
無符號右移16位然後與 hashCode
異或從而得到 hash
值,在 putVal
方法中 (n - 1)& hash
計算得到陣列的索引位置
,如果位置無衝突,則直接將 value 放入陣列中對應位置,如果存在衝突,則使用 equals
方法判斷 key 是否為同一物件,同一物件則覆蓋,不同物件則將 value 掛到連結串列或紅黑樹上。
然後再看看 get
方法
/**
* Implements Map.get and related methods.
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
同樣的,get
也是通過 first = tab[(n - 1) & hash]
計算出位置然後再決定是否從連結串列或紅黑樹中進行查詢,過程中同樣用到了 equals
方法。
總結一下,put
方法使用基於 hashCode
的 hash
方法得到下標位置,但是不同物件 hash
可能相同,即存在 hash碰撞
的可能,所以需要 equals
方法進一步判斷是否為同一物件,get
方法同樣使用 hash
方法得到下標位置,再根據 equals
方法確定是否取出該物件。
誰動了我的乳酪
如果我們的自定義物件沒有覆寫 hashCode
和 equals
,則會使用父類Object的方法,原始碼如下:
public native int hashCode;
public boolean equals(Object obj) {
return (this == obj);
}
hashCode
是個本地方法,和記憶體地址有關係,而預設的 equals
內部實現就是 "=="
運算子,這就會導致一個結果,值相同物件的 hashCode
並不同,並且 equals
方法返回 false
。所以程式設計規約強制要求如果自定義物件做為Map的鍵,那麼必須重寫hashCode和equals。
(敲黑板,這段話第二次出現),沒毛病!
如果沒有覆寫父類方法,下面的程式 cheese 值雖相同,但 put
乳酪後無法 get
到,乳酪被動了
!
Cheese cheese= new Cheese(7, new BigDecimal(20), "nauyus");
Map<Cheese, String> map = new HashMap<>();
map.put(cheese, "something not important");
cheese = new Cheese(7, new BigDecimal(20), "nauyus");
//沒有覆寫hashCode和equals時雖然cheese值相同,但輸出為null
System.out.println(map.get(cheese));
誰又動了我的乳酪
好了,現在我們知道了如果自定義物件做為Map的鍵,那麼必須重寫hashCode和equals。
(重要的事情說三遍!),那有人問我重寫後的產生的BUG後是怎麼回事? 還原了下場景應該是這樣的,我重寫了 hashCode
和 equals
,但是千不該萬不該忽略了原有程式碼很多行後還有一行程式碼,做成Demo後大概是這樣的:
Cheese cheese= new Cheese(7, new BigDecimal(20), "nauyus");
Map<Cheese, String> map = new HashMap<>();
map.put(cheese, "something not important");
//一段被我忽略的程式碼
cheese.setCreator("tom");
System.out.println(map.get(cheese));
在 put
到 HashMap
後,作為 key 的 cheese 物件再次被 set 了值,導致 hashCode
返回結果有了變更,put
乳酪後無法 get
到,乳酪再一次被動了
!
總結
總結一下踩坑經歷,可以得出以下結論:
如果自定義物件做為Map的鍵,那麼必須重寫hashCode和equals。 儘量使用 不可變物件
作為map的鍵,如String。即使萬分自信的程式碼,還是跑一下單元測試為好。(血的教訓)
還有啊,
沒事還是少瞎改別人程式碼吧!
感謝閱讀,原創不易,如有啟發,點個贊吧!這將是我寫作的最強動力!本文不同步釋出於不止於技術的技術公眾號
Nauyus
,主要分享一些程式語言,架構設計,思維認知類文章, 2019年12月起開啟周更模式,歡迎關注,共同學習成長!