1. 程式人生 > >HashMap踩坑實錄——誰動了我的乳酪

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