jdk1.8 HashMap底層資料結構:深入解析為什麼jdk1.8 HashMap的容量一定要是2的n次冪
前言
1.本文根據jdk1.8原始碼來分析HashMap的容量取值問題;
2.本文有做 jdk1.8 HashMap.resize()擴容方法的原始碼解析:見下文“一、3.擴容:同樣需要保證擴容後的容量是2的n次冪”;
3.目錄:
一、jdk1.8中,對“HashMap的容量一定是2的n次冪”做了嚴格控制
1.預設初始容量
2.使用HashMap的有參建構函式來自定義容量的大小(保證容量是2的n次冪)
3.擴容:同樣需要保證擴容後的容量是2的n次冪( jdk1.8 HashMap.resize()擴容方法的原始碼解析)
二、為什麼HashMap的容量一定要是2的n次冪?或者說,保證“HashMap的容量一定是2的n次冪”有什麼好處?
1.關係到元素在桶中的位置計算問題
2.關係到擴容後元素在newCap中的放置問題
2.1 原始碼解析
2.2 深入分析(含圖解)
一、jdk1.8中,對“HashMap的容量一定要是2的n次冪”做了嚴格控制
1.預設初始容量:
/** * The default initial capacity - MUST be a power of two.(預設初始容量——必須是2的n次冪。) */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16(16 = 2^4)
2.使用HashMap的有參建構函式來自定義容量的大小(保證容量是2的n次冪):
HashMap總共有4個建構函式,其中有2個建構函式可以自定義容量的大小:
①HashMap(int initialCapacity):底層呼叫的是②HashMap(int initialCapacity, float loadFactor)建構函式
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
②HashMap(int initialCapacity, float loadFactor)
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity);//tableSizeFor(initialCapacity)方法是重點!!!
}
這裡有個問題:使用①或②建構函式來自定義容量時,怎麼能夠保證傳入的容量一定是2的n次冪呢?
答案就在標記出來的tableSizeFor(initialCapacity)方法中:
/** * 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; }
上面這段程式碼的作用:
假如你傳的cap是5,那麼最終的初始容量為8;假如你傳的cap是24,那麼最終的初始容量為32。
這是因為5並非是2的n次冪,而大於等於5且距離5最近的2的n次冪是8(8 = 2^3);同樣的,24也並非2的n次冪,大於等於24且距離24最近的2的n次冪是32(32 = 2^5)。
假如你傳的cap是64,那麼最終的初始容量就是64,因為64是2^6,它就是等於cap的最小2的n次冪。
總結起來就一句話:通過位移運算,找到大於或等於 cap 的 最小2的n次冪。
jdk1.7的初始容量處理機制和上面jdk1.8具有相同的作用,但1.7的程式碼好懂很多:
public HashMap(int initialCapacity, float loadFactor) { …… int capacity = 1; while (capacity < initialCapacity) { capacity <<= 1; } …… }
3.擴容:同樣需要保證擴容後的容量是2的n次冪( jdk1.8 HashMap.resize()擴容方法的原始碼解析)
resize()擴容方法主要做了三件事(這裡這裡重點講前兩件事,第三件事在下文的“三、2.”中講):
①計算新容量(新桶) newCap 和新閾值 newThr;
②根據計算出的 newCap 建立新的桶陣列table,並對table做初始化;
③將鍵值對節點重新放到新的桶數組裡;
1 final Node<K,V>[] resize() { //擴容 2 3 //---------------- -------------------------- 1.計算新容量(新桶) newCap 和新閾值 newThr。 --------------------------------- 4 5 Node<K,V>[] oldTab = table; 6 int oldCap = (oldTab == null) ? 0 : oldTab.length;//看容量是否已初始化 7 int oldThr = threshold;//下次擴容要達到的閾值。threshold(閾值) = capacity * loadFactor。 8 int newCap, newThr = 0; 9 if (oldCap > 0) {//容量已初始化過了:檢查容量和閾值是否達到上限《========== 10 if (oldCap >= MAXIMUM_CAPACITY) {//oldCap >= 2^30,已達到擴容上限,停止擴容 11 threshold = Integer.MAX_VALUE; 12 return oldTab; 13 }
// newCap < 2^30 && oldCap > 16,還能再擴容:2倍擴容 14 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) 15 newThr = oldThr << 1; // 擴容:閾值*2。(注意:閾值是有可能越界的) 16 } 17 //容量未初始化 && 閾值 > 0。 18 //【啥時會滿足層判斷:使用HashMap(int initialCapacity, float loadFactor)或 HashMap(int initialCapacity)建構函式例項化HashMap時,threshold才會有值。】 19 else if (oldThr > 0) 20 newCap = oldThr;//初始容量設為閾值 21 else { //容量未初始化 && 閾值 <= 0 : 22 //【啥時會滿足這層判斷:①使用無參建構函式例項化HashMap時;②在“if (oldCap > 0)”判斷層newThr溢位了。】 23 newCap = DEFAULT_INITIAL_CAPACITY; 24 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 25 } 26 if (newThr == 0) {//什麼情況下才會進入這個判斷框:前面執行了else if (oldThr > 0),並沒有為newThr賦值,就會進入這個判斷框。 27 float ft = (float)newCap * loadFactor; 28 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); 29 } 30 threshold = newThr; 31 32 //------------------------------------------------------2.擴容:------------------------------------------------------------------ 33 34 @SuppressWarnings({"rawtypes","unchecked"}) 35 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//擴容 36 table = newTab; 37 38 //--------------------------------------------- 3.將鍵值對節點重新放到新的桶數組裡。------------------------------------------------ 39 40 ……//此處原始碼見下文“二、2.” 41 42 return newTab; 43 }
通過resize()擴容方法的原始碼可以知道:每次擴容,都是將容量擴大一倍,所以新容量依舊是2的n次冪。如oldCap是16的話,那麼newCap則為32。
通過上面三點可以確定,不論是預設初始容量,還是自定義容量大小,又或者是擴容後的容量,都必須保證一定是2的n次冪。
二、為什麼HashMap的容量一定要是2的n次冪?或者說,保證“HashMap的容量一定是2的n次冪”有什麼好處?
原因有兩個:
1.關係到元素在桶中的位置計算問題:
簡單來講,一個元素放到哪個桶中,是通過 “hash % capacity” 取模運算得到的餘數來確定的(注:“元素的key的雜湊值”在本文統一簡稱為“hash”)。
hashMap用另一種方式來替代取模運算——位運算:(capacity - 1) & hash。這種運算方式為什麼可以得到跟取模一樣的結果呢? 答案是capacity是2的N次冪。(計算機做位運算的效率遠高於做取模運算的效率,測試見:https://www.cnblogs.com/laipimei/p/11316812.html)
證明取模和位運算結果的一致性:
2.關係到擴容後元素在newCap中的放置問題:
擴容後,如何實現將oldCap中的元素重新放到newCap中?
我們不難想到的實現方式是:遍歷所有Node,然後重新put到新的table中, 中間會涉及計算新桶位置、處理hash碰撞等處理。這裡有個不容忽視的問題——雜湊碰撞。在元素put進桶中時,就已經處理過了雜湊碰撞問題:雜湊值一樣但通過equals()比較確定內容不同的元素,會在同一個桶中形成連結串列,連結串列長度 >=8 時將連結串列轉為紅黑樹;擴容時,需要重新處理這些元素的雜湊碰撞問題,如果資料量一大.......要完……
jdk1.8用了優雅又高效的方式來處理擴容後元素的放置問題,下面我們一起來看看jdk1.8到底是怎麼做的。
2.1 先看jdk1.8原始碼實現:
1 final Node<K,V>[] resize() { //擴容方法 2 3 //---------------- -------------------------- 1.計算新容量(新桶) newCap 和新閾值 newThr: ------------------------------------------- 4 5 …… //此處原始碼見前文“一、3.” 6 7 //---------------------------------------------------------2.擴容:------------------------------------------------------------------ 8 9 …… //此處原始碼見前文“一、3.” 10 11 //--------------------------------------------- 3.將鍵值對節點重新放到新的桶數組裡:------------------------------------------------ 12 13 if (oldTab != null) {//容量已經初始化過了: 14 for (int j = 0; j < oldCap; ++j) {//一個桶一個桶去遍歷,j 用於記錄oldCap中當前桶的位置 15 Node<K,V> e; 16 if ((e = oldTab[j]) != null) {//當前桶上有節點,就賦值給e節點 17 oldTab[j] = null;//把該節點置為null(現在這個桶上什麼都沒有了) 18 if (e.next == null)//e節點後沒有節點了:在新容器上重新計算e節點的放置位置《===== ①桶上只有一個節點 19 newTab[e.hash & (newCap - 1)] = e; 20 else if (e instanceof TreeNode)//e節點後面是紅黑樹:先將紅黑樹拆成2個子連結串列,再將子連結串列的頭節點放到新容器中《===== ②桶上是紅黑樹 21 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 22 else { // preserve order 23 Node<K,V> loHead = null, loTail = null; 24 Node<K,V> hiHead = null, hiTail = null; 25 Node<K,V> next; 26 do { //遍歷連結串列,並將連結串列節點按原順序進行分組《===== ③桶上是連結串列 27 next = e.next; 28 if ((e.hash & oldCap) == 0) {//“定位值等於0”的為一組: 29 if (loTail == null) 30 loHead = e; 31 else 32 loTail.next = e; 33 loTail = e; 34 } 35 else {//“定位值不等於0”的為一組: 36 if (hiTail == null) 37 hiHead = e; 38 else 39 hiTail.next = e; 40 hiTail = e; 41 } 42 } while ((e = next) != null);
//將分好的子連結串列放到newCap中: 43 if (loTail != null) { 44 loTail.next = null; 45 newTab[j] = loHead;//原連結串列在oldCap的什麼位置,“定位值等於0”的子連結串列的頭節點就放到newCap的什麼位置 46 } 47 if (hiTail != null) { 48 hiTail.next = null; 49 newTab[j + oldCap] = hiHead; //“定位值不等於0”的子節點的頭節點在newCap的位置 = 原連結串列在oldCap中的位置 + oldCap 50 } 51 } 52 } 53 } 54 } 55 return newTab; 56 }
2.2 深入分析(含圖解)
① 如果桶上只有一個節點(後面即沒連結串列也沒樹):元素直接做 “hash & (newCap - 1)” 運算,根據結果將元素節點放到newCap的相應位置;
②如果桶上是連結串列:
將連結串列上的所有節點做 “hash & oldCap” 運算(注意,這裡oldCap沒有-1),會得到一個定位值(“定位值”這個名字是我自己取的,為了更好理解該值的意義)。定位值要麼是“0”,要麼是“小於capacity的正整數”!這是個規律,之所以能得此規律和capacity取值一定是2的n次冪有直接關係,如果容量不是2的n次冪,那麼定位值就不再要麼是“0”,要麼是“小於capacity的正整數”,它還有可能是其他的數;
根據定位值的不同,會將連結串列一分為二得到兩個子連結串列,這兩個子連結串列根據各自的定位值直接放到newCap中:
子連結串列的定位值 == 0: 則連結串列在oldCap中是什麼位置,就將子連結串列的頭節點直接放到newCap的什麼位置;
子連結串列的定位值 == 小於capacity的正整數:則將子連結串列的頭節點放到newCap的“oldCap + 定位值”的位置;
這麼做的好處:連結串列在被拆分成兩個子連結串列前就已經處理過了元素的雜湊碰撞問題,子連結串列不用重新處理雜湊碰撞問題,可以直接將頭節點直接放到newCap的合適的位置上,完成 “擴容後將元素放到newCap”這一工作。正因為如此,大大提高了jdk1.8的HashMap的擴容效率。
下面將通過畫圖的形式,進一步理解HashMap到底是怎麼將元素放到newCap中的。
前面我們說了jdk1.8的HashMap元素放到哪個桶中哪個位置,是通過計算 “(capacity - 1) & hash” 得到的餘數來確定的。現在有四個元素,雜湊值分別為35、27、19、43,當“容量 = 8”時,計算所得餘數都等於3,所以這4個元素會被放到 table[3] 的位置,如下圖所示:
進行一次擴容後,現在容量 = 16,再次計算“(capacity - 1) & hash”後,這四個元素在newCap中的位置會有所變化:要麼在原位置,要麼在“oldCap + 原位置”;也就是說這四個元素被分成了兩組。如下圖所示:
下面我們不用 “(capacity - 1) & hash” 的方式來放置元素,而是根據jdk1.8中HashMap.resize()擴容方法來放置元素:先通過 “hash & oldCap” 得到定位值,再根據定位值同樣能將連結串列一分為二(見證奇蹟的時候到了):
“定位值 = 0”的為一組,這組元素就是前面將容量從8擴到16後,通過“(newCap - 1) & hash” 計算確定 “放回原位置” 的那些元素;
“定位值 != 0”的為一組,這組元素就是擴容後,確定 “oldCap + 原位置”的那些元素。 如下圖所示:
再將這兩組元素節點分別連線成子連結串列:loHead是 “定位值 == 0” 的子連結串列的頭節點;hiHead是 “定位值 != 0” 的子連結串列的頭節點。如下圖所示:
最後,將子連結串列的頭節點loHead放到newCap中,位置和在oldCap中的原位置一致;將另一個子連結串列的頭節點hiHead放到newCap的“oldCap + 原位置”上。到這裡HashMap就完成了擴容後將元素重新放到newCap中的工作了。如下圖所示:
到這裡其實我們已經把 “容量一定是2的n次冪是 提高擴容後將元素重新放到newCap中的效率 的前提”解釋完了,現在還有一個小小的問題——通過定位值將連結串列一分為二,會分得均勻嗎?如果分得很不均勻會怎麼樣?
眾所周知,要想HashMap的查詢速度快,那就得儘量做到讓元素均勻地散落到每個桶裡。將連結串列平均分成兩個子連結串列,就意味著讓元素更均勻地放到桶中了,增加了元素雜湊性,從而提高了元素的查詢效率。那jdk1.8又是如何將連結串列分得更平均的呢?這關係到兩點:①元素的雜湊值更隨機、雜湊;②通過“hash & oldCap”中的oldCap再次增加元素放置位置的隨機性。第①點和雜湊演算法的實現直接相關,這裡不細說;第②點的意思如下:
以 “capacity = 8” 為例,下面這些都是當 “容量 = 8” 時放在table[3]位置上的元素的hash值。擴容時做“hash & oldCap” 運算,通過下圖我們可以發現,oldCap所在的位上(即倒數第4位),元素的hash值在這個位是0還是1具有隨機性。
也就是說,jdk1.8在元素通過雜湊演算法使hash值已經具有隨機性的前提下,再做了一個增加元素放置位置隨機性的運算。
③如果桶上是紅黑樹:
將紅黑樹重新放到newCap中的邏輯和將連結串列重新放到newCap的的邏輯差不多。不同之處在於,重新放後,會將紅黑樹拆分成兩條由 TreeNode 組成的子連結串列:
此時,如果子連結串列長度 <= UNTREEIFY_THRESHOLD(即 <= 6 ),則將由 TreeNode組成的子連結串列 轉換成 由Node組成的普通子連結串列,然後再根據定位值將子連結串列的頭節點放到newCap中;
否則,根據條件重新將“由 TreeNode 組成的子連結串列”重新樹化,然後再根據定位值將樹的頭節點放到newCap中。
本文不對“HashMap擴容時紅黑樹在newCap中的重新放置”做詳細解釋,後面我會再寫一篇有關《紅黑樹轉回連結串列的具體時機》的博文,在這篇博文中會做詳細的原始碼解析。
一言蔽之:jdk1.8 HashMap的容量一定要是2的n次冪,是為了提高“計算元素放哪個桶”的效率,也是為了提高擴容效率(避免了擴容後再重複處理雜湊碰撞問題)。
【第一次寫這麼多字的博文,如有筆誤之處,還望在評論中幫忙指出,謝謝~】
&n