HashMap底層那些事(基於jdk1.8)

前言

首先,我們都知道HashMap是Java中提供的一種容器,它是以key-value對的形式進行數據存儲。本篇文章主要是對HashMap的存儲原理以及Jdk1.8中對HashMap的優化來進行講解。在此之前可以看一下HashMap的類繼承結構圖如下:

8b7cse.png

使用案例

在對源碼進行解析的之前,我們先來看一個簡單的創建存儲案例,本文之後的分析基於該案例進行:

 public class Bootstrap {
    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        map.put("name", "joe");
    }
}

源碼分析

上面的這個案例很簡單,創建了一個HashMap對象並且在該容器放入了name-joe這對屬性。接下來就從初始化和put這兩個主要的操作來說明HashMap是如何工作的。

初始化

在我們的工作中,最常用的可能就是是HashMap map = new HashMap()這種方式來進行初始化,那就先從這個入口進行:

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

通過這種方式進行初始化我們可以看到只是給loadFactor進行了默認的賦值,關於該變量的解釋我會在put方法中進行詳細說明,這邊只需要知道這個負載因子和HashMap的擴容有關係。

除了這種初始化的方式,還可以使用new 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);
    }

這種初始化的方式和上面的相比多了指定的兩個參數:

對於loadFactor負載因子沒有太多好說的(不過一般默認是0.75不會修改),只要滿足是Float類型的數據就好;對於initialCapatity有幾點需要注意:容器的大小需要在合法的限定範圍內,大於0並且小於等於1<<30,另外還需要注意的是HashMap會將容器大小維持成2的冪次方,這麼做的原因是爲了改善hash尋址和擴容的效率,這個我也會在put和擴容的解析中進行詳細的說明。

最後來看一下HashMap如何將傳入的initialCapatity轉換成2的冪次方的(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;
    }

HashMap通過無符號右移和按位或運算來將目標值轉換爲2的冪次方,轉換過程如下:

 # 假設initialCapatity的值是60,那麼n=59,至於爲何要減1再做運算,是爲了保證獲取到的值大於或者是等於n的
 # 1. n|=n>>>1
 0 0 1 1 1 0 1 1  
 0 0 0 1 1 1 0 1
 ---------------
 0 0 1 1 1 1 1 1
 # 2. n|=n>>>2
 0 0 1 1 1 1 1 1
 0 0 0 0 1 1 1 1
 ---------------
 0 0 1 1 1 1 1 1
 
 #3. n|=n>>>4
 0 0 1 1 1 1 1 1
 0 0 0 0 0 0 1 1
 ---------------
 0 0 1 1 1 1 1 1
 
 #4. n|=n>>>8
 0 0 1 1 1 1 1 1
 0 0 0 0 0 0 0 0 
 ---------------
 0 0 1 1 1 1 1 1
 
 #5. n|=n>>>16
 0 0 1 1 1 1 1 1
 0 0 0 0 0 0 0 0 
 ---------------
 0 0 1 1 1 1 1 1====>63
 
 # 6.返回n+1 64

通過這種位運算的方式可以高效獲得2的冪次方的值

到這邊初始化的工作基本就完成了,接下來就分析一下如何進行值的存放

put原理

存儲機制

在說明put是如何進行的之前,有必要先說一下HashMap的數據結構。首先HashMap是基於一個數組以及雙向鏈表(紅黑樹,這是jdk1.8的優化)。這裏我用一個圖來進行表示:

8q2Dqx.png

圖示:HashMap數據存儲結構圖

put源碼解析

從上面的圖中可以瞭解到HashMap完整的存儲結構,現在來分析一下執行具體的put操作後底層到底發生了什麼。

當案例中執行map.put("name", "joe");的時候,具體的代碼如下

 public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

可以看見,調用了putVal方法,在進入該方法之前,首先針對key進行了hash值的運算,具體代碼如下:

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

從這個代碼塊來看,首先可以確定HashMap的key是可以爲null的,它對應的hash就是0;除此之外,針對hash會採用讓高位參與按位運算的方式計算hash值,至於爲何要通過這種方式是因爲這樣的話儘量少的發生hash碰撞。因爲所有的key都會通過hash和數組的長度來進行數組位置的定位(取餘運算)。並且在HashMap中會通過(n-1)&hash值來進行取餘運算,而有一些key比如Float類型的值,計算出的hash值地位都是0,這樣的話會存在大量的key都定位在0位置從而hash分佈極不均勻,效率會大大降低。下面我通過一個例子來演示一下

# 假設數組長度爲16,如果傳入的key是一個Float a=1111.000001F;那麼它對應的hash值爲1149952000,轉換成二進制數,此時做(n-1)&hash運算
01000100100010101110000000000000
00000000000000000000000000001101
--------------------------------
00000000000000000000000000000000

可以發現如果低位都是0計算出來的結果也是0,但是當通過(h = key.hashCode()) ^ (h >>> 16)後我們再來一下結果

# h = key.hashCode()) ^ (h >>> 16)
01000100100010101110000000000000
-------------------------------
00000000000000000100010010001010

# 再進行(n-1)&hash運算
00000000000000000100010010001010
00000000000000000000000000001101
-------------------------------
00000000000000000000000000001000

通過讓高位參與運算後結果變成了8,藉助這種方式可以更大程度的避免hash衝突,提高存儲的效率

對key進行hash求值後,接下來就要在數組中進行數據的添加,涉及到的核心方法如下:

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)                           #1
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)                                    #2
            tab[i] = newNode(hash, key, value, null);                                 #3           
        else {                                                                        #4 
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))               #5 
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {                                                                    #6 
                for (int binCount = 0; ; ++binCount) {                                #7 
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);                     #8      
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st       
                            treeifyBin(tab, hash);                                    #9
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))       #10
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;                                                 #11
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;                                                  #12
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();                                                                 #13
        afterNodeInsertion(evict);
        return null;
    }

首先我對主要的步驟進行編號和講解,並且對比較重要的步驟可能會擴展補充,編號以**#**開頭,在上面的代碼中可以看見。

當一個put操作進來之後,會判斷table數組時候存在,如果不存在(#1)需要對數組進行初始化(關於擴容和初始化下面會額外講解);

如果table已經存在,通過(n-1)&hash<該操作在計算hash的時候已經有過說明>求出對應的數組下標,並且如果下標處的結點爲空(#2),則表示當前位置還沒有鏈表存在,直接創建一個新的節點即可(#3

對應的如果判斷節點已經存在(#4 ),則首先判斷頭結點是否就是我們需要的那個節點(需要判斷hash和key都相等,#5),這邊可以引申出爲什麼在Java中重寫了equals方法還要從hashcode方法,首先Java中的同一個對象如果equals方法相等,那麼hashcode返回也應該相等。如果不重寫,這對自定義的類,可能我們定義了只要指定屬性相等equals就返回true。但是如果沒有重寫hashcode方法的話,兩個對象返回的仍然是Object定義的默認hashcode結果(實際上就是對象地址),這樣的話在比較的時候我們邏輯上應該需要返回true但是會返回false,因此就會出現錯誤。並且代碼中借用hashcode先行判斷提高了效率,如果不符合直接返回false了。

在上面的情況都不滿足的話,那麼說明節點已經存在並且不是頭結點(#6)。這樣的話就要遍歷這個單向鏈表來查找對應的節點。如果遍歷到下一個節點爲空了,那麼就在鏈表尾部添加一個新的節點(#8),並且在添加換成之後,判斷節點數有沒有達到8個,如果達到了的話轉換成紅黑樹(#9)。如果在遍歷的過程中發現,有對應的節點已經存在,那麼結束遍歷,此時節點保存在臨時變量e中(#10)。

判斷是否是節點已經存在,如果是,獲取到舊的值(#11)並且把新值賦值給節點(#12)。到這一步put操作已經完成

最後是判斷put好之後,元素的個數是否已經大於閾值threshold(閾值會在擴容的時候提及),判斷是否進行擴容,如果需要進行擴容的話,還需要執行resize()方法。(#13

resize源碼解析

在put的過程中,有幾處地方需要進行擴容,接下分析下具體的擴容情況:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {                                               #1
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&                           #2        
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold                              
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;                                                                #3 
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;                                              #4
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);                 #5
        }
        if (newThr == 0) {                                                                  #6
            float ft = (float)newCap * loadFactor;                                           
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?           
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];                                 #7
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {                                              #8
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((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) {                                   #9
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

同樣,我還是會對關鍵步驟進行編號和講解:

首先,會對table的容量(oldCap)和閾值(oldThr)進行判斷,如果table容量大於0,表示已經存在了,那麼如果容量的一半小於
MAXIMUM_CAPACITY,則將容量值擴大兩倍(#1,#2)。

如果table容量沒有大於0,但是oldThr已經大於0了,表示初始化的時候設置了threshold(事實上就是初始化的時候initialCapacity進行2的冪次方的值),這種情況就把新的容量設置爲oldThr即可(#3)。

如果上述情況都不滿足,代表這是一個新的HashMap,還沒有任何值。此時把newCap設置爲DEFAULT_INITIAL_CAPACITY也就是16,並且把threshold設置爲newCap * loadFactor,到這邊就可以解釋loadFactor是用來設置閾值用的默認爲0.75(**#4,#5*)。

如果是oldThr但是table是空的情況,則表示知識指定了oldThr的值,但是table還沒有被初始化,這種情況newThr的值是newCap * loadFactor,和上一步的區別是這邊指定了容器大小(**#6*)。

接下來是初始化一個新的table數組,如果是第一次進來,對應的就是put中數組爲空時的resize,那麼到這邊resize就結束了,返回數組即可(**#7*)。

如果舊的數組已經有值了,那麼接下來就需要resize的核心操作,將舊的鏈表數據移動到新的數組中去(rehash)。對於如何移動我用一面一張圖來展示:

8L3UN8.png

圖示:HashMap擴容機制圖

可以看到由於hashMap是以2倍擴容,所以將容量初始化成2的冪次方也是提高了擴容的效率,因爲這樣子的話rehash的時候不要重新進行計算,因爲定位到的位置要麼是在原來的索引處,要麼是在oldCap+原來的索引數。可以看到HashMap還用e.hash & oldCap==0將原來單鏈表中的數據進行了劃分,如果滿足條件的節點還是待在原來的索引處,不滿足條件的移動到oldCap+原來的索引數這樣子的話讓hash更加的分散,效率也會有所提升,這也是jdk1.8的優化的地方

到這邊HashMap put和resize的源碼基本解讀完畢,對於樹的轉換由於篇幅原因這邊不詳細講解,有需要的可以自行查看源碼

總結

通過本篇文章可以瞭解到HashMap底層的運作原理,並且可以發現,它的擴容相對來說還是比較耗時的,因此如果是存儲比較多的數據的時候,最好初始化的時候就指定合適的大小。

另外,jdk1.8已經在減少hash碰撞(通過高位參與運算以及通過按位與運算代替取餘操作)和擴容(保持容器大小2的冪次方不用重新rehash)以及數據結構(加入紅黑樹)上都做了優化,進一步提高了效率。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章