1. 程式人生 > >一篇文章徹底讀懂HashMap之HashMap原始碼解析(下)

一篇文章徹底讀懂HashMap之HashMap原始碼解析(下)

put函式原始碼解析

//put函式入口,兩個引數:key和value
public V put(K key, V value) {
    /*下面分析這個函式,注意前3個引數,後面
    2個引數這裡不太重要,因為所有的put
    操作後面的2個引數預設值都一樣 */
    return putVal(hash(key), key, 
              value, false, true);
    }
    
//下面是put函式的核心處理函式
final V putVal(int hash, K key, V value
               , boolean onlyIfAbsent
               ,boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; 
    int n, i;
    /*上面提到過HashMap是懶載入,所有
    put的時候要先檢查table陣列是否已經
    初始化了,沒有初始化得先初始化table
    陣列,保證table陣列一定初始化了 */
    if ((tab = table) == null
       || (n = tab.length) == 0)
        //這個函式後面有resize函式分析
        n = (tab = resize()).length;

    /*到這裡表示table陣列一定初始化了
    與上面get函式相同,指定key的Node,
    會儲存在在table陣列的i=(n-1)&hash
    下標位置,get的時候也是從table陣列
    的該位置搜尋 */
    if ((p = tab[i = (n - 1) & hash])
         == null)
        /*如果i位置還沒有儲存元素,則把
        當前的key,value封裝為Node,
        儲存在table[i]位置  */
        tab[i] = newNode(hash, key
                    , value, null);
    else {
         //下面部分程式碼接上這部分
     }

接上面else部分:

/*
如果table[i]位置已經有元素了,
則接下來的流程是:
首先判斷連結串列或者二叉樹中是否
已經存在key的鍵值對?
存在的話就更新它的value;不存在
的話把當前的key,value插入到
連結串列的末尾或者插入到紅黑樹中
如果連結串列或者紅黑樹中已經存在
Node.key等於key,則e指向該Node,
即e指向一個Node:該Node的key屬性
與put時傳入的key引數相等的那個Node,
後面會更新e.value
 */

Node<K,V> e; K k;
/*
為什麼get和put先判斷p.hash==hash,
下面的if條件中去掉hash的比較邏輯
也是正確?因為hash的比較是兩個整數
的比較,比較的代價相對較小,key是泛型,
物件的比較比整數比較代價大,所以先比較
hash,hash相等再比較key
*/
if(p.hash == hash &&
  ((k = p.key) == key 
  || (key != null
  && key.equals(k))))
  /*
  e指向一個Node:該Node的key
  屬性與put時傳入的key引數相等
  的那個Node
              */
      e = p;
 else if (p instanceof TreeNode)
    /*
    紅黑樹的插入操作,如果已經存在
    該key的TreeNode,則返回該
    TreeNode,否則返回null
     */
     e = ((TreeNode<K,V>)p)
         .putTreeVal(this
         , tab, hash, key, value);
 else {
 /*
 table[i]處存放的是連結串列,接下來和
 TreeNode類似在遍歷連結串列過程中先判斷
 當前的key是否已經存在,如果存在則令
 e指向該Node;否則將該Node插入到鏈
 表末尾,插入後判斷連結串列長度是否>=8,
 是的話要進行額外操作
  */

     //binCountt最後的值是連結串列的長度
     for (int binCount = 0;
                  ;++binCount) {
         if ((e = p.next) == null) {
        /*
        遍歷到了連結串列最後一個元素,接下來
        執行連結串列的插入操作,先封裝為Node,
        再插入p指向的是連結串列最後一個節點,
        將待插入的Node置為p.next,
        就完成了單鏈表的插入
          */
             p.next = newNode(hash, key
                       , value, null);
             if (binCount 
                >= TREEIFY_THRESHOLD - 1)
             /*
             TREEIFY_THRESHOLD值是8,
             binCount>=7,然後又插入了一個新節
             點,連結串列長度>=8,這時要麼進行擴容
             操作,要麼把連結串列結構轉為紅黑樹結構。
             我們接下會分析treeifyBin的原始碼實現
                                         */
             treeifyBin(tab, hash);
             break;
         }

         /*
          當p不是指向連結串列末尾的時候:先判斷
          p.key是否等於key,等於的話表示
          當前key已經存在了,令e指向p,
          停止遍歷,最後會更新e的value;
          不等的話準備下次遍歷,
          令p=p.next,即p=e。 
                      */
         if (e.hash == hash &&
             ((k = e.key) == key 
             || (key != null
             && key.equals(k))))
             break;
         p = e;
     }
 }


 if (e != null) {
 /*
表示當前的key在put之前已經
存在了,並且上面的邏輯保證:
e已經指向了之前已經存在
的Node,這時更新
e.value就好。
      */

     //更新oldvalue
     V oldValue = e.value;

     /*
     onlyIfAbsent默是false,
     evict為true。
     onlyIfAbsent為true表示:
     如果之前已經存在key這個鍵值對了,
     那麼後面再put這個key時,忽略這個
     操作,不更新先前的value。
     這裡瞭解就好 
               */
     if (!onlyIfAbsent 
         || oldValue == null)
         //更新e.value
         e.value = value;

     /*
    這個函式的預設實現是“空”,
    即這個函式預設什麼操作都
    不執行,那為什麼要有它呢?
    這其實是個hook/鉤子函式,
    主要要在LinkedHashMap
    (HashMap子類)中使用,
    LinkedHashMap重寫了這
    個函式。以後會有講解
    LinkedHashMap的文章。
             */
     afterNodeAccess(e);
     //返回舊的value
     return oldValue;
 }
 }

 //如果是第一次插入key這個鍵,
//就會執行到這裡
 ++modCount;//failFast機制

 /*
 size儲存的是當前HashMap中儲存
 了多少個鍵值對,HashMap的size
 方法就是直接返回size之前說過,
 threshold儲存的是當前table數
 組長度*loadfactor,如果table
 陣列中儲存的Node數量大於
 threshold,這時候會進行擴容,
 即將table陣列的容量翻倍。
 後面會詳細講解resize方法。
   */
 if (++size > threshold)
     resize();

 //這也是一個hook函式,作用和
 //afterNodeAccess一樣
     afterNodeInsertion(evict);
     return null;
 }  

(11)treeifyBin原始碼解析

//將連結串列轉換為紅黑樹結構,在連結串列的
//插入操作後呼叫
final void treeifyBin
              (Node<K,V>[] tab
               , int hash) {
    int n, index; 
    Node<K,V> e;

    /*MIN_TREEIFY_CAPACITY值
    是64,也就是當連結串列長度>8的
    時候,有兩種情況:如果table
    陣列的長度<64,此時進行擴容
    操作;如果table陣列的長度>64,
    此時進行連結串列轉紅黑樹結構的操作.
    具體轉細節在面試中幾乎沒有問的,
    這裡不細講了,大部同學認為連結串列長度
    >8一定會轉換成紅黑樹,這是不對的!
    */
    if (tab == null || 
    (n = tab.length)
     < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e=tab[index=(n-1) 
                       & hash])
             != null) {
        TreeNode<K,V> hd = null, 
                      tl = null;
        do {
            TreeNode<K,V> p =
                replacementTreeNode(e
                , null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
} 

HashMap的resize函式原始碼分析
重點中的重點,面試談到HashMap必考resize相關知識,整體思路介紹:

有兩種情況會呼叫當前函式:

1.之前說過HashMap是懶載入,第一次hHashMap的put方法的時候table還沒初始化,這個時候會執行resize,進行table陣列的初始化,table陣列的初始容量儲存在threshold中(如果從構造器中傳入的一個初始容量的話),如果建立HashMap的時候沒有指定容量,那麼table陣列的初始容量是預設值:16。即,初始化table陣列的時候會執行resize函式

2.擴容的時候會執行resize函式,當size的值>threshold的時候會觸發擴容,即執行resize方法,這時table陣列的大小會翻倍。

注意我們每次擴容之後容量都是翻倍( *2),所以HashMap的容量一定是2的整數次冪,那麼HashMap的容量為什麼一定得是2的整數次冪呢?(面試重點)。

要知道原因,首先回顧我們put key的時候,每一個key會對應到一個桶裡面,桶的索引是這樣計算的: index = hash & (n-1),index的計算最為直觀的想法是:hash%n,即通過取餘的方式把當前的key、value鍵值對雜湊到各個桶中;那麼這裡為什麼不用取餘(%)的方式呢?

原因是CPU對位運算支援較好,即位運算速度很快。另外,當n是2的整數次冪時:hash&(n-1)與hash%(n-1)是等價的,但是兩者效率來講是不同的,位運算的效率遠高於%運算。

基於上面的原因,HashMap中使用的是hash&(n-1)。這還帶來了一個好處,就是將舊陣列中的Node遷移到擴容後的新陣列中的時候有一個很方便的特性:

HashMap使用table陣列儲存Node節點,所以table陣列擴容的時候(陣列擴容一定得是先重新開闢一個數組,然後把就陣列中的元素重新雜湊(rehash)到新陣列中去。

這裡舉一個例子來來說明這個特性:下面以Hash初始容量n=16,預設loadfactor=0.75舉例(其他2的整數次冪的容量也是類似的),預設容量:n=16,二進位制:10000;n-1:15,n-1二進位制:01111。某個時刻,map中元素大於16*0.75=12,即size>12。此時會發生擴容,即會新建了一個數組,容量為擴容前的兩倍,newtab,len=32。

接下來我們需要把table中的Node搬移(rehash)到newtab。從table的i=0位置開始處理,假設我們當前要處理table陣列i索引位置的node,那這個node應該放在newtab的那個位置呢?下面的hash表示node.key對應的hash值,也就等於node.hash屬性值,另外為了簡單,下面的hash只寫出了8位(省略的高位的0),實際上hash是32位:node在newtab中的索引:

index = hash % len=hash & (len-1)

=hash & (32 - 1)=hash & 31

=hash & (0x0001_1111);

再看node在table陣列中的索引計算:

i = hash & (16 - 1) = hash & 15

= hash & (0x0000_1111)。

注意觀察兩者的異同:

i = hash&(0x0000_1111);

index = hash&(0x0001_1111)

上面表示式有個特點:

index = hash & (0x0001_1111)

= hash & (0x0000_1111)

| hash & (0x0001_0000)

= hash & (0x0000_1111) | hash & n)

= i + ( hash & n)

什麼意思呢:

hash&n要麼等於n要麼等於0;也就是:inde要麼等於i,要麼等於i+n;再具體一點:當hash&n==0的時候,index=i;

當hash&n==n的時候,index=i+n;這有什麼用呢?當我們把table[i]位置的所有Node遷移到newtab中去的時候:

這裡面的node要麼在newtab的i位置(不變),要麼在newtab的i+n位置;也就是我們可以這樣處理:把table[i]這個桶中的node拆分為兩個連結串列l1和類:如果hash&n==0,那麼當前這個node被連線到l1連結串列;否則連線到l2連結串列。這樣下來,當遍歷完table[i]處的所有node的時候,我們得到兩個連結串列l1和l2,這時我們令newtab[i]=l1,newtab[i+n]=l2,這就完成了table[i]位置所有node的遷移/rehash,這也是HashMap中容量一定的是2的整數次冪帶來的方便之處。

下面的resize的邏輯就是上面講的那樣。將table[i]處的Node拆分為兩個連結串列,這兩個連結串列再放到newtab[i]和newtab[i+n]位置.

(12)resize方法原始碼解析

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) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //正常擴容:newCap = oldCap << 1
        else if ((newCap = oldCap << 1)
                 < MAXIMUM_CAPACITY 
                 && oldCap 
                 >= DEFAULT_INITIAL_CAPACITY)
            //容量翻倍,擴容後的threshold
            //自然也是*2
            newThr = oldThr << 1; 
    }
    else if (oldThr > 0) 
    // initial capacity was placed 
    //in threshold
       newCap = oldThr;
    else {
       // zero initial threshold 
       //signifies  using defaults
       //table陣列初始化的時候會進入到這裡
  
       //預設容量
        newCap = DEFAULT_INITIAL_CAPACITY;
        //threshold
        newThr = (int)(DEFAULT_LOAD_FACTOR
               * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap*loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY 
        && ft < (float)MAXIMUM_CAPACITY ?
               (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;//更新threshold
    @SuppressWarnings({"rawtypes"
                 ,"unchecked"})
    //擴容後的新陣列
    Node<K,V>[] newTab = (Node<K,V>[])
                         new Node[newCap];
    table = newTab;//執行容量翻倍的新陣列
    if (oldTab != null) {
    //之後完成oldTab中Node遷移到table中去
            //見下面
            }
        }
    }
    return newTab;
}    
}
//之後完成oldTab中Node遷移到table中去
for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {
        oldTab[j] = null;
        if (e.next == null)
        /*j這個桶位置只有一個元素,直接
        rehash到table陣列 */
            newTab[e.hash 
                  & (newCap - 1)] = e;
        else if (e instanceof TreeNode)
      /*如果是紅黑樹:也是將紅黑樹拆分為
      兩個連結串列,這裡主要看連結串列的拆分,
      兩者邏輯一樣*/
            ((TreeNode<K,V>)e).split(
            this, newTab, j, oldCap);
        else { 
            //連結串列的拆分
            //第一個連結串列l1
            Node<K,V> loHead = null
                    , loTail = null;

            //第二個連結串列l2
            Node<K,V> hiHead = null
                      , hiTail = null;
            Node<K,V> next;
            do {
                next = e.next;
                if ((e.hash & oldCap)
                    == 0) {
                /*rehash到table[j]位置
                將當前node連線到l1上  */
                    if (loTail == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                }
                else {
                  //將當前node連線到l2上
                    if (hiTail == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                }
            } while ((e = next) != null);

            if (loTail != null) {
                //l1放到table[j]位置
                loTail.next = null;
                newTab[j] = loHead;
            }
            if (hiTail != null) {
           //l1放到table[j+oldCap]位置
                hiTail.next = null;
                newTab[j + oldCap] = hiHead;
            }
        }
    }
}

HashMap面試“明星”問題彙總,及答案
你知道HashMap嗎,請你講講HashMap?
這個問題不單單考察你對HashMap的掌握程度,也考察你的表達、組織問題的能力。個人認為應該從以下幾個角度入手(所有常見HashMap的考點問題總結):
size必須是2的整數次方原因
get和put方法流程
resize方法
影響HashMap的效能因素(key的hashCode函式實現、loadFactor、初始容量)
HashMap key的hash值計算方法以及原因(見上面hash函式的分析)
HashMap內部儲存結構:Node陣列+連結串列或紅黑樹
table[i]位置的連結串列什麼時候會轉變成紅黑樹(上面原始碼中有講)
HashMap主要成員屬性:threshold、loadFactor、HashMap的懶載入
HashMap的get方法能否判斷某個元素是否在map中
HashMap執行緒安全嗎,哪些環節最有可能出問題,為什麼?
HashMap的value允許為null,但是HashTable和ConcurrentHashMap的valued都不允許為null,試分析原因?
HashMap中的hook函式(在後面講解LinkedHashMap時會講到,這也是面試時拓展的一個點)

上面問題的答案都可以在上面的原始碼分析中找到,下面在給三點補充:
HashMap的初始容量是怎樣影響HashMap的效能的?
假如你預先知道最多往HashMap中儲存64個元素,那麼你在建立HashMap的時候:如果選用無參構造器:預設容量16,在儲存16loadFactor個元素之後就要進行擴容(陣列擴容涉及到連續空間的分配,Node節點的rehash,代價很高,所以要儘量避免擴容操作);如果給構造器傳入的引數是64,這時HashMap中在儲存64loadFactor個元素之後就要進行擴容;但是如果你給構造器傳的引數為:(int)(64/0.75)+1,此時就可以保證HashMap不用進行擴容,避免了擴容時的代價。

HashMap執行緒安全嗎,哪些環節最有可能出問題,為什麼?
我們都知道HashMap執行緒不安全,那麼哪些環節最優可能出問題呢,及其原因:沒有參照這個問題有點不好直接回答,但是我們可以找參照啊,參照:ConcurrentHashMap,因為大家都知道HashMap不是執行緒安全的,ConcurrentHashMap是執行緒安全的,對照ConcurrentHashMap,看看ConcurrentHashMap在HashMap的基礎之上增加了哪些安全措施,這個問題就迎刃而解了。後面會有分析ConcurrentHashMap的文章,這裡先簡要回答這個問題:HashMap的put操作是不安全的,因為沒有使用任何鎖;HashMap在多執行緒下最大的安全隱患發生在擴容的時候,想想一個場合:HashMap使用預設容量16,這時100個執行緒同時往HashMap中put元素,會發生什麼?擴容混亂,因為擴容也沒有任何鎖來保證併發安全,另外,後面的博文會講到ConcurrentHashMap的併發擴容操作是ConcurrentHashMap的一個核心方法。

HashMap的value允許為null,但是HashTable和ConcurrentHashMap的value 都不允許為null,試分析原因?
首先要明確ConcurrentHashMap和Hashtable從技術從技術層面講是可以允許value為null;但是它是實際是不允許的,這肯定是為了解決一些問題,為了說明這個問題,我們看下面這個例子(這裡以ConcurrentHashMap為例,HashTable也是類似)。
HashMap由於允value為null,get方法返回null時有可能是map中沒有對應的key;也有可能是該key對應的value為null。所以get不能判斷map中是否包含某個key,只能使用contains判斷是否包含某個key。
看下面的程式碼段,要求完成這個一個功能:如果map中包含了某個key則返回對應的value,否則丟擲異常:
if (map.containsKey(k)) {
return map.get(k);
} else {
throw new KeyNotPresentException();
}
如果上面的map為HashMap,那麼沒什麼問題,因為HashMap本來就是執行緒不安全的,如果有併發問題應該用ConcurrentHashMap,所以在單執行緒下面可以返回正確的結果
如果上面的map為ConcurrentHashMap,此時存在併發問題:在map.containsKey(k)和map.get之間有可能其他執行緒把這個key刪除了,這時候map.get就會返回null,而ConcurrentHashMap中不允許value為null,也就是這時候返回了null,一個根本不允許出現的值?
但是因為ConcurrentHashMap不允許value為null,所以可以通過map.get(key)是否為null來判斷該map中是否包含該key,這時就沒有上面的併發問題了。