HashMap的擴容機制---resize() & 死迴圈的問題
1.8 與1.7 變化較大,
http://www.cnblogs.com/RGogoing/p/5285361.html
學習內容:
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>就需要進行擴容操作,擴容的空間大小就是原來空間的1.5倍,已有元素個數的2倍。0.75 *length。但是擴容的時候需要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>在高併發的情況下不能夠那樣去使用.學一樣東西,不僅僅要知道,而且還要知道其中的原因和道理.
http://blog.csdn.net/aichuanwendang/article/details/53317351
雖然在hashmap的原理裡面有這段,但是這個單獨拿出來講rehash或者resize()也是極好的。
什麼時候擴容:當向容器新增元素的時候,會判斷當前容器的元素個數,如果大於等於閾值---即當前陣列的長度乘以載入因子的值的時候,就要自動擴容啦。
擴容(resize)就是重新計算容量,向HashMap物件裡不停的新增元素,而HashMap物件內部的陣列無法裝載更多的元素時,物件就需要擴大陣列的長度,以便能裝入更多的元素。當然Java裡的陣列是無法自動擴容的,方法是使用一個新的陣列代替已有的容量小的陣列,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。
我們分析下resize的原始碼,鑑於JDK1.8融入了紅黑樹,較複雜,為了便於理解我們仍然使用JDK1.7的程式碼,好理解一些,本質上區別不大,具體區別後文再說。
- void resize(int newCapacity) { //傳入新的容量
- Entry[] oldTable = table; //引用擴容前的Entry陣列
- int oldCapacity = oldTable.length;
- if (oldCapacity == MAXIMUM_CAPACITY) { //擴容前的陣列大小如果已經達到最大(2^30)了
- threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以後就不會擴容了
- return;
- }
- Entry[] newTable = new Entry[newCapacity]; //初始化一個新的Entry陣列
- transfer(newTable); //!!將資料轉移到新的Entry數組裡
- table = newTable; //HashMap的table屬性引用新的Entry陣列
- threshold = (int) (newCapacity * loadFactor);//修改閾值
- }
這裡就是使用一個容量更大的陣列來代替已有的容量小的陣列,transfer()方法將原有Entry陣列的元素拷貝到新的Entry數組裡。
- void transfer(Entry[] newTable) {
- Entry[] src = table; //src引用了舊的Entry陣列
- int newCapacity = newTable.length;
- for (int j = 0; j < src.length; j++) { //遍歷舊的Entry陣列
- Entry<K, V> e = src[j]; //取得舊Entry陣列的每個元素
- if (e != null) {
- src[j] = null;//釋放舊Entry陣列的物件引用(for迴圈後,舊的Entry陣列不再引用任何物件)
- do {
- Entry<K, V> next = e.next;
- int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在陣列中的位置
- e.next = newTable[i]; //標記[1]
- newTable[i] = e; //將元素放在陣列上
- e = next; //訪問下一個Entry鏈上的元素
- } while (e != null);
- }
- }
- }
- static int indexFor(int h, int length) {
- return h & (length - 1);
- }
newTable[i]的引用賦給了e.next,也就是使用了單鏈表的頭插入方式,同一位置上新元素總會被放在連結串列的頭部位置;這樣先放在一個索引上的元素終會被放到Entry鏈的尾部(如果發生了hash衝突的話),這一點和Jdk1.8有區別,下文詳解。在舊陣列中同一條Entry鏈上的元素,通過重新計算索引位置後,有可能被放到了新陣列的不同位置上。
下面舉個例子說明下擴容過程。
這句話是重點----hash(){return key % table.length;}方法,就是翻譯下面的一行解釋:
假設了我們的hash演算法就是簡單的用key mod 一下表的大小(也就是陣列的長度)。
其中的雜湊桶陣列table的size=2, 所以key = 3、7、5,put順序依次為 5、7、3。在mod 2以後都衝突在table[1]這裡了。這裡假設負載因子 loadFactor=1,即當鍵值對的實際大小size 大於 table的實際大小時進行擴容。接下來的三個步驟是雜湊桶陣列 resize成4,然後所有的Node重新rehash的過程。
下面我們講解下JDK1.8做了哪些優化。經過觀測可以發現,我們使用的是2次冪的擴充套件(指長度擴為原來2倍),所以,
經過rehash之後,元素的位置要麼是在原位置,要麼是在原位置再移動2次冪的位置。對應的就是下方的resize的註釋。
- /**
- * Initializes or doubles table size. If null, allocates in
- * accord with initial capacity target held in field threshold.
- * Otherwise, because we are using power-of-two expansion, the
- * elements from each bin must either stay at same index, or move
- * with a power of two offset in the new table.
- *
- * @return the table
- */
- final Node<K,V>[] resize() {
看下圖可以明白這句話的意思,n為table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容後key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的雜湊與高位運算結果。
元素在重新計算hash之後,因為n變為2倍,那麼n-1的mask範圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:
因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”,可以看看下圖為16擴充為32的resize示意圖:
這個設計確實非常的巧妙,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的衝突的節點分散到新的bucket了。這一塊就是JDK1.8新增的優化點。有一點注意區別,JDK1.7中rehash的時候,舊連結串列遷移新連結串列的時候,如果在新表的陣列索引位置相同,則連結串列元素會倒置,但是從上圖可以看出,JDK1.8不會倒置。有興趣的同學可以研究下JDK1.8的resize原始碼,寫的很贊,如下:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}