HashMap 原始碼解析(jdk 1.8)
在程式設計師的日常工作中,面試中常常涉及。對於大部分程式猿來說可能還僅僅停留於會用的一個層面,很少會去摸索它底層是如何實現的,實現的原理是什麼,面對這些的時候都是滿頭霧水。工作中的一次偶然讓作者本人也逐步跨進了原始碼的大門,逐漸發現其中趣味,一點點摸索原始碼創作者的奇思妙想,在不知覺中提升了自己的思維韌性。 ———— 在此與大家一起分享所得,一起討論一起提升自我,這也是本人開始寫部落格的初衷。所以以下有闡述的不對的地方請指出,謝謝
概要
本章我們重新從整體上認識HashMap,先從資料結構開始然後再到原始碼的實現。
常見的資料結構
那麼常見的資料結構無非就是 陣列 連結串列 二叉樹 雜湊表 ,我們大概瞭解下它們的新增,查詢基礎操作執行效能:
- 陣列:採用一段連續的儲存單元來儲存資料,將所有元素按次序一次儲存。查詢操作需要遍歷陣列,逐一對比給定關鍵字和陣列元素
- 連結串列:一條相互相連的資料節點表,每個節點由資料和指向下一個節點的指標組成。查詢操作需要遍歷連結串列逐一進行比對。根據指標域的不同可分為單向連結串列,雙向連結串列,迴圈連結串列,多向表(網狀表)
- 二叉樹:二叉樹是非線性結構,即每個資料節點至多隻有一個前驅節點,但可以有多個後繼節點,它可以採用順序儲存結構和鏈式儲存結構
- 雜湊表:以鍵 - 值(key - index)儲存資料,相比於上面所說的資料結構,雜湊表的效能相當高,在不考慮雜湊衝突的情況下,查詢操作僅需一次定位即可獲得相應資料
HashMap資料結構
這裡我們先來通過圖來更形象具體的展現HashMap資料結構的組成,如圖:
圖1
從上面圖中所得 HashMap 是由陣列+單向連結串列+紅黑樹組成,其主幹是以Node為基本單元的一個Node陣列,每個Node中包含一個key - value 鍵值對。通過雜湊對映來儲存鍵值對資料,在查詢上使用雜湊碼的方式,提升了查詢效率。最多允許一對鍵值對的key為null,允許多對鍵值對的value為null。
HashMap 原始碼解析(基於JDK1.8)
1.重要欄位
//預設初始容量是16,必須是2的冪 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //最大容量(必須是2的冪並且小於2的30次冪,若過大將被這個值替換) static final int MAXIMUM_CAPACITY = 1 << 30; //預設載入因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //預設連結串列轉紅黑樹的閾值 static final int TREEIFY_THRESHOLD = 8; //從紅黑樹轉成連結串列的閾值 static final int UNTREEIFY_THRESHOLD = 6; transient Node<K, V>[] table; //hash陣列 transient Set<Map.Entry<K, V>> entrySet; //entry 快取set transient int size; //元素個數 transient int modCount; // 修改次數 int threshold; //閾值,等於載入因子*容量,當實際大小超過閾值則進行擴容 final float loadFactor; //載入因子,預設值為0.75
2. 基本單元(Entry),亦叫Node節點,是HashMap的靜態內部類,實現了Entry 。多個Node節點成連結串列,當連結串列的長度大於8時轉為紅黑樹結構。
static class Node<K, V> implements Map.Entry<K, V> { final int hash; //hash值,根據key值通過hash演算法所得 final K key; V value; Node<K, V> next; //指向當前Node節點的下一個節點物件 Node(int hash, K key, V value, Node<K, V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; }
3. HashMap的構造方法
1 //構造一個指定容量和負載因子的空HashMap 2 public HashMap(int initialCapacity, float loadFactor) { 3 if (initialCapacity < 0) 4 throw new IllegalArgumentException("Illegal initial capacity: " + 5 initialCapacity); 6 if (initialCapacity > MAXIMUM_CAPACITY) 7 initialCapacity = MAXIMUM_CAPACITY; 8 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 9 throw new IllegalArgumentException("Illegal load factor: " + 10 loadFactor); 11 this.loadFactor = loadFactor; 12 this.threshold = tableSizeFor(initialCapacity);//確定擴容閾值 13 } 14 //構造一個指定容量的空HashMap,預設負載因子(0.75) 15 public HashMap(int initialCapacity) { 16 this(initialCapacity, DEFAULT_LOAD_FACTOR); 17 } 18 19 //構造一個空的HashMap,預設初始容量(16)和預設負載因子(0.75) 20 public HashMap() { 21 this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 22 } 23 24 //構造一個與指定對映Map相同的新HashMap 25 public HashMap(Map<? extends K, ? extends V> m) { 26 this.loadFactor = DEFAULT_LOAD_FACTOR; 27 putMapEntries(m, false); 28 }
/** * Returns a power of two size for the given target capacity. */ static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
這有一點要注意的是,上面程式碼中第一個構造器和第二個器都會通過tableSizeFor()方法來確認threshold值(判斷是否需要擴容臨界值,閾值),如上面程式碼12行所示。而第三個預設的構造器則沒有指定threshold的值, 最後構造器則是將一個Map對映到當前新的Map中。
4. put()方法的實現
1 public V put(K key, V value) { 2 return putVal(hash(key), key, value, false, true); //hash(key)通過hash演算法計算對應key的hash值 3 } 4 5 /** 6 * Implements Map.put and related methods 7 * 8 * @param hash hash for key 9 * @param key the key 10 * @param value the value to put 11 * @param onlyIfAbsent if true, don't change existing value 12 * @param evict if false, the table is in creation mode. 13 * @return previous value, or null if none 14 */ 15 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 16 boolean evict) { 17 Node<K, V>[] tab; 18 Node<K, V> p; 19 int n, i; 20 if ((tab = table) == null || (n = tab.length) == 0) 21 n = (tab = resize()).length; //resize()方法 負責初始化和擴容 22 if ((p = tab[i = (n - 1) & hash]) == null) //計算下標 這裡直接採用了計算機基本運算的二進位制&運算代替取模運算 ('n-1 & hash' 與 'hash%n' 一樣效果) 23 tab[i] = newNode(hash, key, value, null); 24 else { 25 Node<K, V> e ; 26 K k; 27 if (p.hash == hash && 28 ((k = p.key) == key || (key != null && key.equals(k)))) //hashCode相同key相同,直接覆蓋 29 e = p; 30 else if (p instanceof TreeNode) //若為紅黑樹結構時 31 e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value); 32 else { //連結串列 33 for (int binCount = 0; ; ++binCount) { 34 if ((e = p.next) == null) { 35 //連結串列尾插,jdk1.7是頭插 36 p.next = newNode(hash, key, value, null); 37 if (binCount >= TREEIFY_THRESHOLD - 1) // 當連結串列長度大於8是轉為紅黑樹結構 38 //連結串列轉紅黑樹 39 treeifyBin(tab, hash); 40 break; 41 } 42 if (e.hash == hash && 43 ((k = e.key) == key || (key != null && key.equals(k)))) 44 break; 45 p = e; 46 } 47 } 48 if (e != null) { // existing mapping for key 49 V oldValue = e.value; 50 if (!onlyIfAbsent || oldValue == null) 51 e.value = value; 52 afterNodeAccess(e); 53 return oldValue; 54 } 55 } 56 ++modCount; 57 if (++size > threshold) 58 resize(); 59 afterNodeInsertion(evict); 60 return null; 61 }
1)index 演算法:解讀上面第22行程式碼
p = tab[i = (n - 1) & hash] 根據程式碼不難猜到(n-1)& hash 就是陣列元素的下標值index,如何去理解為什麼 (n-1)& hash 就是index值呢?
首先,我們以預設陣列初始容量大小(16)考慮,假如我們通過put("xxx","world")往Map中放鍵值對,那麼根據圖1的結構所示,首先要根據key值"xxx"得到一個 0-15 的整型數才能確定
該Node物件在陣列哪個桶位(也就是下標index),那麼就必須要整型數,正好每個物件都有hashCode,得到一個整型數,因為最終要得到一個0-15的整型數,那麼就可以將得到的hashCode整型
數取模16(hashCode % 16 = result),那麼這樣得到的result的結果就是0-15之間的一個數。我們再對比下原始碼(n-1)& hash 看起來好像不一樣( n=16 ),但是事實告訴我的是一樣
的,這就是原始碼設計者的精妙之處所在。
那來解釋一下到底精妙在哪裡:
首先,計算機的底層基本運算是二進位制運算的,而這裡 hashCode % 16 相比於 二進位制 與(&) 運算更為複雜,因為取模(%)運算最終都將會轉化為二進位制的'&','|','^'等運算,
一定程度上提高了效率,知道了這一點後我們。
再來分析下為什麼 (n-1)& hash == hashCode%16 ?
16 的 二進位制 10000
n-1 15 的 二進位制 01111
假如 hash 的 二進位制 1001 0010 0001 0100 0100 0010 0010 1010 hash ---- int 型別,一定是32位
0000 0000 0000 0000 0000 0000 0000 1111 & 15
----------
result index
正如上面所舉的例子,可變的只有hash值,n是不變,n-1=15 ,15的二進位制是 01111 ,高位不足全部補0,即下滑線的0為補上的0,&運算遇0得0,那麼自然而然 result 的結果則取決
於hash值的末4位數(紅色字位),hash的後4位的範圍是 0000-1111 之間,參與&運算,那麼result的最大值只有可能是1111,最小就是0000,同樣達到了我們前面所要求的獲取 0-15的一
個整型數值。說了這麼多也不知道大家能不能看懂。(不懂歡迎隨時找我)
我們不如再來看看 hash(key)函式,程式碼如下
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); //key.hashCode()得到一個整型數, // 高16位和低16位進行異或運算,使每位都參與運算,增大可能性 }
正如上面我們解釋過 result 的結果最終演變成了hash值的末4位數。我們倒回去看上面putVal()方法的原始碼中,我們可以看到22行 if 的判斷,假設 result =1,也就是陣列index=1的桶
位不為null,那麼則程式碼走else語句,若key相同則直接替代,若不相同,則意味著 hash衝突 的產生,put入的Node節點物件必然要麼存在連結串列結構中,要麼就存在紅黑樹結構中,不管是連結串列或
是紅黑樹的查詢效率都遠遠比不上雜湊碼(雜湊表)的查詢高效。衝突不可避免,只能儘可能的降低衝突發生的可能性,結合上面分析得到,要儘量增大result的可能性,那自然成了要增大hash值的
可能性,這樣則可以實現result儘可能的不一樣,然而起決定性作用的是hash值末4位,為了增大其可能性原始碼設計者採用異或運算,將高位的16位和低16位進行異或,如此一來影響結果的位數就不
止僅後四位決定,通過更多位的參與運算得到一個更加均勻分佈的entry陣列,有效提高了hashMap的效能。運算流程如下圖:
圖2
為什麼用 ‘異或’運算而 ‘與’ ‘或’ 運算不行能呢?相信大家同樣會有這樣的疑惑,我們結合下圖來一起理解下
圖3
圖3中的概率比較明顯的是'&'和'|'運算,有0則0或有1則1 每種可能都是75%,而'^'運算則可以均衡1和0的分佈,那麼自然出現相同的概率相比於前兩者要更低。
2)resize()方法,負責初始化和擴容工作
1 final Node<K, V>[] resize() { 2 Node<K, V>[] oldTab = table; 3 int oldCap = (oldTab == null) ? 0 : oldTab.length; 4 int oldThr = threshold; 5 int newCap, newThr = 0; 6 if (oldCap > 0) { 7 if (oldCap >= MAXIMUM_CAPACITY) { //當陣列超過最大容量處理 8 threshold = Integer.MAX_VALUE; 9 return oldTab; 10 } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)//newCap = oldCap << 1 擴大為原來的兩倍 11 newThr = oldThr << 1; // double threshold 12 } else if (oldThr > 0) // 初始容量設定為閾值 13 newCap = oldThr; 14 else { // 初始閾值為零時使用預設值 15 newCap = DEFAULT_INITIAL_CAPACITY; 16 newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 17 } 18 if (newThr == 0) { 19 float ft = (float) newCap * loadFactor; 20 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ? (int) ft : Integer.MAX_VALUE); 21 } 22 threshold = newThr; 23 @SuppressWarnings({"rawtypes", "unchecked"}) 24 Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap]; 25 //擴容時,資料遷移處理 26 if (oldTab != null) { 27 for (int j = 0; j < oldCap; ++j) { 28 Node<K, V> e; 29 if ((e = oldTab[j]) != null) { 30 oldTab[j] = null; 31 if (e.next == null) // 判斷當前節點下有無連結串列結構 32 newTab[e.hash & (newCap - 1)] = e; 33 else if (e instanceof TreeNode) //判斷當前節點是否為紅黑樹結構 34 ((TreeNode<K, V>) e).split(this, newTab, j, oldCap); 35 else { // preserve order // 連結串列遷移處理 36 Node<K, V> loHead = null, loTail = null;//存與老表索引位置相同的節點 37 Node<K, V> hiHead = null, hiTail = null;//存老表索引+oldCap的節點 38 Node<K, V> next; 39 do {table = newTab; 40 next = e.next; 41 if ((e.hash & oldCap) == 0) { //如果e.hash值與老表的容量進行‘&’運算,則擴容後的索引位置跟老表的索引位置一樣 42 if (loTail == null) 43 loHead = e; 44 else 45 loTail.next = e; 46 loTail = e; 47 } else {//如果e.hash值與原表的容量進行‘&’運算結果為1,則擴容的後的索引位置為 老表索引+oldCap 48 if (hiTail == null) 49 hiHead = e; 50 else 51 hiTail.next = e; 52 hiTail = e; 53 } 54 } while ((e = next) != null); 55 if (loTail != null) { 56 loTail.next = null; 57 newTab[j] = loHead; 58 } 59 if (hiTail != null) { 60 hiTail.next = null; 61 newTab[j + oldCap] = hiHead; 62 } 63 } 64 } 65 } 66 } 67 return newTab; 68 }
我們從上面原始碼第6行開始看,
if(oldCap>0){}
如果老表的容量大於0,判斷老表的是否超過最大容量,若超過將threshold閾值設定為Integer.MAX_VALUE,返回出老表。若新表的容量設為2被的老表容量(oldCap<<1,
左位移運算相當與乘2)小於MAXIMUM_CAPACITY(最大容量),並且老表容量oldCap大於等於DEFAULUT_INITIAL_CAPACITY(預設初始容量 16),則新表的閾值設定位2倍
老表的閾值。
else if(oldThr > 0){}
只有當老表的容量oldCap = 0 ,並且傳入了自定義容量值初始化出來的空表(老表的建立),也就是HashMap(int initialCapacity,float loadFactor)的構造方法
初始化建立的空HashMap,從構造器中可以看出並沒有屬性去接收該initialCapacity值,而是將該值通過tableSizeFor()方法計算出閾值,那麼老表的閾值(oldThr)則
為我們要新建立的HashMap的capacity,所以程式碼中將新表的容量設定為老表的閾值。
else{}
如果老表的容量oldCap = 0 ,並且 閾值threshold = 0,那麼就是沒有自定義引數new出來空的新HashMap,那麼閾值和容量則設定為預設值。
if(newThr == 0){}
當出現newThr == 0 的時,跟上面else if(oldThr > 0)的情況相同,即只有傳入自定義容量初始化出來的老表才能滿足 newThr == 0,亦可理解為程式碼執行了else if
(oldThr > 0 ) 就能滿足 newThr == 0 的條件,從原始碼中執行流程亦可看出此點。
重點來了,大家看到上面原始碼第41行,
e.hash&oldCap == 0
上面原始碼註解中提到,若此條件成立,一個節點從老表中遷移到新表中的下標值(index)不變,反之則下標值為老表下標值+老表容量。
為什麼呢?
我們從上面提到 put()方法內的 p = tab[i = (n-1) & hash] 程式碼來展開,假設老表的容量oldCap = 16,則擴容後的新表的容量newCap = 32 ,在老表中有index = 1 上的
Node節點要從老表遷移到新表上,按正常p = tab[i = (n-1) & hash]的方法來計算index,即 i = 31 & hash ,對於同一個key值算出來的 hash值是一樣的,那麼新表與老表決定i
值區別在於一個 31 ,一個 15 ,我們將其轉為二進位制表示:31為 11111 ,15為 1111 ,二進位制的31和15的後4位都是1,那麼對於同一個Node節點,不管在新表上還是在老表上 hash
&(n-1) 所得result的結果後4位都一樣,這時hash值倒數第5位就決定result。而二進位制只有0和1表示,所以倒數第5位數要麼是‘0’要麼是‘1’,那若同一Node節點在老表中result =
xxxx,那麼在新表中result值只可能有 0xxxx 和 1xxxx, 而1xxxx = xxxx + 10000,10000是16的二級製表示。到這裡結論已經顯而易見了。擴容後,老表節點遷移到新表的後節點的
索引只有兩種情況,要麼是原索引位置,要麼是原索引位置+原容量(老表容量),我們再看原始碼,原始碼設計者巧妙用 e.hash & oldCap == 0 一步直擊要點(真妙!令老夫誠服)
配個圖幫助大家更好理解:
圖4
5. get()方法實現
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; // table不為空 && table長度大於0 && table索引位置(根據hash值計算出)不為空 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; // first的key等於傳入的key則返回first物件 if ((e = first.next) != null) { // 向下遍歷 if (first instanceof TreeNode) // 判斷是否為TreeNode // 如果是紅黑樹節點,則呼叫紅黑樹的查詢目標節點方法getTreeNode return ((TreeNode<K,V>)first).getTreeNode(hash, key); // 走到這代表節點為連結串列節點 do { // 向下遍歷連結串列, 直至找到節點的key和傳入的key相等時,返回該節點 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; // 找不到符合的返回空 }
總結
1.HashMap結構由Node陣列+單向連結串列+黑紅樹組成,在陣列的桶位上若存在多個節點在同一桶位,則可能以單向連結串列或黑紅樹結構存在,當一個桶位個結構根據在該桶位的Node節點個數的
變化,在連結串列和紅黑樹直接變化,桶中Node節點個數為 0-8 個時以連結串列結構存在,當個數 >8時連結串列轉紅黑樹結構,當紅黑樹結構中Node節點 減少到6 個時轉為連結串列結構。
2.HashMap的預設容量16,預設載入因子0.75,容量必須是2的冪,擴容閾值為容量*載入因子的值。
3.HashMap put的過程,對key計算hash值,然後hash*(容量-1)得到index(桶位),如果沒有衝突直接放入桶中,如果發生衝突以連結串列的結構連結在後面,當連結串列結構節點的個數超過8個
連結串列結構變成紅黑樹結構儲存,如果節點已存在直接替換,如果桶滿了(容量*載入因子)就執行resize進行擴容。
4.HashMap 中hash函式實現方式:將key.hashCode()值進行高16位與低16位的異或運算,充分增加參與運算的位數,增大hash結果的可能性,降低衝突概率。 解決衝突的辦法有開放定址
法和拉鍊法(開放定址法:如線性探查法,平方探查法,偽隨機序列法,雙雜湊函式法;拉鍊法:把所有hash值相同的記錄用單鏈表連線起來)。
5.HashMap 擴容後的對原資料的遷移,原表中的Node節點遷移至新表的下標位置變化,只可能在兩個位置,一個在原下標位置,另一個在原下標+原表容量位置。原始碼通過 hash & oldCap
判斷是否為0區分該兩中情況。導致這樣的根本原因是:1)table的容量始終為2的n次冪 2)索引的計算方法為 hash & (table.length-1)
6.執行緒安全性 hashMap是執行緒非安全的,hashtable和ConcurrentHashMap是執行緒安全的(hashtable和ConcurrentHashMap的區別這裡就不細說了,在後面CurrentHashMap原始碼解讀
一章再詳細介紹),所以在併發的場景下需要用ConcurrentHashMap來確保執行緒安全。