目錄
- 并發(fā)問題的癥狀
- 多線程put后可能導(dǎo)致get死循環(huán)
- 多線程put的時候可能導(dǎo)致元素丟失
- put非null元素后get出來的卻是null
- HashMap數(shù)據(jù)結(jié)構(gòu)
- HashMap的rehash源代碼
- 正常的ReHash過程
- 并發(fā)的Rehash過程
- 三種解決方案
- Hashtable替換HashMap
- Collections.synchronizedMap將HashMap包裝起來
- ConcurrentHashMap替換HashMap
并發(fā)問題的癥狀
多線程put后可能導(dǎo)致get死循環(huán)
從前我們的Java代碼因為一些原因使用了HashMap這個東西,但是當(dāng)時的程序是單線程的,一切都沒有問題。后來,我們的程序性能有問題,所以需要變成多線程的,于是,變成多線程后到了線上,發(fā)現(xiàn)程序經(jīng)常占了100%的CPU,查看堆棧,你會發(fā)現(xiàn)程序都Hang在了HashMap.get()這個方法上了,重啟程序后問題消失。但是過段時間又會來。而且,這個問題在測試環(huán)境里可能很難重現(xiàn)。
我們簡單的看一下我們自己的代碼,我們就知道HashMap被多個線程操作。而Java的文檔說HashMap是非線程安全的,應(yīng)該用ConcurrentHashMap。但是在這里我們可以來研究一下原因。簡單代碼如下:
package com.king.hashmap; import java.util.HashMap; 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內(nèi)容/get內(nèi)容,put的內(nèi)容很簡單,key和value都是從0自增的整數(shù)(這個put的內(nèi)容做的并不好,以致于后來干擾了我分析問題的思路)。對HashMap做并發(fā)寫操作,我原以為只不過會產(chǎn)生臟數(shù)據(jù)的情況,但反復(fù)運行這個程序,會出現(xiàn)線程t1、t2被hang住的情況,多數(shù)情況下是一個線程被hang住另一個成功結(jié)束,偶爾會10個線程都被hang住。
產(chǎn)生這個死循環(huán)的根源在于對一個未保護的共享變量 — 一個”HashMap”數(shù)據(jù)結(jié)構(gòu)的操作。當(dāng)在所有操作的方法上加了”synchronized”后,一切恢復(fù)了正常。這算jvm的bug嗎?應(yīng)該說不是的,這個現(xiàn)象很早以前就報告出來了。Sun的工程師并不認為這是bug,而是建議在這樣的場景下應(yīng)采用”ConcurrentHashMap”,
CPU利用率過高一般是因為出現(xiàn)了出現(xiàn)了死循環(huán),導(dǎo)致部分線程一直運行,占用cpu時間。問題原因就是HashMap是非線程安全的,多個線程put的時候造成了某個key值Entry key List的死循環(huán),問題就這么產(chǎn)生了。
當(dāng)另外一個線程get 這個Entry List 死循環(huán)的key的時候,這個get也會一直執(zhí)行。最后結(jié)果是越來越多的線程死循環(huán),最后導(dǎo)致服務(wù)器dang掉。我們一般認為HashMap重復(fù)插入某個值的時候,會覆蓋之前的值,這個沒錯。但是對于多線程訪問的時候,由于其內(nèi)部實現(xiàn)機制(在多線程環(huán)境且未作同步的情況下,對同一個HashMap做put操作可能導(dǎo)致兩個或以上線程同時做rehash動作,就可能導(dǎo)致循環(huán)鍵表出現(xiàn),一旦出現(xiàn)線程將無法終止,持續(xù)占用CPU,導(dǎo)致CPU使用率居高不下),就可能出現(xiàn)安全問題了。
使用jstack工具dump出問題的那臺服務(wù)器的棧信息。死循環(huán)的話,首先查找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)?
共出現(xiàn)了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)?
共出現(xiàn)了3次。
注意:不合理使用HashMap導(dǎo)致出現(xiàn)的是死循環(huán)而不是死鎖。
多線程put的時候可能導(dǎo)致元素丟失
主要問題出在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 ); } } }
在這個方法里,將舊數(shù)組賦值給src,遍歷src,當(dāng)src的元素非null時,就將src中的該元素置null,即將舊數(shù)組中的元素置null了,也就是這一句:
if (e != null ) { src[j] = null ;
此時若有g(shù)et方法訪問這個key,它取得的還是舊數(shù)組,當(dāng)然就取不到其對應(yīng)的value了。
總結(jié):HashMap未同步時在并發(fā)程序中會產(chǎn)生許多微妙的問題,難以從表層找到原因。所以使用HashMap出現(xiàn)了違反直覺的現(xiàn)象,那么可能就是并發(fā)導(dǎo)致的了。
HashMap數(shù)據(jù)結(jié)構(gòu)
我需要簡單地說一下HashMap這個經(jīng)典的數(shù)據(jù)結(jié)構(gòu)。
HashMap通常會用一個指針數(shù)組(假設(shè)為table[])來做分散所有的key,當(dāng)一個key被加入時,會通過Hash算法通過key算出這個數(shù)組的下標(biāo)i,然后就把這個 插到table[i]中,如果有兩個不同的key被算在了同一個i,那么就叫沖突,又叫碰撞,這樣會在table[i]上形成一個鏈表。
我們知道,如果table[]的尺寸很小,比如只有2個,如果要放進10個keys的話,那么碰撞非常頻繁,于是一個O(1)的查找算法,就變成了鏈表遍歷,性能變成了O(n),這是Hash表的缺陷。
所以,Hash表的尺寸和容量非常的重要。一般來說,Hash表這個容器當(dāng)有數(shù)據(jù)要插入時,都會檢查容量有沒有超過設(shè)定的thredhold,如果超過,需要增大Hash表的尺寸,但是這樣一來,整個Hash表里的元素都需要被重算一遍。這叫rehash,這個成本相當(dāng)?shù)拇蟆?/p>
HashMap的rehash源代碼
下面,我們來看一下Java的HashMap的源代碼。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不存在,需要增加一個結(jié)點 addEntry(hash, key, value, i); return null ; }
檢查容量是否超標(biāo):
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); //查看當(dāng)前的size是否超過了我們設(shè)定的閾值threshold,如果超過,需要resize if (size++ >= threshold) resize( 2 * table.length); }
新建一個更大尺寸的hash表,然后把數(shù)據(jù)從老的Hash表中遷移到新的Hash表中。
void resize( int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; ...... //創(chuàng)建一個新的Hash Table Entry[] newTable = new Entry[newCapacity]; //將Old Hash Table上的數(shù)據(jù)遷移到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過程
畫了個圖做了個演示。
- 我假設(shè)了我們的hash算法就是簡單的用key mod 一下表的大小(也就是數(shù)組的長度)。
- 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都沖突在table1這里了。
- 接下來的三個步驟是Hash表 resize成4,然后所有的 重新rehash的過程。
而我們的線程二執(zhí)行完成了。于是我們有下面的這個樣子。
不遵從此建議將導(dǎo)致無法確定的行為。如果指定映射是可序列化的,則返回的映射也將是可序列化的。
ConcurrentHashMap替換HashMap
支持檢索的完全并發(fā)和更新的所期望可調(diào)整并發(fā)的哈希表。此類遵守與 Hashtable 相同的功能規(guī)范,并且包括對應(yīng)于 Hashtable 的每個方法的方法版本。不過,盡管所有操作都是線程安全的,但檢索操作不必鎖定,并且不支持以某種防止所有訪問的方式鎖定整個表。此類可以通過程序完全與 Hashtable 進行互操作,這取決于其線程安全,而與其同步細節(jié)無關(guān)。 檢索操作(包括 get)通常不會受阻塞,因此,可能與更新操作交迭(包括 put 和 remove)。檢索會影響最近完成的更新操作的結(jié)果。對于一些聚合操作,比如 putAll 和 clear,并發(fā)檢索可能只影響某些條目的插入和移除。類似地,在創(chuàng)建迭代器/枚舉時或自此之后,Iterators 和 Enumerations 返回在某一時間點上影響哈希表狀態(tài)的元素。它們不會拋出 ConcurrentModificationException。不過,迭代器被設(shè)計成每次僅由一個線程使用。
原文地址:https://blog.csdn.net/paincupid/article/details/51241783