HashMap引發死鏈問題(HashMap、ConcurrentHashMap原理解析)

事故背景

一個CPU使用率飆升至100%的線上故障,原因是在併發情況下使用HashMap導致死循環。
當cpu使用率100%時,查看堆棧,發現程序都卡在了HashMap.get()這個方法上了,重啓程序後問題消失。但是過段時間又會來。

HashMap結構

HashMap 是我們經常會用到的集合類,JDK 1.7 之前底層使用了數組加鏈表的組合結構,如下圖所示:
在這裏插入圖片描述

HashMap通常會用一個指針數組(假設爲table[])來做分散所有的key,當一個key被加入時,會通過Hash算法通過key算出這個數組的下標i,然後就把這個<key, value>插到table[i]中,如果有兩個不同的key被算在了同一個i,那麼就叫衝突,又叫碰撞,這樣會在table[i]上形成一個鏈表。

如果table[]的尺寸很小,比如只有2個,如果要放進10個keys的話,那麼碰撞非常頻繁,於是一個O(1)的查找算法,就變成了鏈表遍歷,性能變成了O(n),這是Hash表的缺陷。

所以,Hash表的尺寸和容量非常的重要。一般來說,Hash表這個容器當有數據要插入時,都會檢查容量有沒有超過設定的thredhold,如果超過,需要增大Hash表的尺寸,但是這樣一來,整個Hash表裏的無素都需要被重算一遍。這叫rehash,這個成本相當的大。


JDK 1.7 HashMap的rehash源代碼

Put一個Key,Value對到Hash表中:

public V put(K key, V value)
{
    ......
    //算Hash值
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    //如果該key已被插入,則替換掉舊的value (鏈接操作)
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    //該key不存在,需要增加一個結點
    addEntry(hash, key, value, i);
    return null;
}

檢查容量是否超標

void addEntry(int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    //查看當前的size是否超過了我們設定的閾值threshold,如果超過,需要resize
    if (size++ >= threshold)
        resize(2 * table.length);
} 

新建一個更大尺寸(2倍)的hash表,然後把數據從老的Hash表中遷移到新的Hash表中。

void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //創建一個新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //將Old Hash Table上的數據遷移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

遷移的源代碼:

void transfer(Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面這段代碼的意思是:
    //  從OldTable裏摘一個元素出來,然後放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
} 

舊數組元素遷移到新數組時,依舊採用頭插入法,這樣將會導致新鏈表元素的逆序排序。

多線程併發擴容的情況下,鏈表可能形成死鏈(環形鏈表)。一旦有任何查找元素的動作,線程將會陷入死循環,從而引發 CPU 使用率飆升。

在這裏插入圖片描述


JDK1.8 改進方案

JDK1.8 HashMap 底層結構進行徹底重構,使用數組鏈表/紅黑樹方式這種組合結構。

在這裏插入圖片描述
新元素依舊通過取模方式獲取 Table 數組位置,然後再將元素加入鏈表尾部。一旦鏈表元素數量超過 8 之後,自動轉爲紅黑樹,進一步提高了查找效率。

由於 JDK1.8 鏈表採用尾插入法,從而避免併發擴容情況下鏈表形成死鏈的可能。

雖然JDK1.8能避免併發擴容情況下的死鏈問題,但是HashMap仍然不適合用於併發場景。(併發賦值時被覆蓋size 計算問題


ConcurrentHashMap

JDK1.7 ConcurrentHashMap結構

JDK1.7 ConcurrentHashMap 數據結構如下所示:

在這裏插入圖片描述

Segament 是一個ConcurrentHashMap內部類,底層結構與 HashMap 一致。另外Segament 繼承自 ReentrantLock

當新元素加入 ConcurrentHashMap 時,首先根據 key hash 值找到相應的 Segament。接着直接對 Segament 上鎖,若獲取成功,後續操作步驟如同 HashMap

由於鎖的存在,Segament 內部操作都是併發安全,同時由於其他 Segament 未被佔用,因此可以支持 concurrencyLevel 個線程安全的併發讀寫。

size 統計問題

雖然 ConcurrentHashMap 引入分段鎖解決多線程併發的問題,但是同時引入新的複雜度,導致計算 ConcurrentHashMap 元素數量將會變得複雜。

由於 ConcurrentHashMap 元素實際分佈在 Segament 中,爲了統計實際數量,只能遍歷 Segament數組求和。

爲了數據的準確性,這個過程過我們需要鎖住所有的 Segament,計算結束之後,再依次解鎖。不過這樣做,將會導致寫操作被阻塞,一定程度降低 ConcurrentHashMap性能。
所以這裏對 ConcurrentHashMap#size 統計方法進行一定的優化。

Segment 每次被修改(寫入,刪除),都會對 modCount(更新次數)加 1。只要相鄰兩次計算獲取所有的 Segment modCount 總和一致,則代表兩次計算過程並無寫入或刪除,可以直接返回統計數量。
如果三次計算結果都不一致,那沒辦法只能對所有 Segment 加鎖,重新計算結果。
這裏需要注意的是,這裏求得 size 數量不能做到 100% 準確。這是因爲最後依次對 Segment 解鎖後,可能會有其他線程進入寫入操作。這樣就導致返回時的數量與實際數不一致。
不過這也能被接受,總不能因爲爲了統計元素停止所有元素的寫入操作。

性能問題

想象一種極端情況的,所有寫入都落在同一個 Segment中,這就導致ConcurrentHashMap 退化成 SynchronizedMap,共同搶一把鎖。

JDK1.8 改進方案

JDK1.8 之後,ConcurrentHashMap 取消了分段鎖的設計,進一步減鎖衝突的發生。另外也引入紅黑樹的結構,進一步提高查找效率。

數據結構如下所示:

在這裏插入圖片描述
Table 數組的中每一個 Node 我們都可以看做一把鎖,這就避免了 Segament 退化問題。

另外一旦 ConcurrentHashMap 擴容, Table 數組元素變多,鎖的數量也會變多,併發度也會提高。

JDK1.8 使用 CAS 方法加 synchronized 方式,保證併發安全。


總結

  • HashMap 在多線程併發的過程中存在死鏈與丟失數據的可能,不適合用於多線程併發使用的場景的,我們可以在方法的局部變量中使用。

  • SynchronizedMap 雖然線程安全,但是由於鎖粒度太大,導致性能太低,所以也不太適合在多線程使用。

  • ConcurrentHashMap 由於使用多把鎖,充分降低多線程併發競爭的概率,提高了併發度,非常適合在多線程中使用。ConcurrentHashMap 分段鎖的經典思想,我們可以應用在熱點更新的場景,提高更新效率。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章