1. 程式人生 > >HashMap 原始碼解析(jdk 1.8)

HashMap 原始碼解析(jdk 1.8)

   在程式設計師的日常工作中,面試中常常涉及。對於大部分程式猿來說可能還僅僅停留於會用的一個層面,很少會去摸索它底層是如何實現的,實現的原理是什麼,面對這些的時候都是滿頭霧水。工作中的一次偶然讓作者本人也逐步跨進了原始碼的大門,逐漸發現其中趣味,一點點摸索原始碼創作者的奇思妙想,在不知覺中提升了自己的思維韌性。 ———— 在此與大家一起分享所得,一起討論一起提升自我,這也是本人開始寫部落格的初衷。所以以下有闡述的不對的地方請指出,謝謝


 概要

  本章我們重新從整體上認識HashMap,先從資料結構開始然後再到原始碼的實現。

  1. 常見的資料結構
  2. HashMap的資料結構
  3. HashMap 原始碼解析(基於JDK1.8)
  4. 總結

 

  常見的資料結構

   那麼常見的資料結構無非就是 陣列 連結串列 二叉樹 雜湊表 ,我們大概瞭解下它們的新增,查詢基礎操作執行效能:

      • 陣列採用一段連續的儲存單元來儲存資料,將所有元素按次序一次儲存。查詢操作需要遍歷陣列,逐一對比給定關鍵字和陣列元素
      • 連結串列:一條相互相連的資料節點表,每個節點由資料和指向下一個節點的指標組成。查詢操作需要遍歷連結串列逐一進行比對。根據指標域的不同可分為單向連結串列,雙向連結串列,迴圈連結串列,多向表(網狀表)
      • 二叉樹:二叉樹是非線性結構,即每個資料節點至多隻有一個前驅節點,但可以有多個後繼節點,它可以採用順序儲存結構和鏈式儲存結構
      • 雜湊表:以鍵 - 值(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來確保執行緒安全。