事故背景
一個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
分段鎖的經典思想,我們可以應用在熱點更新的場景,提高更新效率。