PS:不得不說Java編程思想這本書是真心強大..
學習內容:
1.HashMap<K,V>在多線程的情況下出現的死循環現象
當初學Java的時候只是知道HashMap<K,V>在並發的情況下使用的話,會出現線程安全問題,但是一直都沒有進行深入的研究,也是最近實驗室的徒弟在問起這個問題的原因之后,才開始進行了一個深入的研究.
那么這一章也就僅僅針對這個問題來說一下,至於如何使用HashMap這個東西,也就不進行介紹了.在面對這個問題之前,我們先看一下HashMap<K,V>的數據結構,學過C語言的,大家應該都知道哈希表這個東西.其實HashMap<K,V>和哈希表我可以說,思想上基本都是一樣的.
這就是二者的數據結構,上面那個是C語言的數據結構,也就是哈希表,下面的則是Java中HashMap<K,V>的數據結構,雖然數據結構上稍微有點差異,不過思想都是一樣的.我們還是以HashMap<K,V>進行講解,我們知道HashMap<K,V>有一個叫裝載因子的東西,默認情況下HashMap<K,V>的裝載因子是75%這是在時間和空間上尋求的一個折衷.那么什么是所謂的裝載因子,裝載因子其實是用來判斷當前的HashMap<K,V>中存放的數據量,如果我們存放的數據量大於了75%,那么HashMap<K,V>就需要進行擴容操作,擴容的空間大小就是原來空間的兩倍.但是擴容的時候需要reshash操作,其實就是講所有的數據重新計算HashCode,然后賦給新的HashMap<K,V>,rehash的過程是非常耗費時間和空間的,因此在我們對HashMap的大小進行控制的時候,應該要進行相當的考慮.還有一個誤區(HashMap<K,V>可不是無限大的.)
簡單介紹完畢之后,就說一下正題吧.其實在單線程的情況下,HashMap<K,V>是不會出現問題的.但是在多線程的情況下也就是並發情況下,就會出現問題.如果HashMap<K,V>的容量很大,我們存入的數據很少,在並發的情況下出現問題的幾率還是很小的.出現問題的主要原因就是,當我們存入的數據過多的時候,尤其是需要擴容的時候,在並發情況下是很容易出現問題.針對這個現象,我們來分析一下.
resize()函數..
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]; boolean oldAltHashing = useAltHashing; useAltHashing |= sun.misc.VM.isBooted() && (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); boolean rehash = oldAltHashing ^ useAltHashing; transfer(newTable, rehash); //transfer函數的調用 table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
上面說過,但HashMap<K,V>的空間不足的情況下,需要進行擴容操作,因此在Java JDK中需要使用resize()函數,Android api中是找不到resize函數的,Android api是使用ensureCapacity來完成調用的..原理其實都差不多,我這里還是只說Java JDK中的..其實在resize()這個過程中,在並發情況下也是不會出現問題的..
關鍵問題是transfer函數的調用過程..我們來看一下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); //重新獲取hashcode e.next = newTable[i]; newTable[i] = e; e = next; } } }
transfer函數其實是在並發情況下導致死循環的因素..因為這里涉及到了指針的移動的過程..transfer的源碼一開始我並有完全的看懂,主要還是newTable[i]=e的這個過程有點讓人難理解..其實這個過程是一個非常簡單的過程..我們來看一下下面這張圖片..
這是在單線程的正常情況下,當HashMap<K,V>的容量不夠之后的擴容操作,將舊表中的數據賦給新表中的數據.正常情況下,就是上面圖片顯示的那樣.新表的數據就會很正常,並且還需要說的一點就是,進行擴容操作之后,在舊表中key值相同的數據塊在新表中數據塊的連接方式會逆向.就拿key = 3和key = 7的兩個數據塊來說,在舊表中是key = 3 的數據塊指向key = 7的數據塊的,但是在新表中,key = 7的數據塊則是指向了key = 3的數據塊key = 5 的數據塊不和二者發生沖突,因此就保存到了 i = 1 的位置(這里的hash算法采用 k % hash.size() 的方式).這里采用了這樣簡單的算法無非是幫助我們理解這個過程,當然在正常情況下算法是不可能這么簡單的.
這樣在單線程的情況下就完成了擴容的操作.其中不會出現其他的問題..但是如果是在並發的情況下就不一樣了.並發的情況出現問題會有很多種情況.這里我簡單的說明倆種情況.我們來看圖。
這張圖可能有點小,大家可以通過查看圖像來放大,就能夠看清晰內容了...
這張圖說明了兩種死循環的情況.第一種相對而嚴還是很容易理解的.第二種可能有點費勁..但是有一點我們需要記住,圖中t1和t2拿到的是同一個內存單元對應的數據塊.而不是t1拿到了一個獨立的數據塊,t2拿到了一個獨立的數據塊..這是不對的..之所以發生系循環的原因就是因為拿到的數據塊是同一個內存單元對應的數據塊.這點我們需要注意..正是因為在高並發的情況下線程的工作方式是不確定的,我們無法預知線程的工作情況.因此在高並發的情況下,我們不要使用多線程對HashMap<K,V>進行操作,否則我們都不知道到底是哪里出了問題.
可能看起來很復雜,但是只要去思考,還是感覺蠻簡單的,我這只是針對兩個線程來分析了一下死循環的情況,當然發生死循環的問題不僅僅只是這兩種方式,方式可能會有很多,我這里只是針對了兩個類型進行了分析,目的是方便大家理解.發生死循環的方式絕不僅僅只是這兩種情況.至於其他的情況,大家如果願意去了解,可以自己再去研磨研磨其他的方式.按照這種思路分析,還是能研磨出來的.並且這還是兩個線程,如果數據量非常大,線程的使用還比較多,那么就更容易發生死循環的現象.因此這就是導致HashMap<K,V>在高並發下導致死循環的原因.
雖然我們都知道當多線程對Map進行操作的時候,我們只需要使用ConcurrentHashMap<K,V>就可以了.但是我們還是需要知道為什么HashMap<K,V>在高並發的情況下不能夠那樣去使用.學一樣東西,不僅僅要知道,而且還要知道其中的原因和道理.