說到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月起開啟周更模式,歡迎關注,共同學習成長!
