原始碼分析系列1:HashMap原始碼分析(基於JDK1.8)

qianby發表於2021-09-09

1.HashMap的底層實現圖示

  圖片描述

  如上圖所示:

  HashMap底層是由  陣列+(連結串列)=(紅黑樹) 組成,每個儲存在HashMap中的鍵值對都存放在一個Node節點之中,其中包含了Key-Value之外,還包括hash值(key.hashCode()) ^ (h >>> 16)) 以及執行下一個節點的指標next。

 

2.HashMap原始碼分析

2.1 重要常量

圖片描述

public class HashMap<K,V> extends AbstractMap<K,V>    implements Map<K,V>, Cloneable, Serializable {    private static final long serialVersionUID = 362498820763181265L;    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  
         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;    static final int MIN_TREEIFY_CAPACITY = 64;
    transient Node<K,V>[] table;   
        transient Set<Map.Entry<K,V>> entrySet; 
     transient int size;
     transient int modCount;
       int threshold;
    final float loadFactor;

圖片描述

  • DEFAULT_INITIAL_CAPACITY : HashMap的預設容量,16

  • MAXIMUM_CAPACITY : HashMap的最大支援容量,2^30

  • DEFAULT_LOAD_FACTOR:HashMap的預設載入因子

  • TREEIFY_THRESHOLD:Bucket中連結串列長度大於該預設值,轉化為紅黑樹

  • UNTREEIFY_THRESHOLD:Bucket中紅黑樹儲存的Node小於該預設值,轉化為連結串列

  • MIN_TREEIFY_CAPACITY:桶中的Node被樹化時最小的hash表容量。(當桶中Node的數量大到需要變紅黑樹時,若hash表容量小於MIN_TREEIFY_CAPACITY時,此時應執行resize擴容操作這個MIN_TREEIFY_CAPACITY的值至少是TREEIFY_THRESHOLD的4倍。)

  • table:儲存元素的陣列,總是2的n次冪

  • entrySet:儲存具體元素的集

  • size:HashMap中儲存的鍵值對的數量

  • modCount:HashMap擴容和結構改變的次數。

  • threshold:擴容的臨界值,=容量*填充因子

  • loadFactor:填充因子

2.2 重要方法

1.獲取hash值  hash

    static final int hash(Object key) {        int h;        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

  hash方法用傳入的key的hashCode和hashCode無符號右移16位的結果,做異或運算後作為hash值返回。

注:之所以獲取hashCode後,還需要和右移16位的hashCode做異或運算,原因是:在根據hash值獲取鍵值對在bucket陣列中的下標時,採用的演算法是:index=h & (length-1),當陣列的length較小時,只有低位能夠參與到“與”運算中,但是將hashCode右移16位再與本身做異或獲取到的hash,可以使高低位均能夠參與到後面的與運算中。

下面圖說明:

圖片描述

2.根據鍵值對數量獲取HashMap容量方法   tableSizeFor

圖片描述

    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;
    }

圖片描述

tabSizeFor方法,主要根據傳入的鍵值對容量,來返回大於容量的最小的二次冪數值。

演算法如下: 

 將傳入的容量-1:至於這裡為什麼需要減1,是為了防止cap已經是2的冪。如果cap已經是2的冪, 又沒有執行這個減1操作,則執行完後面的幾條無符號右移操作之後,返回的capacity將是這個cap的2倍,各位可自行驗證:圖片描述

 

圖片描述

圖片描述

圖片描述

 假設原始n:    0001  xxxx xxxx xxxx

第一次右移1位+或運算:二進位制序列出現至少兩個連續的1,如 0001 1xxx xxxx xxxx;
第二次右移2位+或運算:二進位制序列出現至少四個連續的1,如 0001 111x xxxx xxxx;

第三次右移4位+或運算:二進位制序列出現至少八個連續的1, 如 0001 1111 1111 xxxx;

第四次右移8位+或運算:二進位制序列至少出現16個連續的1,如 0001 1111 1111 1111;
第五次右移16位+或運算:二進位制序列至少出現32個連續的1,如 0001 1111 1111 1111;
  上述運算中,若出現右移後為0,則或運算得到的結果和原始值一致,則後續推導過程可以忽略。
此時可以保證,原始序列從包含1的最高位,到最低位,全部都變成了1.
最後+1,返回的結果就是大於原值的最小二次冪數。
  

圖片描述

3.插入方法   putVal

  

圖片描述

 1  final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 2                    boolean evict) { 3                     4         Node<K,V>[] tab;    //儲存Node節點的陣列tab 5         Node<K,V> p;         //單個Node節點p 6         int n, i;            //HashMap的容量n 7         //初始化陣列桶table 8         if ((tab = table) == null || (n = tab.length) == 0) 9             n = (tab = resize()).length;10         //如果陣列桶中不包含要插入的元素,將新鍵值對作為新Node存入陣列,11         if ((p = tab[i = (n - 1) & hash]) == null)    //此處p初始化,p和需要儲存的鍵值對下標相同且P是連結串列的第一個元素12             tab[i] = newNode(hash, key, value, null);13             14         //桶中包含要插入的元素15         else {16             Node<K,V> e; K k;17             //如果key和連結串列第一個元素p的key相等18             if (p.hash == hash &&19                 ((k = p.key) == key || (key != null && key.equals(k))))20             //則將e指向該鍵值對21                 e = p;22             //若p是TreeNode型別,則使用紅黑樹的方法插入到樹中23             else if (p instanceof TreeNode)24                 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);25             //else:鍵值對的引用不在連結串列的第一個節點,此時需要遍歷連結串列    26             else {27                 for (int binCount = 0; ; ++binCount) {28                     //將e指向p的下一個元素,直到其next為null時,將鍵值對作為新Node放到p的尾部或樹中。29                     if ((e = p.next) == null) {30                         p.next = newNode(hash, key, value, null);31                         //如果遍歷連結串列的長度大於等於8,則變成樹32                         if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st33                             treeifyBin(tab, hash);34                         break;   //新元素已追加到連結串列尾部或樹中,退出遍歷35                     }36                     //在衝突連結串列中找到另一個具有相同key值的節點,退出遍歷37                     if (e.hash == hash &&38                         ((k = e.key) == key || (key != null && key.equals(k))))39                         break;40                         41                     //將e指向p,便於下次遍歷e = p.next42                     p = e;43                 }44                 //上述for迴圈執行完畢後,e要麼指向了儲存的新節點,要麼是原來已有的元素,具有和新節點一樣key值45             }46             //當e非空時,說明e是原來HashMap中的元素,具有和新節點一樣的key值47             if (e != null) { // existing mapping for key48                 V oldValue = e.value;49                 if (!onlyIfAbsent || oldValue == null)    //onlyIfAbsent 表示是否僅在 oldValue 為 null 的情況下更新鍵值對的值50                     e.value = value;51                 //空實現,LinkedHashMap用52                 afterNodeAccess(e);53                 return oldValue;54             }55         }56         //HashMap結構更改,modCount+157         ++modCount;58         //判斷是否需要擴容59         if (++size > threshold)60             resize();61         //空實現,LinkedHashMap用62         afterNodeInsertion(evict);63         return null;64     }

圖片描述

  HashMap中進行儲存的入口方法是:put(K,V),但是核心方法是putVal方法,該方法一共有以下步驟:

  1. 初始化陣列桶

  2. 判斷陣列桶中對應下標是否無元素存在,是,就直接存入

  3. 若陣列桶中對應下標有元素存在,則開始遍歷,根據長度將元素存入連結串列尾部或樹中。

  4. 判斷是否需要擴容

  

4.擴容方法   resize

圖片描述

  1 final Node<K,V>[] resize() {  2         Node<K,V>[] oldTab = table;  3         int oldCap = (oldTab == null) ? 0 : oldTab.length;    //原HashMap的容量  4         int oldThr = threshold;                                //原HashMap的擴容臨界值                  5         int newCap, newThr = 0;  6         //case1:odlCap>0,說明桶陣列已經初始化過  7         if (oldCap > 0) {  8             //原HashMap的越界檢查  9             if (oldCap >= MAXIMUM_CAPACITY) { 10                 threshold = Integer.MAX_VALUE; 11                 return oldTab; 12             } 13             //容量擴大一倍後的越界檢查 14             else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 15                      oldCap >= DEFAULT_INITIAL_CAPACITY) 16                 newThr = oldThr << 1; // double threshold 17         } 18         //case2:oldCap=0 && oldThr >0,桶陣列尚未初始化,當呼叫帶初始化容量的建構函式時會發生該情況 19         else if (oldThr > 0) // initial capacity was placed in threshold 20             //在前面HashMap的初始化中,將Initial capcity暫存在threshold中 21             newCap = oldThr; //未初始化,則用Initial capcity作為新的容量 22              23             //若oldThr = threshold = 0,則說明未傳入初始化容量引數 24              25         //case3:oldCap=0 && oldThr = 0,當呼叫無參建構函式時會發生該情況,此時使用預設容量初始化 26         else {               // zero initial threshold signifies using defaults 27             newCap = DEFAULT_INITIAL_CAPACITY;    //預設容量 28             newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);    //預設擴容臨界值 29         } 30          31         // 當newThr 為 0 時,閾值按照計算公式進行計算 32         if (newThr == 0) { 33             float ft = (float)newCap * loadFactor; 34             newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 35                       (int)ft : Integer.MAX_VALUE); 36         } 37          38         threshold = newThr; 39         /* 40         * 在上面的操作中,主要是獲取了Resize之後的新的Capcity和新的擴容臨界值threshold 41         */ 42          43         @SuppressWarnings({"rawtypes","unchecked"}) 44             //上面獲取到的新的Capcity,來建立一個新的桶陣列 newTab,並指向table 45             Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 46         table = newTab; 47         //若oldTab非空,則需要將原來桶陣列的元素取出來放到新的桶陣列中 48         if (oldTab != null) { 49             for (int j = 0; j < oldCap; ++j) { 50                 Node<K,V> e; 51                 if ((e = oldTab[j]) != null) { 52                     oldTab[j] = null;    //將原桶陣列的元素佔用的空間釋放,便於GC 53                     if (e.next == null)    
 54                         //若桶中元素的next為空,獲取index後直接將其放入新桶陣列中 55                         newTab[e.hash & (newCap - 1)] = e; 56                         //若桶中元素的next是樹節點 57                     else if (e instanceof TreeNode) 58                         //採用樹的方式插入 59                         ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); 60                         //若桶中元素的next是連結串列節點 61                     else { // preserve order 62                         Node<K,V> loHead = null, loTail = null; 63                         Node<K,V> hiHead = null, hiTail = null; 64                         Node<K,V> next; 65                          66                         /*遍歷原連結串列,按照原來的順序進行分組 67                         */ 68                          69                          70                         /*原始連結串列中的元素,在resize之後,其下標有兩種可能,一種是在原來下標處,另一種是原來下標+oldCap處 71                         *舉例說明:  若原來的容量 -1後 只有n位,低位有n個1,去下標公式為:i = (oldCap - 1) & hash,若hash值只有低n為有值,則與運算後獲得的值和 72                         *擴容前是一樣的,若hash不止第n位有值,那採用與運算後,結果比原來剛好大oldCap。 下面有圖片示例) 73                         */ 74

圖片描述

 75                          76                         do { 77                             next = e.next 78                             //若e.e.hash & oldCap 結果為0,則下標在新桶陣列中不用改變,此時,將元素存放在loHead為首的連結串列中 79                             if ((e.hash & oldCap) == 0) { 80                                 if (loTail == null) 81                                     loHead = e; 82                                 else 83                                     loTail.next = e; 84                                 loTail = e; 85                             } 86                              87                             //若e.e.hash & oldCap 結果不為0,則下標在新桶陣列等於原下標+oldCap,此時,將元素存放在hiHead為首的連結串列中 88                             else { 89                                 if (hiTail == null) 90                                     hiHead = e; 91                                 else 92                                     hiTail.next = e; 93                                 hiTail = e; 94                             } 95                         } while ((e = next) != null); 96                          97                          98                          99                         if (loTail != null) {        //loHead為首的連結串列中,下標不改變100                             loTail.next = null;101                             newTab[j] = loHead;102                         }103                         if (hiTail != null) {        //hiHead為首的連結串列中,下標=原下標+oldCap104                             hiTail.next = null;105                             newTab[j + oldCap] = hiHead;106                         }107                     }108                 }109             }110         }111         return newTab;        
112     }

圖片描述

  上述程式碼分析較長,總結如下:

1.獲取不同情況下的 新的容量 和 新的擴容臨界值

2.根據新容量建立新的桶陣列tab。

3.根據節點型別,樹節點和連結串列節點分別採用對應方法放入新的桶陣列

 

5.移除元素  remove

圖片描述

 1  final Node<K,V> removeNode(int hash, Object key, Object value, 2                                boolean matchValue, boolean movable) { 3         Node<K,V>[] tab; Node<K,V> p; int n, index; 4          5         //透過hash值獲取下標,下標對應的節點p不為空 6         if ((tab = table) != null && (n = tab.length) > 0 && 7             (p = tab[index = (n - 1) & hash]) != null) { 8             Node<K,V> node = null, e; K k; V v; 9             //若節點p的key和待移除的節點key相等10             if (p.hash == hash &&11                 ((k = p.key) == key || (key != null && key.equals(k))))12                 //則p就是待移除節點13                 node = p;    //將p指向待移除節點14             //p的key和待移除的節點key不相等,遍歷p作為頭的連結串列或者樹15             else if ((e = p.next) != null) {16                 //若p是樹節點17                 if (p instanceof TreeNode)18                     //採用樹節點方式獲得要移除的節點19                     node = ((TreeNode<K,V>)p).getTreeNode(hash, key);20                 else {//p是連結串列的頭節點21                     do {22                         //採用迴圈,當p.next不為空,比對它和傳入的key,直到找到相等的key23                         if (e.hash == hash &&24                             ((k = e.key) == key ||25                              (key != null && key.equals(k)))) {26                             //找到後,將節點指向node27                             node = e;    //將e指向待移除節點,此時相當於p.next就是待移除的節點node,可自行驗證迴圈便知28                             break;29                         }30                         p = e;  
31                     } while ((e = e.next) != null);32                 }33             }34             //若node非空,傳入的matchValue引數為flase或 node的value等於傳入value35            if (node != null && (!matchValue || (v = node.value) == value ||36                                  (value != null && value.equals(v)))) {37                 //若node是樹節點38                 if (node instanceof TreeNode)39                     //採用樹節點的方式移除40                     ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);41                     //若待移除節點是連結串列頭,將其指向待移除元素的next,移除對node的引用42                 else if (node == p)43                     tab[index] = node.next;  
44                 else//待移除元素是連結串列中的元素,此時其等於p.next45                     //將p.next指向node.next,移除了對node的引用46                     p.next = node.next;47                 //增加結構修改計數器48                 ++modCount;49                 //size-150                 --size;51                 //空實現,LinkedHashMap用52                 afterNodeRemoval(node);53                 54                 //返回移除的節點node55                 return node;56             }57         }58         return null;59     }

圖片描述

 

    移除節點的入口方法是: public V remove(Object key)  ,其核心方法是removeNode,主要做了以下幾個工作:

  1. 透過用key獲取的hash,來獲取下標。

  2. 若下標對應處無元素,返回null。

  3. 若下標對應處有元素,判斷是樹或者連結串列,採用對應方法移除。  

6.查詢元素方法 get

圖片描述

 1     final Node<K,V> getNode(int hash, Object key) { 2         Node<K,V>[] tab; Node<K,V> first, e; int n; K k; 3         //根據hash值,獲取對應下標的第一個元素first 4         if ((tab = table) != null && (n = tab.length) > 0 && 5             (first = tab[(n - 1) & hash]) != null) { 6             //如果first的key和待查詢的key相等,返回first 7             if (first.hash == hash && // always check first node 8                 ((k = first.key) == key || (key != null && key.equals(k)))) 9                 return first;10             //若first不是待查詢的元素11             if ((e = first.next) != null) {12                 //若first是樹節點,採用樹節點的方式獲取13                 if (first instanceof TreeNode)14                     return ((TreeNode<K,V>)first).getTreeNode(hash, key);15                 //first是連結串列節點頭,使用迴圈獲取16                 do {17                     if (e.hash == hash &&18                         ((k = e.key) == key || (key != null && key.equals(k))))19                         return e;20                 } while ((e = e.next) != null);21             }22         }23         return null;24     }

圖片描述

    查詢元素的入口方法是:public V get(Object key),返回值是node的value,核心方法是getNode(int hash, Object key)。

2.3 構造方法

1.無參建構函式

    public HashMap() {        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

  使用所有預設引數來構造一個HashMap,我們常用的就是這種。

2.給出容量的建構函式 

    public HashMap(int initialCapacity) {        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

  此處呼叫了下面這個建構函式,將給出的容量傳入和預設負載因子。

3.給出容量和負載因子的建構函式

圖片描述

    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);  //此處將初始化的容量暫存到threshold中
    }

圖片描述

4.用一個map來初始化的建構函式

    public HashMap(Map<? extends K, ? extends V> m) {        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

  此處將map中所有元素放入HashMap進行初始化。

3.常見問題解答

3.1 HashMap的容量為什麼必須是2的n次冪?

    答:當容量是2的n次冪時,不同的key獲取在桶陣列中的下標相同的機率減小,發生Hash碰撞機率減少,元素分佈更加均勻,見下圖。

  圖片描述

結論:

1.由上述例項可以看出,當HashMap容量為2的n次冪的時候,length-1,可以使得在計算index的"&"運算過程中,hash值的對應位都能參與到計算;若HashMap容量不等於2的n次冪,leng-1後必然有一些位是等於0的,那麼在計算index的"&"運算過程中,對應位的數字無論是0或者1,都未能參與到運算中。導致Hash衝突機率增大。

2.初次之外,若HashMap容量不為2的n次冪,無論Hash值如何變化,始終有一些下標值無法取得,因為"&"運算過程中,必然有一些位置結果始終為0,如例項所示,其最低位始終為0,因此下標 1(二進位制0000 0001)、3(二進位制0000 0011)、5(二進位制0000 0101)、7(二進位制0000 0111)等下標、永遠都獲取不了。造成了容量的浪費

3.2 HashMap的時間複雜度?

  答:O(1)或者O(log(n))或O(n),分析如下:

  根據第一節的內容可知,根據HashMap的資料結構,可能有以下三種情況:

  1.無連結串列和紅黑樹,理想狀態。每個桶只有一個或0個元素,不存在Hash衝突,此時時間複雜度為O(1);但此時耗費的記憶體空間較大。

  2.只有連結串列,此時因為需要迴圈連結串列來獲取元素,時間複雜度為O(n)

  3.只有紅黑樹,此時時間複雜度為紅黑樹查詢的時間複雜度,O(log(n)).

  4.連結串列和紅黑樹均有,此時時間複雜度依據紅黑樹的層數和連結串列長度而定。為O(n)或者O(log(n)).

3.3 負載因子LoadFactor為何預設值為0.75。

  當負載因子過大時,Hash衝突機率增加、讀寫的時間複雜度也大大增加,當負載因子過小時,Hash衝突機率較小,時間複雜度較低,但佔用記憶體空間較大。

  至於為什麼預設值是0.75,這是一個基於時間和空間複雜度的綜合考慮的結果,可以參考這篇文章:

3.4 作為HasHMap的key有什麼條件?

  使用HashMap,若用int/String等作為key值,因為Integer類和String類都以及重寫了equals()和hashCode()方法.

  但是如果key是自定義的類,就必須重寫hashcode()和equals()。理由如下:

  

圖片描述

//在插入元素中,根據hash值後,與length-1做&運算獲取下標       //獲取hash
        static final int hash(Object key) {        int h;        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }       //獲取下標
       p = tab[i = (n - 1) & hash]    //用equals方法比對key值和節點的key值,來確認是否遍歷到所需元素
     (key != null && key.equals(k)))    
/*對比hash值,如果不重寫hashCode方法,那麼採用Object類的預設的hash方法是獲取記憶體地址,此時即使兩個key物件相等,但其記憶體地址不可能相等,所以必須重寫hashCode方法。*//*equals方法若不重寫,採用的Object的equals方法,比對的是記憶體地址,如果不重寫,會造成兩個一樣的key值都插入,存在重複元素*/        
//同理,在查詢過程中,在第二節putVal方法中有分析,會用到hash值,以及用到key.equals方法,因此如果不重寫equals()和hashCode(),會造成雖然元素存在,但是因記憶體地址不一致,差找不到對應元素。

圖片描述

3.5 HashMap key允許為空嗎?,最多幾個?

  答:允許但只允許一個key值為空。當key值為空時,其hash值為0,因此在桶陣列中位置是0,即第一個元素。

  那麼為什麼不能有兩個key值為null呢,原因是兩個key為null,是一樣的,後面put進去的(null,value2)會覆蓋先put進去的(null,value1)。驗證如下:

  圖片描述

  圖片描述

3.6 HashMap value允許為空嗎?最多幾個?

  答:允許,可以有多個value為null,檢視原始碼可知,在putVal中沒有對value進行限制,驗證如下:

  圖片描述

寫在最後:

  1.本文中設計到數操作的都沒有詳細介紹,因為紅黑樹本身概念較為抽象複雜,打算下一篇文章中再來詳細分析一下,還有其他一些類似於“map.clear()、map.ContainsKey()”等等邏輯較為簡單的方法也未作贅述。

  2.不得不感嘆一些設計Java集合類的大牛是真的牛,看似一個簡單的HashMap中、對於位運算、連結串列。紅黑樹的應用可謂是爐火純青,看起來都不能一目瞭然,設計時那更是天馬行空,匠心獨運。

原文出處:https://www.cnblogs.com/LearnAndGet/p/9971526.html  

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/3549/viewspace-2817828/,如需轉載,請註明出處,否則將追究法律責任。

相關文章