1. 程式人生 > >hashMap的執行緒不安全,從原始碼談起

hashMap的執行緒不安全,從原始碼談起

HashMap的原理以及如何實現,之前在JDK7與JDK8中HashMap的實現中已經說明了。

那麼,為什麼說HashMap是執行緒不安全的呢?它在多執行緒環境下,會發生什麼情況呢?

  1. resize死迴圈
    我們都知道HashMap初始容量大小為16,一般來說,當有資料要插入時,都會檢查容量有沒有超過設定的thredhold,如果超過,需要增大Hash表的尺寸,但是這樣一來,整個Hash表裡的元素都需要被重算一遍。這叫rehash,這個成本相當的大。

void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 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;
}
}
}
大概看下transfer:

對索引陣列中的元素遍歷
對連結串列上的每一個節點遍歷:用 next 取得要轉移那個元素的下一個,將 e 轉移到新 Hash 表的頭部,使用頭插法插入節點。
迴圈2,直到連結串列節點全部轉移
迴圈1,直到所有索引陣列全部轉移
經過這幾步,我們會發現轉移的時候是逆序的。假如轉移前連結串列順序是1->2->3,那麼轉移後就會變成3->2->1。這時候就有點頭緒了,死鎖問題不就是因為1->2的同時2->1造成的嗎?所以,HashMap 的死鎖問題就出在這個transfer()函式上。

1.1 單執行緒 rehash 詳細演示
單執行緒情況下,rehash 不會出現任何問題:

假設hash演算法就是最簡單的 key mod table.length(也就是陣列的長度)。
最上面的是old hash 表,其中的Hash表的 size = 2, 所以 key = 3, 7, 5,在 mod 2以後碰撞發生在 table[1]
接下來的三個步驟是 Hash表 resize 到4,並將所有的 <key,value> 重新rehash到新 Hash 表的過程
如圖所示:
在這裡插入圖片描述
1.2 多執行緒 rehash 詳細演示
為了思路更清晰,我們只將關鍵程式碼展示出來

while(null != e) {
Entry<K,V> next = e.next;
e.next = newTable[i];
newTable[i] = e;
e = next;
}
Entry<K,V> next = e.next;——因為是單鏈表,如果要轉移頭指標,一定要儲存下一個結點,不然轉移後連結串列就丟了
e.next = newTable[i];——e 要插入到連結串列的頭部,所以要先用 e.next 指向新的 Hash 表第一個元素(為什麼不加到新連結串列最後?因為複雜度是 O(N))
newTable[i] = e;——現在新 Hash 表的頭指標仍然指向 e 沒轉移前的第一個元素,所以需要將新 Hash 表的頭指標指向 e
e = next——轉移 e 的下一個結點
假設這裡有兩個執行緒同時執行了put()操作,並進入了transfer()環節

while(null != e) {
Entry<K,V> next = e.next; //執行緒1執行到這裡被排程掛起了
e.next = newTable[i];
newTable[i] = e;
e = next;
}
那麼現在的狀態為:
在這裡插入圖片描述
從上面的圖我們可以看到,因為執行緒1的 e 指向了 key(3),而 next 指向了 key(7),線上程2 rehash 後,就指向了執行緒2 rehash 後的連結串列。

然後執行緒1被喚醒了:

執行e.next = newTable[i],於是 key(3)的 next 指向了執行緒1的新 Hash 表,因為新 Hash 表為空,所以e.next = null,
執行newTable[i] = e,所以執行緒1的新 Hash 表第一個元素指向了執行緒2新 Hash 表的 key(3)。好了,e 處理完畢。
執行e = next,將 e 指向 next,所以新的 e 是 key(7)
然後該執行 key(3)的 next 節點 key(7)了:

現在的 e 節點是 key(7),首先執行Entry<K,V> next = e.next,那麼 next 就是 key(3)了
執行e.next = newTable[i],於是key(7) 的 next 就成了 key(3)
執行newTable[i] = e,那麼執行緒1的新 Hash 表第一個元素變成了 key(7)
執行e = next,將 e 指向 next,所以新的 e 是 key(3)
這時候的狀態圖為:
在這裡插入圖片描述
然後又該執行 key(7)的 next 節點 key(3)了:

現在的 e 節點是 key(3),首先執行Entry<K,V> next = e.next,那麼 next 就是 null
執行e.next = newTable[i],於是key(3) 的 next 就成了 key(7)
執行newTable[i] = e,那麼執行緒1的新 Hash 表第一個元素變成了 key(3)
執行e = next,將 e 指向 next,所以新的 e 是 key(7)
這時候的狀態如圖所示:
在這裡插入圖片描述
很明顯,環形連結串列出現了!!當然,現在還沒有事情,因為下一個節點是 null,所以transfer()就完成了,等put()的其餘過程搞定後,HashMap 的底層實現就是執行緒1的新 Hash 表了。
在這裡插入圖片描述
2. fail-fast
如果在使用迭代器的過程中有其他執行緒修改了map,那麼將丟擲ConcurrentModificationException,這就是所謂fail-fast策略。

這個異常意在提醒開發者及早意識到執行緒安全問題,具體原因請檢視ConcurrentModificationException的原因以及解決措施

順便再記錄一個HashMap的問題:

為什麼String, Interger這樣的wrapper類適合作為鍵? String, Interger這樣的wrapper類作為HashMap的鍵是再適合不過了,而且String最為常用。因為String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因為為了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那麼就不能從HashMap中找到你想要的物件。不可變性還有其他的優點如執行緒安全。如果你可以僅僅通過將某個field宣告成final就能保證hashCode是不變的,那麼請這麼做吧。因為獲取物件的時候要用到equals()和hashCode()方法,那麼鍵物件正確的重寫這兩個方法是非常重要的。如果兩個不相等的物件返回不同的hashcode的話,那麼碰撞的機率就會小些,這樣就能提高HashMap的效能。

Reference:

  1. http://hwl-sz.iteye.com/blog/1897468?utm_source=tuicool&utm_medium=referral

  2. http://github.thinkingbar.com/hashmap-infinite-loop/

  3. http://www.importnew.com/7099.html