1. 程式人生 > >HashMap原理分析總結

HashMap原理分析總結

HashMap即雜湊表,是面試時最常見的一類問題,也是應用非常廣泛的一種集合,現在將HashMap的學習總結一下。
先來看HashMap中幾個很重要的變數:

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    static final int TREEIFY_THRESHOLD = 8;

其中,DEFAULT_INITIAL_CAPACITY為HashMap的初始容量,預設為16;
MAXIMUM_CAPACITY為HashMap的最大容量,為2^30;
DEFAULT_LOAD_FACTOR為負載因子,預設為0.75,負載因子是用來衡量HashMap中元素數量與HashMap容量的比值大小的,換句話說,負載因子乘以HashMap容量即是擴容閾值,如果HashMap中的元素數量達到了這個擴容閾值,那麼HashMap就會進行擴容,後面會進行分析;
TREEIFY_THRESHOLD為連結串列重構為紅黑樹閾值,預設為8。

1.HashMap的結構
HashMap的資料結構在jdk 1.7及以前是簡單的陣列+連結串列結構,在這種結構下,如果陣列某一index下的連結串列過長,則會導致資料存取的時間複雜度達到O(N),為此,jdk 1.8對其進行了優化,定義了閾值TREEIFY_THRESHOLD = 8,當連結串列長度達到該閾值時,連結串列則會重構為紅黑樹,在紅黑樹資料結構下,資料存取的時間複雜度為O(logN),因此,HashMap的資料結構在jdk 1.8後為陣列+連結串列+紅黑樹,如下圖所示。
在這裡插入圖片描述


其中,陣列的每個元素均是Node型的,需要注意的是,在jdk 1.8之前每個元素的定義為Entry,在jdk1.8中改為了Node,實際上二者區別不大,Node的定義如下:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
......
}

可以看到,每一個Node中包含了相應的鍵值對資訊、hash值以及指向下一個結點的指標,即相當於各連結串列的頭結點。

2.put原理
put函式用於向HashMap中新增鍵值對,在新增時,首先需要通過hash函式獲取待插入鍵值對的key值所對應的hash值,然後將該hash值與陣列長度取餘最終得到key在HashMap中對應的index,然後再訪問index,遍歷index下的所有結點,如果key值存在那麼就用新的value進行覆蓋,否則將鍵值對插入連結串列中,原始碼如下:

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)
        n = (tab = resize()).length;   //初始化桶,預設16個元素
    if ((p = tab[i = (n - 1) & hash]) == null)   //如果第i個桶為空,建立Node例項
        tab[i] = newNode(hash, key, value, null);
    else { //雜湊碰撞的情況, 即(n-1)&hash相等
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;   //key相同,後面會覆蓋value
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  //紅黑樹添加當前node
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);  //連結串列添加當前元素
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);  //當連結串列個數大於等於7時,將連結串列改造為紅黑樹
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;            //覆蓋key相同的value並return, 即不會執行++size
        }
    }
    ++modCount;
    if (++size > threshold)    //key不相同時,每次插入一條資料自增1. 當size大於threshold時resize
        resize();
    afterNodeInsertion(evict);
    return null;
}

需要注意的是,在這段原始碼中直接使用if ((p = tab[i = (n - 1) & hash]) == null)一句來找出相應的index,而不是使用indexFor函式(實際上indexFor函式也就是這一句),n為HashMap容量,將n-1再與hash按位與,最終得到的i即是鍵值對對應的index了。為什麼這裡i 就等於(n - 1) & hash呢?首先我們需要想到,這裡的index對映原則是均勻分配,即是任意一個hash值其所對應的index在這n個桶中的概率是相同的,假設現在的n為預設值16,那麼很顯然index的範圍是0 ~ 15的,15即是16-1,其對應的二進位制即是0000 ~ 1111,也就是說,如果我將所有鍵值對對應的hash值以低四位二進位制位進行區分,那麼所有hash值必定是等可能的位於0000 ~ 1111之間,這樣就實現了均勻分配,那麼這種低四位二進位制位區分具體是怎麼實現呢?

很簡單,即是用1111與各hash值進行按位與,這樣每個hash值的按位與結果必定在0000 ~ 1111之間,這樣也就能夠確定其所在的桶了,用n-1與各hash值按位與,所得結果必定也是0 ~ n-1,達到了hash%n的效果(因為hash%n所得結果也是位於0~n-1之間的),而在另一方面,由於取模運算的效率是低於按位與運算的效率的,因此往往用按位與而不是取模,不過使用按位與代替取模的前提是n為2的m次冪,因為如果n-1中某一位為0,那麼根據按位與的結果時無法確定hash值在該位是0還是1的,比如說n-1的二進位制位101,那麼111和101與n-1按位與的結果均是101,這樣hash值的低三位為111和101的鍵值對的index均是5(101),index為7(111)的桶就不再可能放入元素了,這樣也就不能達到均勻分配的目的,因此,n-1的二進位制位中間不能存在0,即必須保證HashMap的容量為2的m次冪。

這也是非常重要的一點,**一定要保證HashMap的容量為2的m次冪,這樣才能使用(n - 1) & hash來代替hash%n,從而即提高效率,也保證各hash值對應的Index均勻分配。**即使在建立HashMap時元素個數並不是2的m次冪,HashMap的容量最終也會取大於元素個數的最小的2的m次冪作為HashMap的容量。比如n=6,那麼實際的HashMap容量則為8。

此外,根據這段程式中的 if ((e = p.next) == null) {p.next = newNode(hash, key, value, null); 可以發現,在插入元素的時候是先找到當前index下連結串列的末端(p.next==null),然後將新元素插入末端後(p.next = newNode(hash, key, value, null))此時新元素的next指標指向Null,由此可以看出插入元素時使用的是尾插法。

需要注意的是,這裡尾插法也是在jdk 1.8時進行的改變,在其之前,插入新元素的程式碼如下:

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//當size超過臨界閾值threshold,並且即將發生雜湊衝突時進行擴容,新容量為舊容量的2倍
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);//擴容後重新計算插入的位置下標
        }
        //把元素放入HashMap的桶的對應位置
        createEntry(hash, key, value, bucketIndex);
    }
//建立元素  
    void createEntry(int hash, K key, V value, int bucketIndex) {  
        Entry<K,V> e = table[bucketIndex];  //獲取待插入位置元素
        table[bucketIndex] = new Entry<>(hash, key, value, e);//這裡執行連結操作,使得新插入的元素指向原有元素。
//這保證了新插入的元素總是在連結串列的頭  
        size++;//元素個數+1  
    }  


`
Entry<K,V> e = table[bucketIndex];

table[bucketIndex] = new Entry<>(hash, key, value, e);`

不難看出,這種插入新元素採取的方法是頭插法。那為什麼在jdk 1.8會做出改變採用尾插法呢?很明顯頭插法只需要找到頭結點插入即可,而尾插法需要遍歷至尾結點再插入,頭插法顯然是效率更高的,那為什麼要採取尾插法呢?原因就在於jdk 1.8為了在連結串列長度過長時提高元素存取效率,採取了連結串列重構為紅黑樹的方法,JDK1.7是用單鏈表進行的縱向延伸,當採用頭插法就是能夠提高插入的效率,但是也會容易出現逆序且環形連結串列死迴圈問題。但是在JDK1.8之後是因為加入了紅黑樹使用尾插法,能夠避免出現逆序且連結串列死迴圈的問題。

3.get原理
get函式相比於put函式更加簡單,與put函式相類似,通過key找出相應的index,然後遍歷index下的連結串列各結點,判斷各結點的key值與待獲取的key值是否相等即可,這裡不做贅述。

4.擴容原理
前面提到,當HashMap中的元素數量達到了擴容閾值,那麼HashMap就會進行擴容,擴容閾值=負載因子*HashMap容量,HashMap的元素個數是指陣列以及連結串列和樹中所有元素的個數之和。由於HashMap的容量必須為2的倍數,那麼HashMap擴容後的容量大小即是當前容量大小的兩倍,比如說,當前HashMap的容量為16,擴容後的容量即是32。擴容後,如前所述,index = (n - 1) & hash,n變了,那麼每個key所對應的index也就變了,那麼就需要對HashMap進行rehash,即對所有元素的index進行重定位。jdk1.8 對resize做出了很大的優化,在其之前,rehash程式碼如下:

void resize(int newCapacity) {  
        Entry[] oldTable = table;//老的資料  
        int oldCapacity = oldTable.length;//獲取老的容量值  
        if (oldCapacity == MAXIMUM_CAPACITY) {//老的容量值已經到了最大容量值  
            threshold = Integer.MAX_VALUE;//修改擴容閥值  
            return;  
        }  
        //新的結構  
        Entry[] newTable = new Entry[newCapacity];  
        transfer(newTable, initHashSeedAsNeeded(newCapacity));//將老的表中的資料拷貝到新的結構中  
        table = newTable;//修改HashMap的底層陣列  
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改閥值  
    }  

void transfer(Entry[] newTable, boolean rehash) {  
        int newCapacity = newTable.length;//容量  
        for (Entry<K,V> e : table) { //遍歷所有桶
            while(null != e) {  //遍歷桶中所有元素(是一個連結串列)
                Entry<K,V> next = e.next;  
                if (rehash) {//如果是重新Hash,則需要重新計算hash值  
                    e.hash = null == e.key ? 0 : hash(e.key);  
                }  
                int i = indexFor(e.hash, newCapacity);//定位Hash桶  
                e.next = newTable[i];//元素連線到桶中,這裡相當於單鏈表的插入,總是插入在最前面
                newTable[i] = e;//newTable[i]的值總是最新插入的值
                e = next;//繼續下一個元素  
            }  
        }  
    }  

可見,jdk 1.8之前的rehash是需要每個元素重新通過indexFor函式計算Index,然後將元素插入到新陣列中相應位置,再來看jdk 1.8的rehash:

final Node<K,V>[] resize() {
         ....

         newThr = oldThr << 1; // double threshold,   大小擴大為2倍
   if (e.next == null)
      newTab[e.hash & (newCap - 1)] = e;  //如果該下標只有一個數據,則雜湊到當前位置或者高位對應位置(以第一次resize為例,原來在第4個位置,resize後會儲存到第4個或者第4+16個位置)
  else if (e instanceof TreeNode)
     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);  //紅黑樹重構

   else {

     do {
        next = e.next;
        if ((e.hash & oldCap) == 0) {    
            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; //下標位置移動原來容量大小
      }

可以發現,這裡採用的方法是判斷(e.hash & oldCap) == 0,為什麼可以這樣判斷呢?前面可以知道,以n=16為例,擴容前衝突的各key所對應的hash值的低4位均是相同的,此時擴容後n=32,再進行按位與操作的物件則是低5位了,而第5位要麼是0要麼是1,如果是0,那麼說明該hash值在擴容後的index仍舊不會改變(0xxxx==xxxx),而如果是1,那麼說明該hash在擴容後的index會改變,由xxxx變為了1xxxx,index增加量剛好就是擴容前的容量大小。
因此,直接將hash值與原先的容量大小按位與,hash值與16按位與即是測試hash值的倒數第5位是否為0,如果為0,那麼Index無變化,否則index就需要加上擴容前的容量大小作為新的index值。

先記錄一下,以後再補充…