HashMap插入數據原理分析

在JDK1.7中,HASHMAP是由數組+鏈表實現的,原理圖如下:

HashMap map = new HashMap(); // 僞初始化
map.put("鍵","值"); // 真初始化

1、HashMap初始化操作

  1. HashMap的構造方法在執行時會初始化一個數組table,大小爲0。
  2. HashMap的PUT方法在執行時首先會判斷table的大小是否爲0,如果爲0則會進行真初始化,也叫做延遲初始化。
  3. 當進行真初始化時,數組的默認大小爲16,當然也可以調用HashMap的有參構造方法由你來指定一個數組的初始化容量,但是注意,並不是你真正說了算,比如你現在想讓數組的初始化容量爲6,那麼HashMap會生成一個大小爲8的數組,如果你想數組的初始化容量爲20,那麼HashMap會生成一個大小爲32的數組,也就是你想初始化一個大小爲n的數組,但是HashMap會初始化一個大小大於等於n的二次方數的一個數組

2、HashMap存儲操作

  1. 對於PUT方法,當無需對table進行初始化或已經初始化完了之後,它接下來的主要任務是將key和value存到數組或鏈表中。那麼怎麼將一個keyvalue給存到數組中去呢?
  2. 我們知道,如果我們想往數組中存入數據,我們首先得有一個數組下標,而我們在進行PUT的時候並不需要再傳一個參數來作爲數組的下標,那麼HashMap中下標是怎麼獲取來的呢?答案爲哈希算法,這也是爲什麼叫HashMap而不叫其他MAP。
  3. 對於哈希算法,瞭解過的人應該都知道它需要一個哈希函數,這個函數接收一個參數返回一個HashCode,哈希函數的特點是對於相同的參數那麼返回的HashCode肯定是相同的,對於不相同的參數,函數會儘可能的去返回不相同的HashCode,所以換一個角度理解,對於哈希函數,給不相同的參數可能會返回相同的HashCode,這個就叫哈希衝突或哈希碰撞。
  4. 那麼我們能直接把這個HashCode來作爲數組下標嗎,另外一個很重要的問題是我們到底應該對key做哈希運算還是對value做哈希運算,還是對keyvalue同時做哈希運算?
  5. 那麼這個時候我們就要考慮到GET方法了,因爲GET只需要傳入一個key作爲參數,而實際上GET方法的邏輯就是通過把key進行哈希運算快速的得到數組下標,從而快速的找到key所對應的value。所以對於PUT方法雖然傳入了兩個參數,但是隻能對key進行哈希運算得到數組下標,這樣才能方便GET方法快速查找
  6. 所以答案是:put時只對key做哈希運算

3、HashMap獲取數組下標的方法

  • 但是還有一個問題就是,HashCode它能直接作爲數組下標嗎?HashCode它通常是一個比較大的數字,比如:
System.out.println("鍵".hashCode()); // 38190
// 爲什麼是這個結果,大家自行去看String類中的hashCode方法
  • 所以我們不可能直接把hashCode這麼大的一個數字作爲數組下標,那怎麼辦?大家可能通常會想到取模運算,比如對上面的例子“鍵的hashCode爲38190”然後對hashmap的數組長度進行取餘數計算下標,這樣可以嗎?這樣我覺得也是可以的,就是效率比較低而已
  • 所以HashMap沒有用取模這種方式,而是使用了key的hashCode的二進制與數組長度的二進制進行邏輯與運算得出下標
static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    //h-key的hashCode,length-map的數組長度
    return h & (length-1);
}
  • 這個方法就是JDK1.7HashMap中PUT和GET方法中獲取數組下標的方法
  • 這個方法中h代表hashcode,length代表數組長度。我們發現它是用的邏輯與操作,那麼問題就來了,邏輯與操作能準確的算出來一個數組下標?我們來算算,假設hashcode是01010101(二進制表示),length爲00010000(16的二進制表示),那麼h & (length-1)則爲:
h:  0101 0101
15: 0000 1111
  &
    0000 0101
  • 對於上面這個運行結果的取值方法我們來討論一下:因爲15的高四位都是0,低四位都是1,而與操作的邏輯是兩個運算位都爲1結果才爲1,所以對於上面這個運算結果的高四位肯定都是0,而低四位和h的低四位是一樣的,所以結果的取值範圍就是h的低四位的一個取值範圍:0000-1111,也就是0至15,所以這個結果是符合數組下標的取值範圍的。
  • 那麼假設length爲17呢?那麼h & (length-1)則爲:
h:  0101 0101
16: 0001 0000
  &
    0001 0000
  • 當length爲17時,上面的運算的結果取值範圍只有兩個值,要麼是0000 0000,要麼是0001 000,這是不太好的
  • 所以我們發現,如果我們想把HashCode轉換爲覆蓋數組下標取值範圍的下標,跟我們的length是非常相關的,length如果是16,那麼減一之後就是15(0000 1111),正是這種高位都爲0,低位都爲1的二級制數才保證了可以對任意一個hashcode經過邏輯與操作後得到的結果是我們想要的數組下標。這就是爲什麼在真初始化HashMap的時候,對於數組的長度一定要是二次方數,二次方數和算數組下標是息息相關的,而這種位運算是要比取模更快的。

4、結論

  • 所以到此我們可以理一下:在調用PUT方法時,會對傳入的key進行哈希運算得到一個hashcode,然後再通過邏輯與操作得到一個數組下標,最後將key+value存在這個數組下標處。
  • 確定了key+value該存的位置之後,上文說過,對於不同的參數可能會得到相同的HashCode,也就是會發生哈希衝突,反應到HashMap中就是,當PUT兩個不同的key時可能會得到相同的HashCode從而得到相同的數組下標,其實在HashMap中就算key所對應的HashCode不一樣,那麼也有可能在經過邏輯與操作之後得到相同的數組下標,那麼這時HashMap是如何處理的呢?對,就是鏈表

5、HashMap存儲key衝突

  • HashMap在PUT的時候會發生衝突,而解決衝突的方式就是在同一個數組下標中引入鏈表結構來解決,這也就是HashMap的數據結構爲什麼是數組加動態鏈表的數據結構的原因

 

  • HashMap在插入時會按照上圖所示的方式進行插入,我們考慮鏈表的插入效率,將節點3插在鏈表的頭部是最快的,也就是JDK1.7版本中HashMap所以的頭插法
  • 那麼按照上圖這種插入辦法,會出現一個問題:當我們需要get(節點2)時,我們先將節點2的key進行哈希然後算出下標,拿到下標後可以定位到數組中的節點,然後再比較key是否相等,但是發現節點1不等於節點2,所以不是最終的結果,但是節點1存在下一個節點,所以可以順着向下的指針找到節點2。
  • 那麼當我們需要get(節點3)時呢,我們發現是找不到節點3的,所以當我們把節點簡單的插在鏈表的頭部是不行的。
  • 那HashMap是怎麼實現的呢?HashMap確實是將節點插在鏈表的頭部,但是在插完之後HashMap會將整個鏈表向下移動一位,移動完之後就會變成:
  • 第三行代碼也是PUT,而這個時候在HashMap裏會將value覆蓋,也就是key="1"對應的value最終爲"3",而第三行代碼返回的value將會是2。
  • 我們現在來考慮這個PUT它是如何實現的,其實很簡單,第三行代碼的邏輯也是先對"1"計算哈希值以及對應的數組下標,有了數組下標之後就可以找到對應的位置的鏈表,而在將新節點插入到鏈表之前,還需要判斷一下當前新節點的key值是不是已經在這個鏈表上存在,所以需要先去遍歷當前這個位置的鏈表,在遍歷的過程中如果找到了相同的key則會進行value的覆蓋,並且返回oldvalue。
  • 好,寫到這裏其實對於HashMap的PUT的主要邏輯也差不多了,總結一下:
  1. PUT(key,value)
    int hashcode = key.hashCode();
    int index = hashcode & (數組長度-1)
    遍歷index位置的鏈表,如果存在相同的key,則進行value覆蓋,並且返回之前的value值
    將key,value封裝爲節點對象(Entry)
    將節點插在index位置上的鏈表的頭部

     

  • 將鏈表頭節點移動到數組上
    這是最核心的7步,然後在這個過程中還有很重要的一步就是擴容,而擴容是發生在插入節點之前的,也就是步驟4和5之間的。

6、總結

  • 因爲jdk1.7版本的HashMap在插入數據的時候使用的是頭插法,所以在數據擴容的時候會產生死循環問題,在1.8版本之後已經優化,插入時使用了尾插法來解決這個問題

7、HashMap擴容死循環問題

  • HashMap的線程不安全主要是發生在擴容函數中,即根源是在transfer函數中,並採用頭插法將元素遷移到新數組中。頭插法會將鏈表的順序翻轉,這也是形成死循環的關鍵點,而jdk1.8之後進行擴容元素插入時使用的是尾插法。
  • put的時候導致的多線程數據不一致。
比如有兩個線程A和B,首先A希望插入一個key-value對到HashMap中,
首先計算記錄所要落到的桶的索引座標,然後獲取到該桶裏面的鏈表頭結點,
此時線程A的時間片用完了,而此時線程B被調度得以執行,和線程A一樣執行
,只不過線程B成功將記錄插到了桶裏面,假設線程A插入的記錄計算出來的
桶索引和線程B要插入的記錄計算出來的桶索引是一樣的,那麼當線程B成功插入之後,
線程A再次被調度運行時,它依然持有過期的鏈表頭但是它對此一無所知,
以至於它認爲它應該這樣做,如此一來就覆蓋了線程B插入的記錄,
這樣線程B插入的記錄就憑空消失了,造成了數據不一致的行爲。
  • 另外一個比較明顯的線程不安全的問題是HashMap的get操作可能因爲resize而引起死循環(cpu100%),具體分析如下:
我們假設有兩個線程同時需要執行resize操作,我們原來的桶數量爲2,記錄數爲3,
需要resize桶到4,原來的記錄分別爲:[3,A],[7,B],[5,C],在原來的map裏面,
我們發現這三個entry都落到了第二個桶裏面,如下圖:

 

 

  • 假設線程thread1執行到了transfer方法的Entry next = e.next這一句,然後時間片用完了,此時的e = [3,A], next = [7,B]。線程thread2被調度執行並且順利完成了resize操作,需要注意的是,此時的[7,B]的next爲[3,A]。此時線程thread1重新被調度運行,此時的thread1持有的引用是已經被thread2 resize之後的結果。線程thread1首先將[3,A]遷移到新的數組上,然後再處理[7,B],而[7,B]被鏈接到了[3,A]的後面,處理完[7,B]之後,就需要處理[7,B]的next了啊,而通過thread2的resize之後,[7,B]的next變爲了[3,A],此時,[3,A]和[7,B]形成了環形鏈表,在get的時候,如果get的key的桶索引和[3,A]和[7,B]一樣,那麼就會陷入死循環。

 

 

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