HashMap的底層結構、原理、擴容機制

優秀的小碼農發表於2020-12-19

一.問題

Q0:HashMap是如何定位下標的?

A:先獲取Key,然後對Key進行hash,獲取一個hash值,然後用hash值對HashMap的容量進行取餘(實際上不是真的取餘,而是使用按位與操作,原因參考Q6),最後得到下標。

Q1:HashMap由什麼組成?

A:陣列+單連結串列,jdk1.8以後又加了紅黑樹,當連結串列節點個數超過8個(m預設值)以後,開始使用紅黑樹,使用紅黑樹一個綜合取優的選擇,相對於其他資料結構,紅黑樹的查詢和插入效率都比較高。而當紅黑樹的節點個數小於6個(預設值)以後,又開始使用連結串列。這兩個閾值為什麼不相同呢?主要是為了防止出現節點個數頻繁在一個相同的數值來回切換,舉個極端例子,現在單連結串列的節點個數是9,開始變成紅黑樹,然後紅黑樹節點個數又變成8,就又得變成單連結串列,然後節點個數又變成9,就又得變成紅黑樹,這樣的情況消耗嚴重浪費,因此乾脆錯開兩個閾值的大小,使得變成紅黑樹後“不那麼容易”就需要變回單連結串列,同樣,使得變成單連結串列後,“不那麼容易”就需要變回紅黑樹。

Q2:Java的HashMap為什麼不用取餘的方式儲存資料?

A:實際上HashMap的indexFor方法用的是跟HashMap的容量-1做按位與操作,而不是%求餘。(這裡有個硬性要求,容量必須是2的指數倍,原因參考Q6)

Q3:HashMap往連結串列裡插入節點的方式?

A:jdk1.7以前是頭插法,jdk1.8以後是尾插法,因為引入紅黑樹之後,就需要判斷單連結串列的節點個數(超過8個後要轉換成紅黑樹),所以乾脆使用尾插法,正好遍歷單連結串列,讀取節點個數。也正是因為尾插法,使得HashMap在插入節點時,可以判斷是否有重複節點。

Q4:HashMap預設容量和負載因子的大小是多少?

A:jdk1.7以前預設容量是16,負載因子是0.75。

Q5:HashMap初始化時,如果指定容量大小為10,那麼實際大小是多少?

A:16,因為HashMap的初始化函式中規定容量大小要是2的指數倍,即2,4,8,16,所以當指定容量為10時,實際容量為16。

Q6:容量大小為什麼要取2的指數倍?

A:兩個原因:1,提升計算效率:因為2的指數倍的二進位制都是隻有一個1,而2的指數倍-1的二進位制就都是左全0右全1。那麼跟(2^n - 1)做按位與運算的話,得到的值就一定在【0,(2^n - 1)】區間內,這樣的數就剛合適可以用來作為雜湊表的容量大小,因為往雜湊表裡插入資料,就是要對其容量大小取餘,從而得到下標。所以用2^n做為容量大小的話,就可以用按位與操作替代取餘操作,提升計算效率。2.便於動態擴容後的重新計算雜湊位置時能均勻分佈元素:因為動態擴容仍然是按照2的指數倍,所以按位與操作的值的變化就是二進位制高位+1,比如16擴容到32,二進位制變化就是從0000 1111(即15)到0001 1111(即31),那麼這種變化就會使得需要擴容的元素的雜湊值重新按位與操作之後所得的下標值要麼不變,要麼+16(即挪動擴容後容量的一半的位置),這樣就能使得原本在同一個連結串列上的元素均勻(相隔擴容後的容量的一半)分佈到新的雜湊表中。(注意:原因2(也可以理解成優點2),在jdk1.8之後才被發現並使用)

Q7:HashMap滿足擴容條件的大小(即擴容閾值)怎麼計算?

A:擴容閾值=min(容量負載因子,MAXIMUM_CAPACITY+1),MAXIMUM_CAPACITY非常大,所以一般都是取(容量負載因子)

Q8:HashMap是否支援元素為null?

A:支援。

Q9:HashMap的 hash(Obeject k)方法中為什麼在呼叫 k.hashCode()方法獲得hash值後,為什麼不直接對這個hash進行取餘,而是還要將hash值進行右移和異或運算?

A:如果HashMap容量比較小而hash值比較大的時候,雜湊衝突就容易變多。基於HashMap的indexFor底層設計,假設容量為16,那麼就要對二進位制0000 1111(即15)進行按位與操作,那麼hash值的二進位制的高28位無論是多少,都沒意義,因為都會被0&,變成0。所以雜湊衝突容易變多。那麼hash(Obeject k)方法中在呼叫 k.hashCode()方法獲得hash值後,進行的一步運算:h=(h>>>20)(h>>>12);有什麼用呢?首先,h>>>20和h>>>12是將h的二進位制中高位右移變成低位。其次異或運算是利用了特性:同0異1原則,儘可能的使得h>>>20和h>>>12在將來做取餘(按位與操作方式)時都參與到運算中去。綜上,簡單來說,通過h=(h>>>20)(h>>>12);運算,可以使k.hashCode()方法獲得的hash值的二進位制中高位儘可能多地參與按位與操作,從而減少雜湊衝突。

Q10:雜湊值相同,物件一定相同嗎?物件相同,雜湊值一定相同嗎?

A:不一定。一定。

Q11:HashMap的擴容與插入元素的順序關係?

A:jdk1.7以前是先擴容再插入,jdk1.8以後是先插入再擴容。

Q12:HashMap擴容的原因?

A:提升HashMap的get、put等方法的效率,因為如果不擴容,連結串列就會越來越長,導致插入和查詢效率都會變低。

Q13:jdk1.8引入紅黑樹後,如果單連結串列節點個數超過8個,是否一定會樹化?

A:不一定,它會先去判斷是否需要擴容(即判斷當前節點個數是否大於擴容的閾值),如果滿足擴容條件,直接擴容,不會樹化,因為擴容不僅能增加容量,還能縮短單連結串列的節點數,一舉兩得。

二.總結

hashMap.put(key,value)—entry物件—根據計算hash值—按位與----得到下標

jdk1.7

put —判斷陣列是否為空,如果為空進行初始化,初始化的是陣列容量(@Q6:必須為2的冪次方)
—判斷key是否為空,執行方法,key為null存在index為0的位置
—根據key得到hash,對key進行hashcode,(@Q9進行右移和異或運算)
—根據hash值和容量得到下標,indexFor方法(@Q6hash值s與容量-1進行按位與操作)
—覆蓋邏輯,遍歷連結串列,短路與先判斷hash值是否相等,再判斷key,value覆蓋,返回oldvalue
—addEntry(hash,key,value,i),先有(@Q6擴容機制),再根據四個值,頭插法或尾插法插入
get —判斷key是否為空
—根據key獲得entry,計算hash值,得到下標,遍歷連結串列,先比較hash再比較key,就得到了
—根據entry獲得value

jdk1.8

1—判斷陣列是否為空,呼叫resize方法進行初始化(resize方法李有初始化邏輯和擴容邏輯)
1—判斷陣列元素是否為空(計算出下標得到的這個陣列元素,其實就是頭節點)
2—為空則新增新節點
1—陣列元素不為空意味著存在一個或一個以上元素,則為連結串列或紅黑樹
2—判斷hash值和key,短路與操作,是同一個節點則繼續往下執行2,不是則執行判斷3
3—判斷是否為紅黑樹
4—是樹,則新增新節點
4—不是樹則為連結串列,遍歷連結串列,判斷是否為尾節點
5—是尾節點則新增新節點
4—判斷hash和key,返回e執行下一步的2操作覆蓋操作
2—同一個節點則執行覆蓋操作,同jdk1.7
結果不是了新增新節點,就是因為相同所以覆蓋原來的舊節點
jdk1.8先加資料再擴容,如果擴容可以解決連結串列太長問題就不變紅黑樹

1.7和1.8差不多流程都是
1.判斷陣列是否為空
2.遍歷連結串列(1.8就是多加了遍歷紅黑樹)
3.判斷hash和key,相同則覆蓋,不同就是加新元素

相關文章