為什么jdk1.8 HashMap的容量一定要是2的n次冪


一、jdk1.8中,對“HashMap的容量一定要是2的n次冪”做了嚴格控制
  1.默認初始容量:
[Java]  純文本查看 復制代碼
?
1
2
3
4
/**
  * 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)構造函數
[Java]  純文本查看 復制代碼
?
1
2
3
public HashMap( int initialCapacity) {
        this (initialCapacity, DEFAULT_LOAD_FACTOR);
    }
  ②HashMap(int initialCapacity, float loadFactor)
[Java]  純文本查看 復制代碼
?
01
02
03
04
05
06
07
08
09
10
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)方法中:
[Java]  純文本查看 復制代碼
?
01
02
03
04
05
06
07
08
09
10
11
12
/**
      * 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的代碼好懂很多:
[Java]  純文本查看 復制代碼
?
1
2
3
4
5
6
7
8
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做初始化;
    ③將鍵值對節點重新放到新的桶數組里;
[Java]  純文本查看 復制代碼
?
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
    final Node<K,V>[] resize() { //擴容
     
//---------------- -------------------------- 1.計算新容量(新桶) newCap 和新閾值 newThr。 ---------------------------------
  
         Node<K,V>[] oldTab = table;
         int oldCap = (oldTab == null ) ? 0 : oldTab.length; //看容量是否已初始化
         int oldThr = threshold; //下次擴容要達到的閾值。threshold(閾值) = capacity * loadFactor。
         int newCap, newThr = 0 ;
         if (oldCap > 0 ) { //容量已初始化過了:檢查容量和閾值是否達到上限《==========
             if (oldCap >= MAXIMUM_CAPACITY) { //oldCap >= 2^30,已達到擴容上限,停止擴容
                 threshold = Integer.MAX_VALUE;
                 return oldTab;
             }
        // newCap < 2^30 && oldCap > 16,還能再擴容:2倍擴容
             else if ((newCap = oldCap << 1 ) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
                 newThr = oldThr << 1 ; // 擴容:閾值*2。(注意:閾值是有可能越界的)
         }
         //容量未初始化 && 閾值 > 0。
         //【啥時會滿足層判斷:使用HashMap(int initialCapacity, float loadFactor)或 HashMap(int initialCapacity)構造函數實例化HashMap時,threshold才會有值。】
         else if (oldThr > 0 )
             newCap = oldThr; //初始容量設為閾值
         else { //容量未初始化 && 閾值 <= 0 :
             //【啥時會滿足這層判斷:①使用無參構造函數實例化HashMap時;②在“if (oldCap > 0)”判斷層newThr溢出了。】
             newCap = DEFAULT_INITIAL_CAPACITY;
             newThr = ( int )(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
         }
         if (newThr == 0 ) { //什么情況下才會進入這個判斷框:前面執行了else if (oldThr > 0),並沒有為newThr賦值,就會進入這個判斷框。
             float ft = ( float )newCap * loadFactor;
             newThr = (newCap < MAXIMUM_CAPACITY && ft < ( float )MAXIMUM_CAPACITY ? ( int )ft : Integer.MAX_VALUE);
         }
         threshold = newThr;
         
//------------------------------------------------------2.擴容:------------------------------------------------------------------
         
         @SuppressWarnings ({ "rawtypes" , "unchecked" })
             Node<K,V>[] newTab = (Node<K,V>[]) new Node[newCap]; //擴容
         table = newTab;
         
//--------------------------------------------- 3.將鍵值對節點重新放到新的桶數組里。------------------------------------------------
         
         …… //此處源碼見下文“二、2.”
 
         return newTab;
     }
  通過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源碼實現:
[Java]  純文本查看 復制代碼
?
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
final Node<K,V>[] resize() { //擴容方法
     
//---------------- -------------------------- 1.計算新容量(新桶) newCap 和新閾值 newThr: -------------------------------------------
  
         …… //此處源碼見前文“一、3.”
         
//---------------------------------------------------------2.擴容:------------------------------------------------------------------
         
         …… //此處源碼見前文“一、3.”
         
//--------------------------------------------- 3.將鍵值對節點重新放到新的桶數組里:------------------------------------------------
         
         if (oldTab != null ) { //容量已經初始化過了:
             for ( int j = 0 ; j < oldCap; ++j) { //一個桶一個桶去遍歷,j 用於記錄oldCap中當前桶的位置
                 Node<K,V> e;
                 if ((e = oldTab[j]) != null ) { //當前桶上有節點,就賦值給e節點
                     oldTab[j] = null ; //把該節點置為null(現在這個桶上什么都沒有了)
                     if (e.next == null ) //e節點后沒有節點了:在新容器上重新計算e節點的放置位置《===== ①桶上只有一個節點
                         newTab[e.hash & (newCap - 1 )] = e;
                     else if (e instanceof TreeNode) //e節點后面是紅黑樹:先將紅黑樹拆成2個子鏈表,再將子鏈表的頭節點放到新容器中《===== ②桶上是紅黑樹
                         ((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 ) { //“定位值等於0”的為一組:
                                 if (loTail == null )
                                     loHead = e;
                                 else
                                     loTail.next = e;
                                 loTail = e;
                             }
                             else { //“定位值不等於0”的為一組:
                                 if (hiTail == null )
                                     hiHead = e;
                                 else
                                     hiTail.next = e;
                                 hiTail = e;
                             }
                         } while ((e = next) != null );
 
               //將分好的子鏈表放到newCap中:
                         if (loTail != null ) {
                             loTail.next = null ;
                             newTab[j] = loHead; //原鏈表在oldCap的什么位置,“定位值等於0”的子鏈表的頭節點就放到newCap的什么位置
                         }
                         if (hiTail != null ) {
                             hiTail.next = null ;
                             newTab[j + oldCap] = hiHead; //“定位值不等於0”的子節點的頭節點在newCap的位置 = 原鏈表在oldCap中的位置 + oldCap
                         }
                     }
                 }
             }
         }
         return newTab;
     }
  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次冪,是為了提高“計算元素放哪個桶”的效率,也是為了提高擴容效率(避免了擴容后再重復處理哈希碰撞問題)。 
更多技術資訊可關注:itheimaGZ獲取


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM