HashMap原始碼解析(java1.8.0)
1.1 背景知識
1.1.1 紅黑樹
二叉查詢樹可能因為多次插入新節點導致失去平衡,使得查詢效率低,查詢的複雜度甚至可能會出現線性的,為了解決因為新節點的插入而導致查詢樹不平衡,此時就出現了紅黑樹。
紅黑樹它一種特殊的二叉查詢樹。紅黑樹的每個節點上都有儲存位表示節點的顏色,可以是紅(Red)或黑(Black)。它具有以下特點:
(1)每個節點或者是黑色,或者是紅色。
(2)根節點是黑色。
(3)每個葉子節點(葉子節點,是指為空(NIL或NULL)的葉子節點)是黑色。
(4)如果一個節點是紅色的,則它的子節點一定是黑色(即從根節點到葉子節點的路徑上不能有兩個重複的紅色節點)。
(5)從一個節點到其上每一個葉節點的所有路徑都具有相同的黑色節點個數。
紅黑樹的基本操作--新增
① 將紅黑樹當作一顆二叉查詢樹,將節點插入。
② 將插入的節點著色為"紅色"。(因為條件5,從一個節點到其中每一個節點的的所有路徑都具有相同的黑色節點)。
③通過一系列的旋轉(左旋或右旋操作)或著色等操作,使之重新成為一顆紅黑樹。
1.2 原始碼
在java 1.7之前是用陣列和連結串列一起組合構成HashMap,在java1.8之後就使用當連結串列長度超過8之後,就會將連結串列轉化為紅黑樹,縮小查詢的時間(紅黑樹維護也會花費大量時間,包含左旋、右旋和變色過程)。
1.2.1 HashMap的初始化
hashmap建構函式會初始化三個值:
- 初始容量initialCapacity:預設值是16,當儲存的資料越來越多的時候,就必須進行擴容操作。
- 閾值threshold:hashmap的陣列結構中所能存放的最大數量,超過該數量,則會對陣列進行擴容。閾值的計算方式為:容量(initialCapacity)*負載因子(loadFactor)。
- 負載因子loadFactor:當負載因子很大時,閾值會很大,table陣列擴容的可能性比較小,會使得一個數組中的連結串列(紅黑樹)存放過多的資料,雖然節省了一定的空間,但會導致查詢時間很長。相反負載因子很小時,擴容的可能性會很高,使得陣列中的資料變得相對少,查詢時間會縮短,但會花費較長的時間。
在初始化一個hashmap物件的時候,指定鍵值對的同時,也可以指定初始map的容量大小,假設此處我們指定大小為11,則會在建構函式中呼叫tableSizeFor將容量改為2的n冪次,即比當前容量大,而且必須是2的指數次冪的最小數,就會變成16。這是因為2的指數次冪便於計算進行位運算操作,提升執行效率問題(位運算>加法>乘法>除法>取模)。
hashmap的的預設值是16,負載因子預設是0.75,程式碼如下:
//HashMap<String,String> hashMap = new HashMap<String, String>(11); /** * Returns a power of two size for the given target capacity. **/ static final int tableSizeFor(int cap) { int n = cap - 1; //10 防止在cap已經是2的n次冪的情況下 // >>> 表示不關心符號位,對資料的二進位制形式進行右移 |表示或運算 n |= n >>> 1; //15 n |= n >>> 2; //15 n |= n >>> 4; //15 n |= n >>> 8; //15 n |= n >>> 16; //15 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; //16 }
1.2.2 HashMap的put操作
這裡可以介紹一下&位運算,當我們將一對KV儲存到hashmap當中時,會通過(n - 1) & hash運算來定位將要插入的鍵值對放入到雜湊表的某個桶中。其中n表示雜湊表的長度,通常n為2的倍數,通過n-1即可n所表示的二進位制數,除最高位外,全部轉化為1,藉助與運算即可快速完成取模操作。
//hashMap.put("2020", "good luck"); /** * Implements Map.put and related methods. * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //如果hashtable沒有初始化,則初始化該table陣列 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //通過位運算找到陣列中的下標位置,如果陣列中對應下標為空,則可以直接存放下去 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { //陣列元素對應的位置已經有元素,產生碰撞 Node<K,V> e; K k; //如果插入的元素key是已經存在的,則將新的value替換掉原來的舊值 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //如果此時table陣列對應的位置是紅黑樹結構,則將該節點插入紅黑樹中 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //如果此時table陣列對應的位置是連結串列結構 for (int binCount = 0; ; ++binCount) { //遍歷到陣列尾端,沒有與插入鍵值對相同的key,則將新的鍵值對插入連結串列尾部 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //連結串列過長,將連結串列轉化為紅黑樹 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //發現連結串列中的某個節點有與插入鍵值對相同的key,則跳出迴圈,在迴圈外部重新賦值 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //該key在hashmap已存在,更新與在連結串列跳出迴圈節點對應的值 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; //超過閾值則更新 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
1.2.3 HashMap的get操作
/** * Implements Map.get and related methods. * * @param hash hash for key * @param key the key * @return the node, or null if none */ final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //table陣列不為空,且對應的下標位置也不為空。 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //如果第一個位置是對應的key,則返回 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; //遍歷其他元素 if ((e = first.next) != null) { //紅黑樹 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); //連結串列 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
1.2.4 HashMap的擴容操作
/** * 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() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; //table不為空,且容量大於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 } //閾值大於0,直接使用舊的閾值 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) { //依次遍歷舊table中每個陣列桶的元素 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; /* * e.hash & oldCap 對hash取模運算, * 雖然陣列大小擴大了一倍, * 但是同一個key在新舊table中對應的index卻存在一定聯絡: * 要麼一致,要麼相差一個 oldCap。 */ 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; }
此處在處理連結串列的時候,如何將連結串列中的節點重新分配到新的雜湊表需要做一些解釋。在擴容的時候,將原來的雜湊表擴大了一倍,原來屬於同一個桶中的資料會被重新分配,此時取模運算時(a mod b),會注意到,b會擴大兩倍(a mod 2b),此時如果該桶中的某一個數據的雜湊值是c1(0<c<b),則它必定還是會落入原來的位置,而如果桶中的某一個數據的雜湊值是c2(b<c2<2b),則它會被重新分配到一個新的位置(這個位置是原先的雜湊桶位置+舊桶的大小)。
HashMap在多執行緒的情況下出現的死迴圈現象
在某些java版本中擴容機制如果使用連結串列,且再插入時使用尾插法會出現死迴圈,具體原因可以參考老生常談,HashMap的死迴圈,在本文中所參考的java版本使用了頭插法的方式將元素新增到連結串列當中,可以避免死迴圈的出現,但是會出現一部分節點丟失的問題。如圖:
假設原始的雜湊map的某個桶的資料如下,此時執行緒開始擴容,將桶中的資料分配到lo和hi桶的連結串列中。
初始時刻執行緒1和執行緒2開始執行,執行緒1在執行完以下程式碼後,執行緒1的時間片執行結束。執行緒1執行的結果如圖所示
執行緒2與執行緒1同時執行,執行緒2的時間片未用完,還在繼續執行,根據程式碼的分配策略,執行緒2直到時間片執行結束,出現如圖所示的結果:
此時CPU的時間片又被分配到了執行緒1,執行緒1繼續執行,因為此時A所在的連結串列結構已經發生了變化,只能處理A,B,D三個元素。此時執行緒1建立的hashmap如圖:
參考資料
教你初步瞭解紅