HashMap多線程並發問題分析
多線程put后可能導致get死循環
從前我們的Java代碼因為一些原因使用了HashMap這個東西,但是當時的程序是單線程的,一切都沒有問題。后來,我們的程序性能有問題,所以需要變成多線程的,於是,變成多線程后到了線上,發現程序經常占了100%的CPU,查看堆棧,你會發現程序都Hang在了HashMap.get()這個方法上了,重啟程序后問題消失。但是過段時間又會來。而且,這個問題在測試環境里可能很難重現。
我們簡單的看一下我們自己的代碼,我們就知道HashMap被多個線程操作。而Java的文檔說HashMap是非線程安全的,應該用ConcurrentHashMap。但是在這里我們可以來研究一下原因。簡單代碼如下:
public class TestLock { private HashMap map = new HashMap(); public TestLock() { Thread t1 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t1 over"); } }; Thread t2 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t2 over"); } }; Thread t3 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t3 over"); } }; Thread t4 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t4 over"); } }; Thread t5 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.put(new Integer(i), i); } System.out.println("t5 over"); } }; Thread t6 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t6 over"); } }; Thread t7 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t7 over"); } }; Thread t8 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t8 over"); } }; Thread t9 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t9 over"); } }; Thread t10 = new Thread() { public void run() { for (int i = 0; i < 50000; i++) { map.get(new Integer(i)); } System.out.println("t10 over"); } }; t1.start(); t2.start(); t3.start(); t4.start(); t5.start(); t6.start(); t7.start(); t8.start(); t9.start(); t10.start(); } public static void main(String[] args) { new TestLock(); } }
就是啟了10個線程,不斷的往一個非線程安全的HashMap中put內容/get內容,put的內容很簡單,key和value都是從0自增的整數(這個put的內容做的並不好,以致於后來干擾了我分析問題的思路)。對HashMap做並發寫操作,我原以為只不過會產生臟數據的情況,但反復運行這個程序,會出現線程t1、t2被hang住的情況,多數情況下是一個線程被hang住另一個成功結束,偶爾會10個線程都被hang住。
產生這個死循環的根源在於對一個未保護的共享變量 — 一個"HashMap"數據結構的操作。當在所有操作的方法上加了"synchronized"后,一切恢復了正常。這算jvm的bug嗎?應該說不是的,這個現象很早以前就報告出來了。Sun的工程師並不認為這是bug,而是建議在這樣的場景下應采用"ConcurrentHashMap”,
CPU利用率過高一般是因為出現了出現了死循環,導致部分線程一直運行,占用cpu時間。問題原因就是HashMap是非線程安全的,多個線程put的時候造成了某個key值Entry key List的死循環,問題就這么產生了。
當另外一個線程get 這個Entry List 死循環的key的時候,這個get也會一直執行。最后結果是越來越多的線程死循環,最后導致服務器dang掉。我們一般認為HashMap重復插入某個值的時候,會覆蓋之前的值,這個沒錯。但是對於多線程訪問的時候,由於其內部實現機制(在多線程環境且未作同步的情況下,對同一個HashMap做put操作可能導致兩個或以上線程同時做rehash動作,就可能導致循環鍵表出現,一旦出現線程將無法終止,持續占用CPU,導致CPU使用率居高不下),就可能出現安全問題了。
使用jstack工具dump出問題的那台服務器的棧信息。死循環的話,首先查找RUNNABLE的線程,找到問題代碼如下:
java.lang.Thread.State:RUNNABLE at java.util.HashMap.get(HashMap.java:303) at com.sohu.twap.service.logic.TransformTweeter.doTransformTweetT5(TransformTweeter.java:183) 共出現了23次。 java.lang.Thread.State:RUNNABLE at java.util.HashMap.put(HashMap.java:374) at com.sohu.twap.service.logic.TransformTweeter.transformT5(TransformTweeter.java:816) 共出現了3次。
注意:不合理使用HashMap導致出現的是死循環而不是死鎖。
多線程put的時候可能導致元素丟失
主要問題出在addEntry方法的new Entry (hash, key, value, e),如果兩個線程都同時取得了e,則他們下一個元素都是e,然后賦值給table元素的時候有一個成功有一個丟失。
put非null元素后get出來的卻是null
在transfer方法中代碼如下:
void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { Entry e = src[j]; if (e != null) { src[j] = null; do { Entry next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } }
在這個方法里,將舊數組賦值給src,遍歷src,當src的元素非null時,就將src中的該元素置null,即將舊數組中的元素置null了,也就是這一句:
if (e != null) { src[j] = null;
關於HashMap線程不安全這一點,《Java並發編程的藝術》一書中是這樣說的:
HashMap在並發執行put操作時會引起死循環,導致CPU利用率接近100%。因為多線程會導致HashMap的Node鏈表形成環形數據結構,一旦形成環形數據結構,Node的next節點永遠不為空,就會在獲取Node時產生死循環。
哇塞,聽上去si不si好神奇,居然會產生死循環。。。。google了一下,才知道死循環並不是發生在put操作時,而是發生在擴容時。詳細的解釋可以看下面幾篇博客:
HashMap數據結構
我需要簡單地說一下HashMap這個經典的數據結構。
HashMap通常會用一個指針數組(假設為table[])來做分散所有的key,當一個key被加入時,會通過Hash算法通過key算出這個數組的下標i,然后就把這個 插到table[i]中,如果有兩個不同的key被算在了同一個i,那么就叫沖突,又叫碰撞,這樣會在table[i]上形成一個鏈表。
我們知道,如果table[]的尺寸很小,比如只有2個,如果要放進10個keys的話,那么碰撞非常頻繁,於是一個O(1)的查找算法,就變成了鏈表遍歷,性能變成了O(n),這是Hash表的缺陷。
所以,Hash表的尺寸和容量非常的重要。一般來說,Hash表這個容器當有數據要插入時,都會檢查容量有沒有超過設定的thredhold,如果超過,需要增大Hash表的尺寸,但是這樣一來,整個Hash表里的元素都需要被重算一遍。這叫rehash,這個成本相當的大。
HashMap的rehash源代碼
下面,我們來看一下Java的HashMap的源代碼。Put一個Key,Value對到Hash表中:
新建一個更大尺寸的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); } } }
好了,這個代碼算是比較正常的。而且沒有什么問題。
正常的ReHash過程
畫了個圖做了個演示。
- 我假設了我們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。
- 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都沖突在table1這里了。
- 接下來的三個步驟是Hash表 resize成4,然后所有的 重新rehash的過程。
並發的Rehash過程
(1)假設我們有兩個線程。我用紅色和淺藍色標注了一下。我們再回頭看一下我們的 transfer代碼中的這個細節:
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);
而我們的線程二執行完成了。於是我們有下面的這個樣子。
注意:因為Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash后,指向了線程二重組后的鏈表。我們可以看到鏈表的順序被反轉后。
(2)線程一被調度回來執行。
- 先是執行 newTalbe[i] = e。
- 然后是e = next,導致了e指向了key(7)。
- 而下一次循環的next = e.next導致了next指向了key(3)。
(3)一切安好。
線程一接着工作。把key(7)摘下來,放到newTable[i]的第一個,然后把e和next往下移。
(4)環形鏈接出現。
e.next = newTable[i] 導致 key(3).next 指向了 key(7)。注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。
於是,當我們的線程一調用到,HashTable.get(11)時,悲劇就出現了——Infinite Loop。
三種解決方案
Hashtable替換HashMap
Hashtable 是同步的,但由迭代器返回的 Iterator 和由所有 Hashtable 的“collection 視圖方法”返回的 Collection 的 listIterator 方法都是快速失敗的:在創建 Iterator 之后,如果從結構上對 Hashtable 進行修改,除非通過 Iterator 自身的移除或添加方法,否則在任何時間以任何方式對其進行修改,Iterator 都將拋出 ConcurrentModificationException。因此,面對並發的修改,Iterator 很快就會完全失敗,而不冒在將來某個不確定的時間發生任意不確定行為的風險。由 Hashtable 的鍵和值方法返回的 Enumeration 不是快速失敗的。
注意,迭代器的快速失敗行為無法得到保證,因為一般來說,不可能對是否出現不同步並發修改做出任何硬性保證。快速失敗迭代器會盡最大努力拋出 ConcurrentModificationException。因此,為提高這類迭代器的正確性而編寫一個依賴於此異常的程序是錯誤做法:迭代器的快速失敗行為應該僅用於檢測程序錯誤。
先稍微吐槽一下,為啥命名不是HashTable啊,看着好難受,不管了就裝作它叫HashTable吧。這貨已經不常用了,就簡單說說吧。HashTable源碼中是使用synchronized
來保證線程安全的,比如下面的get方法和put方法:
public synchronized V get(Object key) { // 省略實現 } public synchronized V put(K key, V value) { // 省略實現 }
所以當一個線程訪問HashTable的同步方法時,其他線程如果也要訪問同步方法,會被阻塞住。舉個例子,當一個線程使用put方法時,另一個線程不但不可以使用put方法,連get方法都不可以,好霸道啊!!!so~~,效率很低,現在基本不會選擇它了。
Collections.synchronizedMap將HashMap包裝起來
返回由指定映射支持的同步(線程安全的)映射。為了保證按順序訪問,必須通過返回的映射完成對底層映射的所有訪問。在返回的映射或其任意 collection 視圖上進行迭代時,強制用戶手工在返回的映射上進行同步:
Map m = Collections.synchronizedMap(new HashMap()); ... Set s = m.keySet(); // Needn't be in synchronized block ... synchronized(m) { // Synchronizing on m, not s! Iterator i = s.iterator(); // Must be in synchronized block while (i.hasNext()) foo(i.next()); }
不遵從此建議將導致無法確定的行為。如果指定映射是可序列化的,則返回的映射也將是可序列化的。
看了一下源碼,SynchronizedMap的實現還是很簡單的。
// synchronizedMap方法 public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) { return new SynchronizedMap<>(m); } // SynchronizedMap類 private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable { private static final long serialVersionUID = 1978198479659022715L; private final Map<K,V> m; // Backing Map final Object mutex; // Object on which to synchronize SynchronizedMap(Map<K,V> m) { this.m = Objects.requireNonNull(m); mutex = this; } SynchronizedMap(Map<K,V> m, Object mutex) { this.m = m; this.mutex = mutex; } public int size() { synchronized (mutex) {return m.size();} } public boolean isEmpty() { synchronized (mutex) {return m.isEmpty();} } public boolean containsKey(Object key) { synchronized (mutex) {return m.containsKey(key);} } public boolean containsValue(Object value) { synchronized (mutex) {return m.containsValue(value);} } public V get(Object key) { synchronized (mutex) {return m.get(key);} } public V put(K key, V value) { synchronized (mutex) {return m.put(key, value);} } public V remove(Object key) { synchronized (mutex) {return m.remove(key);} } // 省略其他方法 }
通過Collections.synchronizedMap()來封裝所有不安全的HashMap的方法,就連toString, hashCode都進行了封裝. 封裝的關鍵點有2處,1)使用了經典的synchronized來進行互斥, 2)使用了代理模式new了一個新的類,這個類同樣實現了Map接口.
方法一使用的是的synchronized方法,是一種悲觀鎖.在進入之前需要獲得鎖,確保獨享當前對象,然后做相應的修改/讀取.
ConcurrentHashMap替換HashMap
支持檢索的完全並發和更新的所期望可調整並發的哈希表。此類遵守與 Hashtable 相同的功能規范,並且包括對應於 Hashtable 的每個方法的方法版本。不過,盡管所有操作都是線程安全的,但檢索操作不必鎖定,並且不支持以某種防止所有訪問的方式鎖定整個表。此類可以通過程序完全與 Hashtable 進行互操作,這取決於其線程安全,而與其同步細節無關。
檢索操作(包括 get)通常不會受阻塞,因此,可能與更新操作交迭(包括 put 和 remove)。檢索會影響最近完成的更新操作的結果。對於一些聚合操作,比如 putAll 和 clear,並發檢索可能只影響某些條目的插入和移除。類似地,在創建迭代器/枚舉時或自此之后,Iterators 和 Enumerations 返回在某一時間點上影響哈希表狀態的元素。它們不會拋出 ConcurrentModificationException。不過,迭代器被設計成每次僅由一個線程使用。
ConcurrentHashMap(以下簡稱CHM)是JUC包中的一個類,Spring的源碼中有很多使用CHM的地方。之前已經翻譯過一篇關於ConcurrentHashMap的博客,如何在java中使用ConcurrentHashMap,里面介紹了CHM在Java中的實現,CHM的一些重要特性和什么情況下應該使用CHM。需要注意的是,上面博客是基於Java 7的,和8有區別,在8中CHM摒棄了Segment(鎖段)的概念,而是啟用了一種全新的方式實現,利用CAS算法,有時間會重新總結一下。
重新寫了HashMap,比較大的改變有如下幾點.
使用了新的鎖機制(可以理解為樂觀鎖)稍后詳細介紹
把HashMap進行了拆分,拆分成了多個獨立的塊,這樣在高並發的情況下減少了鎖沖突的可能
方法二使用的是樂觀鎖,只有在需要修改對象時,比較和之前的值是否被人修改了,如果被其他線程修改了,那么就會返回失敗.鎖的實現,使用的是NonfairSync. 這個特性要確保修改的原子性,互斥性,無法在JDK這個級別得到解決,JDK在此次需要調用JNI方法,而JNI則調用CAS指令來確保原子性與互斥性.讀者可以自行Google JAVA CAS來了解更多. JAVA的樂觀鎖是如何實現的.
當如果多個線程恰好操作到ConcurrentHashMap同一個segment上面,那么只會有一個線程得到運行,其他的線程會被LockSupport.park(),稍后執行完成后,會自動挑選一個線程來執行LockSupport.unpark().
如何得到/釋放鎖
得到鎖:
方法一:在Hashmap上面,synchronized鎖住的是對象(不是Class),所以第一個申請的得到鎖,其他線程將進入阻塞,等待喚醒.
方法二:檢查AbstractQueuedSynchronizer.state,如果為0,則得到鎖,或者申請者已經得到鎖,則也能再辭得到鎖,並且state也加1.
釋放鎖:
都是得到鎖的逆操作,並且使用正確,二種方法都是自動選取一個隊列中的線程得到鎖可以獲得CPU資源.