佔小狼 轉載請註明原創出處,謝謝!面試
最近的幾回面試中,我都問了是否瞭解HashMap在併發使用時可能發生死循環,致使cpu100%,結果讓我很意外,都表示不知道有這樣的問題,讓我意外的是面試者的工做年限都不短。算法
因爲HashMap並不是是線程安全的,因此在高併發的狀況下必然會出現問題,這是一個廣泛的問題,雖然網上分析的文章不少,仍是以爲有必須寫一篇文章,讓關注我公衆號的同窗可以意識到這個問題,並瞭解這個死循環是如何產生的。數組
若是是在單線程下使用HashMap,天然是沒有問題的,若是後期因爲代碼優化,這段邏輯引入了多線程併發執行,在一個未知的時間點,會發現CPU佔用100%,居高不下,經過查看堆棧,你會驚訝的發現,線程都Hang在hashMap的get()方法上,服務重啓以後,問題消失,過段時間可能又復現了。安全
這是爲何?bash
在瞭解前因後果以前,咱們先看看HashMap的數據結構。數據結構
在內部,HashMap使用一個Entry數組保存key、value數據,當一對key、value被加入時,會經過一個hash算法獲得數組的下標index,算法很簡單,根據key的hash值,對數組的大小取模 hash & (length-1),並把結果插入數組該位置,若是該位置上已經有元素了,就說明存在hash衝突,這樣會在index位置生成鏈表。多線程
若是存在hash衝突,最慘的狀況,就是全部元素都定位到同一個位置,造成一個長長的鏈表,這樣get一個值時,最壞狀況須要遍歷全部節點,性能變成了O(n),因此元素的hash值算法和HashMap的初始化大小很重要。併發
當插入一個新的節點時,若是不存在相同的key,則會判斷當前內部元素是否已經達到閾值(默認是數組大小的0.75),若是已經達到閾值,會對數組進行擴容,也會對鏈表中的元素進行rehash。高併發
HashMap的put方法實現:性能
一、判斷key是否已經存在
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);
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;
}
複製代碼
二、檢查容量是否達到閾值threshold
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
複製代碼
若是元素個數已經達到閾值,則擴容,並把原來的元素移動過去。
三、擴容實現
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
...
Entry[] newTable = new Entry[newCapacity];
...
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
複製代碼
這裏會新建一個更大的數組,並經過transfer方法,移動元素。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
複製代碼
移動的邏輯也很清晰,遍歷原來table中每一個位置的鏈表,並對每一個元素進行從新hash,在新的newTable找到歸宿,並插入。
假設HashMap初始化大小爲4,插入個3節點,不巧的是,這3個節點都hash到同一個位置,若是按照默認的負載因子的話,插入第3個節點就會擴容,爲了驗證效果,假設負載因子是1.
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
複製代碼
以上是節點移動的相關邏輯。
插入第4個節點時,發生rehash,假設如今有兩個線程同時進行,線程1和線程2,兩個線程都會新建新的數組。
假設 線程2 在執行到Entry<K,V> next = e.next;
以後,cpu時間片用完了,這時變量e指向節點a,變量next指向節點b。
線程1繼續執行,很不巧,a、b、c節點rehash以後又是在同一個位置7,開始移動節點
第一步,移動節點a
第二步,移動節點b
注意,這裏的順序是反過來的,繼續移動節點c
這個時候 線程1 的時間片用完,內部的table尚未設置成新的newTable, 線程2 開始執行,這時內部的引用關係以下:
這時,在 線程2 中,變量e指向節點a,變量next指向節點b,開始執行循環體的剩餘邏輯。
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
複製代碼
執行以後的引用關係以下圖
執行後,變量e指向節點b,由於e不是null,則繼續執行循環體,執行後的引用關係
變量e又從新指回節點a,只能繼續執行循環體,這裏仔細分析下: 一、執行完Entry<K,V> next = e.next;
,目前節點a沒有next,因此變量next指向null; 二、e.next = newTable[i];
其中 newTable[i] 指向節點b,那就是把a的next指向了節點b,這樣a和b就相互引用了,造成了一個環; 三、newTable[i] = e
把節點a放到了數組i位置; 四、e = next;
把變量e賦值爲null,由於第一步中變量next就是指向null;
因此最終的引用關係是這樣的:
節點a和b互相引用,造成了一個環,當在數組該位置get尋找對應的key時,就發生了死循環。
另外,若是線程2把newTable設置成到內部的table,節點c的數據就丟了,看來還有數據遺失的問題。
因此在併發的狀況,發生擴容時,可能會產生循環鏈表,在執行get的時候,會觸發死循環,引發CPU的100%問題,因此必定要避免在併發環境下使用HashMap。
曾經有人把這個問題報給了Sun,不過Sun不認爲這是一個bug,由於在HashMap原本就不支持多線程使用,要併發就用ConcurrentHashmap。
END。 我是佔小狼。 若是讀完以爲有收穫的話,記得關注和點贊