HashMap踩坑實錄——誰動了我的奶酪


說到HashMaphashCodeequals ,想必絕大多數人都不會陌生,然而你真的了解這它們的機制么?本文將通過一個簡單的Demo還原我自己前不久在 HashMap 上導致的線上問題,看看我是如何跳進這個坑里去的。

起因

在重構一段舊代碼的時候發現有個 HashMap 的key對象沒有重寫 hashCodeequals 方法,使用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原因,我們先想一想為什么編程規約中強制要求了關於 hashCodeequals 的如下規則?

這要簡單說下 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 方法使用基於 hashCodehash 方法得到下標位置,但是不同對象 hash 可能相同,即存在 hash碰撞 的可能,所以需要 equals 方法進一步判斷是否為同一對象,get 方法同樣使用 hash 方法得到下標位置,再根據 equals 方法確定是否取出該對象。

誰動了我的奶酪

如果我們的自定義對象沒有覆寫 hashCodeequals ,則會使用父類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后是怎么回事? 還原了下場景應該是這樣的,我重寫了 hashCodeequals ,但是千不該萬不該忽略了原有代碼很多行后還有一行代碼,做成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));

putHashMap 后,作為 key 的 cheese 對象再次被 set 了值,導致 hashCode 返回結果有了變更,put 奶酪后無法 get 到,奶酪再一次被動了

總結

總結一下踩坑經歷,可以得出以下結論:

  1. 如果自定義對象做為Map的鍵,那么必須重寫hashCode和equals。
  2. 盡量使用 不可變對象作為map的鍵,如String。
  3. 即使萬分自信的代碼,還是跑一下單元測試為好。(血的教訓)

還有啊,

沒事還是少瞎改別人代碼吧!

感謝閱讀,原創不易,如有啟發,點個贊吧!這將是我寫作的最強動力!本文不同步發布於不止於技術的技術公眾號 Nauyus ,主要分享一些編程語言,架構設計,思維認知類文章, 2019年12月起開啟周更模式,歡迎關注,共同學習成長!


免責聲明!

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



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