今天回顧hashmap源碼的時候發現一個很有意思的地方,那就是jdk1.8在hashmap擴容上面的優化。
首先大家可能都知道,1.8比1.7多出了一個紅黑樹化的操作,當然在擴容的時候也要對紅黑樹進行重排,然而今天要說的並不是這個,而是針對數組中的鏈表項的處理優化。
關於hashmap的源碼都十分精妙,有時間可以多看看。
首先上1.7的源碼:
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; // 是否需要重新計算hash值 transfer(newTable, rehash); // 將table的數據轉移到新的table中 table = newTable; // 數組重新賦值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); //重新計算閾值 }
很顯然,是遍歷整個map中的每一個節點(如果是鏈表就再對其循環),每一個節點新的放置位置=[hash & (newCapacity - 1)]------indexFor方法。
transfer(newTable, rehash); // 將table的數據轉移到新的table中
此方法會根據rehash來判斷是否重新計算hashCode,然后放置到新的數組中:
1 void transfer(Entry[] newTable, boolean rehash) { 2 int newCapacity = newTable.length; 3 for (Entry<K,V> e : table) { 4 5 while(null != e) { 6 Entry<K,V> next = e.next; 7 if (rehash) { 8 e.hash = null == e.key ? 0 : hash(e.key); 9 } 10 int i = indexFor(e.hash, newCapacity); 11 e.next = newTable[i]; 12 newTable[i] = e; 13 e = next; 14 } 15 } 16 }
這是transfer將當前數組中各節點e移動到新數組的 i 位置上的核心代碼,為了避免調用put方法,它直接取 e.next = newTable[i];
例如:
oldtable[ i ]為:A->B->null
newtable[ j ]為:X->Y->null
移動oldtable[ i ]到newTable[ j ]中,步驟如下:
1. e指向A;
2. e.next指向newTable[ j ]也就是X,所以A->X;
3. newTable[ j ]指向A,所以此時newTable[ j ]為A->X->Y->null
4. e指向B;
類似循環操作1,2,3
最后newTable[ j ]結果為:B->A->X->Y->null,變成了逆序。
並且,並發的時候可能會發生死鎖:
假如線程1在剛執行完 Entry<K,V> next = e.next; 后,此時e指向A,next指向B,
然后被B線程搶占,然后B完整執行完擴容后,此時newTable[ j ]為:B->A->X->Y->null,
然后A線程恢復執行,e.next = newTable[i];,此時newTable[ j ]為:B<->A X->Y->null,(已經形成環,並且后面鏈表丟失)
newTable[i] = e;,此時newTable[ j ]為:A<->B
繼續循環,發現已經形成環,沒有null了, while(null != e) 永遠跳不出循環,所以會形成死鎖。
可以看出形成環的主要原因是因為形成了逆序,應該怎么解決呢?
下面是1.8的代碼:
1 final Node<K,V>[] resize() { 2 //保存舊的 Hash 數組 3 Node<K,V>[] oldTab = table; 4 int oldCap = (oldTab == null) ? 0 : oldTab.length; 5 int oldThr = threshold; 6 int newCap, newThr = 0; 7 if (oldCap > 0) { 8 //超過最大容量,不再進行擴充 9 if (oldCap >= MAXIMUM_CAPACITY) { 10 threshold = Integer.MAX_VALUE; 11 return oldTab; 12 } 13 //容量沒有超過最大值,容量變為原來的兩倍 14 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 15 oldCap >= DEFAULT_INITIAL_CAPACITY) 16 //閥值變為原來的兩倍 17 newThr = oldThr << 1; 18 } 19 else if (oldThr > 0) 20 newCap = oldThr; 21 else { 22 //閥值和容量使用默認值 23 newCap = DEFAULT_INITIAL_CAPACITY; 24 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 25 } 26 if (newThr == 0) { 27 //計算新的閥值 28 float ft = (float)newCap * loadFactor; 29 //閥值沒有超過最大閥值,設置新的閥值 30 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 31 (int)ft : Integer.MAX_VALUE); 32 } 33 threshold = newThr; 34 @SuppressWarnings({"rawtypes","unchecked"}) 35 //創建新的 Hash 表 36 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 37 table = newTab; 38 //遍歷舊的 Hash 表 39 if (oldTab != null) { 40 for (int j = 0; j < oldCap; ++j) { 41 Node<K,V> e; 42 if ((e = oldTab[j]) != null) { 43 //釋放空間 44 oldTab[j] = null; 45 //當前節點不是以鏈表的形式存在 46 if (e.next == null) 47 newTab[e.hash & (newCap - 1)] = e; 48 //紅黑樹的形式,略過 49 else if (e instanceof TreeNode) 50 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 51 else { 52 //以鏈表形式存在的節點; 53 //這一段就是新優化的地方,見下面分析 54 Node<K,V> loHead = null, loTail = null; 55 Node<K,V> hiHead = null, hiTail = null; 56 Node<K,V> next; 57 do { 58 next = e.next; 59 if ((e.hash & oldCap) == 0) { 60 if (loTail == null) 61 loHead = e; 62 else 63 loTail.next = e; 64 loTail = e; 65 } 66 else { 67 if (hiTail == null) 68 hiHead = e; 69 else 70 hiTail.next = e; 71 hiTail = e; 72 } 73 } while ((e = next) != null); 74 if (loTail != null) { 75 //最后一個節點的下一個節點做空 76 loTail.next = null; 77 newTab[j] = loHead; 78 } 79 if (hiTail != null) { 80 //最后一個節點的下一個節點做空 81 hiTail.next = null; 82 newTab[j + oldCap] = hiHead; 83 } 84 } 85 } 86 } 87 } 88 return newTab; 89 }
嗯。。很多,
主要邏輯就是,循環數組內每一個元素
1、是普通節點,直接和1.7一樣放置;
2、紅黑樹,調用 split 修剪方法進行拆分放置(不是本文要點,略);
3、是鏈表………………
是不是鏈表那段沒看懂?下面舉一個例子就好明白了:
假如現在容量為初始容量16,再假如5,21,37,53的hash自己(二進制),
所以在oldTab中的存儲位置就都是 hash & (16 - 1)【16-1就是二進制1111,就是取最后四位】,
5 :00000101
21:00010101
37:00100101
53:00110101
四個數與(16-1)相與后都是0101
即原始鏈為:5--->21--->37--->53---->null
此時進入代碼中 do-while 循環,對鏈表節點進行遍歷,判斷是留下還是去新的鏈表:
lo就是擴容后仍然在原地的元素鏈表
hi就是擴容后下標為 原位置+原容量 的元素鏈表,從而不需要重新計算hash。
因為擴容后計算存儲位置就是 hash & (32 - 1)【取后5位】,但是並不需要再計算一次位置,
此處只需要判斷左邊新增的那一位(右數第5位)是否為1即可判斷此節點是留在原地lo還是移動去高位hi:(e.hash & oldCap) == 0 (oldCap是16也就是10000,相與即取新的那一位)
5 :00000101——————》0留在原地 lo鏈表
21:00010101——————》1移向高位 hi鏈表
37:00100101——————》0留在原地 lo鏈表
53:00110101——————》1移向高位 hi鏈表
第一輪循環
loHead:5
loTail:5
其他:null
第二輪循環
loHead:5
loTail:5
hiHead:21
hiTail:21
第三輪循環
loHead:5 (5.next = 37)
loTail:37
hiHead:21
hiTail:21
。。。
所以循環結束之后:
loHead:5
loTail:37
hiHead:21
hiTail:53
lo:5--->37---->null
hi:21--->53---->null
退出循環后只需要判斷lo,hi是否為空,然后把各自鏈表頭結點直接放到對應位置上即可完成整個鏈表的移動。
原理是:利用了尾指針Tail,完成了尾部插入,不會造成逆序,所以也不會產生並發死鎖的問題。
這種方法對比1.7中算法的優點是:
1、不管怎么樣都不需要重新再計算hash;
2、放過去的鏈表內元素的相對順序不會改變;
3、不會在並發擴容中發生死鎖。
注意,時間復雜度並沒有減少
有以上分析同樣可以得出hashmap擴容的開銷很大,日常開發中應該根據實際需要設定合適的 初始容量 和 負載因子 ,這對提高程序性能有不小幫助。