1. 程式人生 > >HashMap及ConcurrentHashMap基本原理概述

HashMap及ConcurrentHashMap基本原理概述

0、前言

  • 本博文部分文字及圖片參考自以下三篇文章,其餘內容為本人經過思考及總結後所寫,僅作為學習分享使用,如有侵權,請聯絡本人刪除,謝謝。

1、HashMap基本原理

  • 眾所周知,HashMap是一個用於儲存Key-Value鍵值對的集合,每一個鍵值對也叫做Entry。這些個鍵值對(Entry)分散儲存在一個數組當中,這個陣列就是HashMap的主幹。

  • HashMap陣列每一個元素的初始值都是Null。


  • 對於HashMap,我們最常使用的是兩個方法:Get 和 Put。

  • Put方法的原理

    • 呼叫Put方法的時候發生了什麼呢?

    • 比如呼叫 hashMap.put("apple", 0) ,插入一個Key為“apple"的元素。這時候我們需要利用一個雜湊函式來確定Entry的插入位置(index):

      index =  Hash(“apple”)
      
    • 假定最後計算出的index是2,那麼結果如下:


    • 但是,因為HashMap的長度是有限的,當插入的Entry越來越多時,再完美的Hash函式也難免會出現index衝突的情況。比如下面這樣:


    • 這時候該怎麼辦呢?我們可以利用連結串列來解決。

    • HashMap陣列的每一個元素不止是一個Entry物件,也是一個連結串列的頭節點。每一個Entry物件通過Next指標指向它的下一個Entry節點。當新來的Entry對映到衝突的陣列位置時,只需要插入到對應的連結串列即可:

     
    • 需要注意的是,新來的Entry節點插入連結串列時,使用的是“頭插法”。之所以把Entry6放在頭節點,是因為HashMap的發明者認為,後插入的Entry被查詢的可能性更大。
  • Get方法的原理

    • 使用Get方法根據Key來查詢Value的時候,發生了什麼呢?

    • 首先依然會把輸入的Key做一次Hash對映,得到對應的index:

      index =  Hash(“apple”)
      
    • 由於剛才所說的Hash衝突,同一個位置有可能匹配到多個Entry,這時候就需要順著對應連結串列的頭節點,一個一個向下來查詢。假設我們要查詢的Key是“apple”:


      • 第一步,我們檢視的是頭節點Entry6,Entry6的Key是banana,顯然不是我們要找的結果。

      • 第二步,我們檢視的是Next節點Entry1,Entry1的Key是apple,正是我們要找的結果。

      • 在這裡get方法會沿著連結串列一直往下尋找,直到找到了key為apple的節點。若找到連結串列最尾端的時候(e.next=null)還找不到的話,則返回null

        • 所以當此處出現雙向迴圈連結串列的時候,那麼程式就會出現死迴圈,因為e.next永遠不會等於null

2、HashMap預設初始長度是多少,為什麼?

  • 預設長度是16,並且每次自動擴充套件或手動初始化時,長度必須是2的冪次方。

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    
  • 之前說過,從Key對映到HashMap陣列的對應位置,會用到一個Hash函式,如何實現一個儘量均勻分佈的Hash函式呢?我們通過利用Key的HashCode值來做某種運算。

  • 通常情況下,Key的HashCode值會是一個比較大的值,但我們HashMap的初始長度只有16,所以我們必須採取某些方法來將這個HashCode值和Map的長度值做一個對映轉換

    • 常見的做法就是將Key的HashCode值和Map的長度值進行求模運算,但模運算效率低,為了實現高效的演算法,HashMap採用了位運算的演算法。

    • 如何進行位運算呢?有如下的公式(Length是HashMap的長度):

      index =  key.hashCode() & Length - 1
      
    • 下面我們以“book"的Key來演示整個過程:

      • 1、計算book的hashcode,結果為十進位制的3029737,二進位制的101110001110101110 1001。

      • 2、假定HashMap長度是預設的16,計算Length-1的結果為十進位制的15,二進位制的1111。

      • 3、把以上兩個結果做與運算,101110001110101110 1001 & 1111 = 1001,十進位制是9,所以 index=9。

      • 可以說,Hash演算法最終得到的index結果,完全取決於Key的Hashcode值的最後幾位。

  • HashMap長度必須是2的冪次方,這樣才能保證Length-1的二進位制形式全是1。因為假如Length-1的值為1000的話,那麼其他數和1000進行與運算之後,結果就只有1000或0000這兩種情況,這樣就會造成大量的衝突,顯然不符合Hash演算法均勻分佈的原則。

3、HashMap的擴充套件

  • HashMap的容量是有限的。當經過多次元素插入,使得HashMap達到一定飽和度時,Key對映位置發生衝突的機率會逐漸提高。這時候,HashMap需要擴充套件它的長度,也就是進行Resize。


  • 影響發生Resize的因素有兩個:

    • 1、Capacity:HashMap的當前長度。

    • 2、LoadFactor:HashMap負載因子,預設值為0.75f。

  • 衡量HashMap是否進行Resize的條件如下,也就是說當HashMap中儲存的資料量超過總量的0.75倍的時候,則認為該HashMap已經超過負載,需要進行Resize:

                      HashMap.Size >= Capacity * LoadFactorHashMap.Size>=CapacityLoadFactor
  • Resize的步驟

    • 1、擴容

      • 建立一個新的Entry空陣列,長度是原陣列的2倍。
    • 2、ReHash

      • 遍歷原Entry陣列,把所有的Entry重新Hash到新陣列。為什麼要重新Hash呢?因為長度擴大以後,Hash的規則也隨之改變。

      • 讓我們回顧一下Hash公式:

        index =  key.hashCode() & Length - 1
        
      • 當原陣列長度為8時,Hash運算是和111B做與運算;新陣列長度為16,Hash運算是和1111B做與運算。Hash結果顯然不同。

      • Resize前的HashMap:


      • Resize後的HashMap:


      • ReHash的Java程式碼如下:

        /**
         * Transfers all entries from current table to newTable.
         */
        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) {
                        e.hash = null == e.key ? 0 : hash(e.key);
                    }
                    int i = indexFor(e.hash, newCapacity);
                    
                    // 頭插法,讓e.next指向連結串列中的最後一個節點
                    e.next = newTable[i];
                    // 然後讓e成為連結串列中的第一個節點
                    newTable[i] = e;
                    e = next;
                }
            }
        }
        
  • HashMap的擴充套件在多執行緒下會造成死迴圈

    • 如當前HashMap中存在兩個元素a和b,其中他們存放的地址衝突,即hash(a) = hash(b) = 0,此時該雜湊表的記憶體圖如下:


    • 假如現在有兩個執行緒分別對該HashMap執行put操作,此時HashMap由於容量不夠就需要進行擴容了,假設執行緒1先執行,在執行完Entry<K,V> next = e.next;這一句之後,cpu的時間片切換到了執行緒2上了,並且執行緒2順利地執行完畢,此時我們先看執行緒2執行完後的記憶體圖是怎樣的。


    • 此時又輪到執行緒1執行了,我們回顧下rehash的程式碼

      /**
       * Transfers all entries from current table to newTable.
       */
      void transfer(Entry[] newTable, boolean rehash) {
          int newCapacity = newTable.length;
          for (Entry<K,V> e : table) {
              while(null != e) {
              
                  // 執行緒1執行完後停在了這裡,e=0x001,next=0x009
                  Entry<K,V> next = e.next;
                  
                  // 執行緒1又執行了
                  // 與之前不同的是,原本是0x009.next = null,現在變成了0x009.next = 0x001
                  if (rehash) {
                      e.hash = null == e.key ? 0 : hash(e.key);
                  }
                  int i = indexFor(e.hash, newCapacity);
                  
                  e.next = newTable[i];
                  newTable[i] = e;
                  e = next;
              }
          }
      }
      
    • 執行緒1在while程式碼塊中執行了三次

      • 1、e=0x001,next=0x009,然後newTable[2] = 0x001,0x001.next = null
      • 2、e=0x009,next=0x001,然後newTable[2] = 0x009,0x009.next = 0x001
      • 3、e=0x001,next=null,然後newTable[2] = 0x001,0x001.next = 0x009
    • 最終記憶體圖如下:


    • 此時當呼叫Get查詢一個不存在的Key,而這個Key的Hash結果恰好等於2的時候,由於位置2帶有環形連結串列,所以程式將會進入死迴圈!

4、ConcurrentHashMap

  • 由於多執行緒在操作HashMap時會出現環形連結串列進而導致死迴圈的問題,所以此時就必須尋找解決方法。

  • Hashtable或Collections.synchronizedMap均能保證執行緒的安全性,但兩者都使用了帶有阻塞的悲觀鎖,效能不高。

  • 在併發環境下,ConcurrentHashMap能到兼顧執行緒的安全性以及執行的效率,替代了Hashtable。

  • ConcurrentHashMap通過使用Segment的方式來減少悲觀鎖的產生。

    • 其原理有點類似於jvm堆記憶體分配物件時所使用的本地執行緒分配緩衝(TLAB),每個執行緒有一個自己專屬的區域,各個執行緒在自己的區域中執行程式碼,互不干擾。

    • ConcurrentHashMap則可以看成一個二級雜湊表,首先其維護的雜湊表中儲存的均為Segment物件,而各個Segment物件中同時也維護了一個雜湊表,雜湊表裡面存放的才是真正我們要用的entry物件。


    • 如果兩個執行緒同時操作兩個Segment中的兩個雜湊表,那麼自然也就不會出現執行緒安全性問題了,兩者可以同時執行。


    • 這樣子設計之後,當我們put進一個元素時,就需要進行兩次hash值的獲取,第一次先獲取key所對應的Segment的位置,第二次再獲取key在Segment中所對應entry物件的真正位置。

    • 當然,AB執行緒也有可能同時操作到同一個Segment,為了保障執行緒的安全性問題,Segment的寫入是需要上鎖的,因此對同一Segment的併發寫入會被阻塞(由於只對寫操作上鎖,所以併發讀或一個執行緒讀一個執行緒寫的情況並不會被阻塞)。


    • 由此可見,ConcurrentHashMap當中每個Segment各自持有一把鎖。在保證執行緒安全的同時降低了鎖的粒度(降低了執行緒阻塞的可能性),讓併發操作效率更高。

  • 總結ConcurrentHashMap的get步驟和put步驟如下:

    • get

      • 1、為輸入的Key做Hash運算,得到hash值。

      • 2、通過hash值,定位到對應的Segment物件

      • 3、再次通過hash值,定位到Segment當中陣列的具體位置。

    • put

      • 1、為輸入的Key做Hash運算,得到hash值。

      • 2、通過hash值,定位到對應的Segment物件

      • 3、獲取可重入鎖

      • 4、再次通過hash值,定位到Segment當中陣列的具體位置。

      • 5、插入或覆蓋HashEntry物件。

      • 6、釋放鎖。

  • ConcurrentHashMap如何保障size()方法資料的一致性?

    • ConcurrentHashMap中的size方法是通過將各個Segment內部的元素數量彙總起來從而得出ConcurrentHashMap元素的總數量的。

    • 假如size方法在統計完Segment1之後,準備統計Segment2的數量時,另一個執行緒往Segment1插入了一個元素,同時比size方法更先執行完畢。那麼在size方法執行完成之後,所得出的數量值就會比map的總數量就會少了一個。那麼ConcurrentHashMap是如何保證size方法資料的一致性的呢?

    • ConcurrentHashMap是使用了類似於CAS樂觀鎖的思想來保證size方法統計時不會出現問題,其步驟如下:

      • 1、遍歷所有的Segment,把所有Segment的修改次數累加起來(我們在看集合原始碼的時候經常會看到modCount++的這個操作,其實modCount變數就是用來統計當前集合修改次數用的)。

      • 2、在第一步遍歷的時候,同時把Segment中內部的元素數量累加起來,得到size值。

      • 3、再一次統計所有Segment修改次數的總和。

      • 4、判斷所有Segment的總修改次數是否大於我們第一步所統計的修改次數。如果大於,說明統計過程中有修改,重新統計(跳回第一步),記錄嘗試次數+1;如果不是。說明沒有修改,統計結束。

      • 5、如果嘗試次數超過閾值,則對每一個Segment加鎖,再重新統計。

      • 6、再次判斷所有Segment的總修改次數是否大於上一次的總修改次數。由於已經加鎖,次數一定和上次相等。

      • 7、釋放鎖,統計結束。