一、HashMap HashMap 是線程不安全的。 JDK 1.7 HashMap 採用數組 + 鏈表的數據結構,多線程背景下,在數組擴容的時候,存在 Entry 鏈死迴圈和數據丟失問題。 JDK 1.8 HashMap 採用數組 + 鏈表 + 紅黑二叉樹的數據結構,優化了 1.7 中數組擴容的方 ...
一、HashMap
HashMap 是線程不安全的。
JDK 1.7 HashMap 採用數組 + 鏈表的數據結構,多線程背景下,在數組擴容的時候,存在 Entry 鏈死迴圈和數據丟失問題。
JDK 1.8 HashMap 採用數組 + 鏈表 + 紅黑二叉樹的數據結構,優化了 1.7 中數組擴容的方案,解決了 Entry 鏈死迴圈和數據丟失問題。但是多線程背景下,put 方法存在數據覆蓋的問題。
1.7 中擴容引發的線程不安全分析
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;
}
}
}
這段代碼是 HashMap 的擴容操作,重新定位每個桶的下標,並採用頭插法將元素遷移到新數組中。頭插法會將鏈表的順序翻轉,這也是形成死迴圈的關鍵點。
假設現在有兩個線程A、B同時對下麵這個 HashMap 進行擴容操作:
正常擴容後的結果是下麵這樣的:
但是當線程A執行到上面 transfer 函數的第11行代碼時,CPU 時間片耗盡,線程A被掛起。即如下圖中位置所示:
此時線程A中:e=3、next=7、e.next=null
當線程A的時間片耗盡後,CPU 開始執行線程B,併在線程B中成功的完成了數據遷移:
重點來了,根據 Java 記憶體模式可知,線程B執行完數據遷移後,此時主記憶體中 newTable 和 table 都是最新的,也就是說:7.next=3、3.next=null
隨後線程A獲得CPU時間片繼續執行 newTable[i] = e,將3放入新數組對應的位置,執行完此輪迴圈後線程A的情況如下:
接著繼續執行下一輪迴圈,此時 e=7,從主記憶體中讀取 e.next 時發現主記憶體中 7.next=3,於是乎next=3,並將 7 採用頭插法的方式放入新數組中,並繼續執行完此輪迴圈,結果如下:
執行下一次迴圈可以發現,next=e.next=null,所以此輪迴圈將會是最後一輪迴圈。接下來當執行完e.next=newTable[i]即3.next=7後,3和7之間就相互連接了,當執行完newTable[i]=e後,3被頭插法重新插入到鏈表中,執行結果如下圖所示:
上面說了此時 e.next=null 即 next=null,當執行完 e=null 後,將不會進行下一輪迴圈。到此線程A、B的擴容操作完成,很明顯當線程A執行完後,HashMap 中出現了環形結構,當在以後對該 HashMap 進行操作時會出現死迴圈。
並且從上圖可以發現,元素5在擴容期間被莫名的丟失了,這就發生了數據丟失的問題。
1.8 中 put 方法數據覆蓋問題分析
根據上面JDK1.7出現的問題,在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的源碼會發現找不到 transfer 函數,因為 JDK1.8 直接在 resize 函數中完成了數據遷移。另外說一句,JDK1.8在進行元素插入時使用的是尾插法。
為什麼說JDK1.8會出現數據覆蓋的情況喃,我們來看一下下麵這段JDK1.8中的put操作代碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 如果沒有hash碰撞則直接插入元素
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
其中第六行代碼是判斷是否出現 hash 碰撞,假設兩個線程A、B都在進行 put 操作,並且 hash 函數計算出的插入下標是相同的,當線程A 執行完第六行代碼後由於時間片耗盡導致被掛起,而線程B得到時間片後在該下標處插入了元素,完成了正常的插入,然後線程A獲得時間片,由於之前已經進行了 hash 碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了線程B插入的數據被線程A覆蓋了,從而線程不安全。
除此之前,還有就是代碼的第38行處有個 ++size,我們這樣想,還是線程A、B,這兩個線程同時進行 put 操作時,假設當前 HashMap 的zise大小為10,當線程A執行到第38行代碼時,從主記憶體中獲得size的值為10後準備進行+1操作,但是由於時間片耗盡只好讓出CPU,線程B快樂的拿到CPU還是從主記憶體中拿到size的值10進行+1操作,完成了put操作並將size=11寫回主記憶體,然後線程A再次拿到CPU並繼續執行(此時size的值仍為10),當執行完put操作後,還是將size=11寫回記憶體,此時,線程A、B都執行了一次put操作,但是size的值只增加了1,所有說還是由於數據覆蓋又導致了線程不安全。
二、HashTable
HashTable 是線程安全的。
HashTable 容器使用 synchronized 來保證線程安全,但線上程競爭激烈的情況下 HashTable 的效率非常低下。因為當一個線程訪問 HashTable 的同步方法,其他線程也訪問 HashTable 的同步方法時,會進入阻塞或輪詢狀態。如線程1使用 put 進行元素添加,線程2不但不能使用 put 方法添加元素,也不能使用 get 方法來獲取元素,所以競爭越激烈效率越低。
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
三、ConcurrentHashMap
ConcurrentHashMap 線程安全的。
JDK 1.7 ConcurrentHashMap 採用Segment數組 + HashEntry數組實現。 Segment 是一種可重入鎖(ReentrantLock),在 ConcurrentHashMap 里扮演鎖的角色;HashEntry 則用於存儲鍵值對數據。一個 ConcurrentHashMap 里包含一個 Segment 數組,一個 Segment 里包含一個 HashEntry 數組,每個 HashEntry 是一個鏈表結構的元素。
分段鎖技術將數據分成一段一段的存儲,然後給每一段數據配一把鎖,當一個線程占用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問,能夠實現真正的併發訪問。
從上面的結構我們可以瞭解到,ConcurrentHashMap 定位一個元素的過程需要進行兩次 Hash 操作。第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的鏈表的頭部。
這一種結構寫操作的時候只對元素所在的Segment進行加鎖即可,不會影響到其他的 Segment,這樣,在最理想的情況下,ConcurrentHashMap 可以最高同時支持Segment數量大小的寫操作(剛好這些寫操作都非常平均地分佈在所有的Segment上)。
這一種結構的帶來的副作用是Hash的過程要比普通的 HashMap 要長。
JDK 1.8 ConcurrentHashMap 採用數組 + 鏈表 + 紅黑樹的方式實現,結構基本上和 1.8 中的 HashMap 一樣,不過大量的利用了 volatile,final,CAS 等 lock-free 技術來減少鎖競爭對於性能的影響,從而保證線程安全性。
附錄
- 學習 util.concurrent 包,真是感慨 Doug Lea 的智慧。
- 上文 HashMap 線程不安全的分析摘抄自:https://blog.csdn.net/swpu_ocean/article/details/88917958