天天看點

為什麼不建議使用自定義Object作為HashMap的key?

此前部門内的一個線上系統上線後記憶體一路飙高、一段時間後直接占滿。協助開發人員去分析定位,發現記憶體中某個Object的量遠遠超出了預期的範圍,很明顯出現記憶體洩漏了。

結合代碼分析發現,洩漏的這個對象,主要存在一個全局HashMap中,是作為HashMap的Key值。第一反應就是這裡key對應類沒有去覆寫equals()和hashCode()方法,但對照代碼仔細一看卻發現其實已經按要求提供了自定義的equals和hashCode方法了。進一步走讀業務實作邏輯,才發現了其中的玄機。

踩坑曆程回顧

鑒于項目代碼相對保密,這裡舉個簡單的DEMO來輔助說明下。

場景:

記憶體中建構一個

HashMap<User, List<Post>>

映射集,用于存儲每個使用者最近的發帖資訊(隻是個例子,實際工作中如果遇到這種使用者發帖緩存的場景,一般都是用的集中緩存,而不是單機緩存)。

使用者資訊User類定義如下:

@Data
public class User {
    // 使用者名稱
    private String userName;
    // 賬号ID
    private String accountId;
    // 使用者上次登入時間,每次登入的時候會自動更新DB對應時間
    private long lastLoginTime;
    // 其他字段,忽略

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return lastLoginTime == user.lastLoginTime &&
                Objects.equals(userName, user.userName) &&
                Objects.equals(accountId, user.accountId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(userName, accountId, lastLoginTime);
    }
}

           

實際使用的時候,使用者發帖之後,會将這個文章資訊添加到使用者對應的緩存中。

/**
 *  将發帖資訊加入到使用者緩存中
 *
 * @param currentUser 目前使用者
 * @param postContent 文章資訊
 */
public void addCache(User currentUser, Post postContent) {
    cache.computeIfAbsent(currentUser, k -> new ArrayList<>()).add(postContent);
}

           

當實際運作的時候,會發現問題就來了,Map中的記錄越來越多,遠超系統内實際的使用者數量。為什麼呢?仔細看下User類就可以知道了!

原來編碼的時候直接用IDE工具自動生成的equals和hashCode方法,裡面将lastLoginTime也納入計算邏輯了。這樣每次使用者重新登入之後,對應hashCode值也就變了,這樣發帖的時候判斷使用者是不存在Map中的,就會再往map中插入一條,随着時間的推移,記憶體中資料就會越來越多,導緻記憶體洩漏。

這麼一看,其實問題很簡單。但是實際編碼的時候,很多人往往又會忽略這些細節、或者當時可能沒有這個場景,後面維護的人新增了點邏輯,就會出問題 —— 說白了,就是埋了個坑給後面的人踩上了。

hashCode覆寫的講究

hashCode,即一個Object的散列碼。HashCode的作用:

  • 對于List、數組等集合而言,HashCode用途不大;
  • 對于HashMap\HashTable\HashSet等集合而言,HashCode有很重要的價值。

HashCode在上述HashMap等容器中主要是用于尋域,即尋找某個對象在集合中的區域位置,用于提升查詢效率。

一個Object對象往往會存在多個屬性字段,而選擇什麼屬性來計算hashCode值,具有一定的考驗:

  • 如果選擇的字段太多,而HashCode()在程式執行中調用的非常頻繁,勢必會影響計算性能;
  • 如果選擇的太少,計算出來的HashCode勢必很容易就會出現重複了。

為什麼hashCode和equals要同時覆寫

這就與HashMap的底層實作邏輯有關系了。

對于JDK1.8+版本中,HashMap底層的資料結構形如下圖所示,使用數組+連結清單或者紅黑樹的結構形式:

為什麼不建議使用自定義Object作為HashMap的key?

給定key進行查詢的時候,分為2步:

  1. 調用key對象的hashCode()方法,擷取hashCode值,然後換算為對應數組的下标,找到對應下标位置;
  2. 根據hashCode找到的數組下标可能會同時對應多個key(所謂的hash碰撞,不同元素産生了相同的hashCode值),這個時候使用key對象提供的equals()方法,進行逐個元素比對,直到找到相同的元素,傳回其所對應的值。

根據上面的介紹,可以概括為:

  • hashCode負責大概定位,先定位到對應片區
  • equals負責在定位的片區内,精确找到預期的那一個

這裡也就明白了為什麼hashCode()和equals()需要同時覆寫。

資料退出機制的兜底

其實,說到這裡,全局Map出現記憶體洩漏,還有一點就是編碼實作的時候缺少對資料退出機制的考慮。

參考下redis之類的依賴記憶體的緩存中間件,都有一個繞不開的兜底政策,即資料淘汰機制。

對于業務類編碼實作的時候,如果使用Map等容器類來實作全局緩存的時候,應該要結合實際部署情況,确定記憶體中允許的最大資料條數,并提供超出指定容量時的處理政策。比如我們可以基于LinkedHashMap來定制一個基于LRU政策的緩存Map,來保證記憶體資料量不會無限制增長,這樣即使代碼出問題也隻是這一個功能點出問題,不至于讓整個程序當機。

public class FixedLengthLinkedHashMap<K, V> extends LinkedHashMap<K, V> {
    private static final long serialVersionUID = 1287190405215174569L;
    private int maxEntries;

    public FixedLengthLinkedHashMap(int maxEntries, boolean accessOrder) {
        super(16, 0.75f, accessOrder);
        this.maxEntries = maxEntries;
    }
    
    /**
     *  自定義資料淘汰觸發條件,在每次put操作的時候會調用此方法來判斷下
     */
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxEntries;
    }
}

           

總結

梳理下幾個要點:

  • 最好不要使用Object作為HashMap的Key
  • 如果不得已必須要使用,除了要覆寫equals和hashCode方法
  • 覆寫的equals和hashCode方法中一定不能有頻繁易變更的字段
  • 記憶體緩存使用的Map,最好對Map的資料記錄條數做一個強制限制,提供下資料淘汰政策。

好啦,關于這個問題的分享就到這裡咯,你是否有在工作中遇到此類相同或者相似的問題呢?歡迎一起分享讨論下哦~

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點個關注,也可以關注下我的公衆号【架構悟道】,擷取更及時的更新。

期待與你一起探讨,一起成長為更好的自己。

本文來自部落格園,作者:架構悟道,歡迎關注公衆号[架構悟道]持續擷取更多幹貨,轉載請注明原文連結:https://www.cnblogs.com/softwarearch/p/16423496.html