HashMap原理(二)——jdk1.8中HashMap擴容底層代碼和算法分析

這次給大家帶來的是HashMap原理第二篇之——HashMap擴容的底層代碼和算法分析。需要說明的是本文是基於jdk1.8來進行展開的,今後有機會會和大家分享在jdk1.7中HashMap的實現方式和1.8有哪些區別(擴容方式是其中的區別之一)。有朋友會說,既然HashMap是基於數組+單向鏈表+紅黑樹的底層數據結構,鏈表可以無限地延伸啊,紅黑樹也可以不停滴往裏面放東西啊,還擴容幹什麼?這樣的說法既對也不對,說對是因爲HashMap確實是基於單向鏈表和紅黑樹的,但是有沒有想過不斷地往鏈表上添加元素或者不斷地往樹裏面加東西會怎麼樣?是不是會導致鏈表過長以及樹的深度增大?是不是進而會提高遍歷鏈表或者紅黑樹的時間複雜度?最終導致從表現上來看插入和查找操作會越來越慢?所以,到了一定程度對數組擴容還是很有必要的。那又有朋友會問:擴容很簡單啊,需要的時候從數組兩端往外延伸一下內存空間不就可以了嗎?……想啥呢?數組不是拉麪,需要的時候從兩邊往外抻一下,不存在的!數組擴容只能開闢出一個更大的內存空間出來,將原來的內容遷移到新的內存空間裏面。OK,對HashMap的底層數據結構有一個清晰的認識後,我們就開始今天的擴容之旅。

HashMap的擴容其實就看一下resize()方法就可以了,直接上源碼:

首先我要說一下的是,觸發HashMap擴容的時機有兩個:一個是第一次調用put()方法的時候,一個是當size > threshold的時候。前面的文章也分析過,HashMap最重要的數據結構——數組是延遲初始化的,也就是在第一次調用put()方法往裏面去存放數據的時候初始化的。我們結合源碼來看一下在第一次調用put時擴容是怎麼實現的以及何時初始化的table數組。

第一次調用put方法還要分兩種情況,根據什麼來分呢?就根據這個HashMap是怎麼來的來劃分,也就是在new一個HashMap的時候你調用的是哪個構造方法。

Case 1:調用默認無參構造函數新建HashMap

首先678行將table數組賦給一個局部變量oldTab,此時爲null,因爲還沒有初始化,所以顯而易見679行的oldCap是0,注意這裏的oldCap是數組的長度,不是size,這兩個東東在上一篇文章提到過是不一樣的。680行的threshold也是0,因爲默認構造器裏面也沒有賦值。所以682行和691行的if判斷條件就爲false了,直接進入else語句塊。到else裏面大家可能會眼前一亮,不僅新容量被設置成默認值16(至於爲什麼是16請參考上一篇文章)而且也見到了擴容閾值是怎麼算的了:數組長度 * 加載因子,當然由於是第一次擴容這裏面的兩個值都是默認值(16 * 0.75 = 12),然後賦值給newThr變量。接下來697行的newThr不會爲0了因爲剛賦過值,直接到702行將剛纔計算的新擴容閾值12賦值給成員變量threshold,那麼下次再擴容時HashMap元素的個數(注意這裏是size)必須大於12才能進行。好了,到這裏我們可以看到剛纔是各種賦值,但此時主角table數組並沒有被初始化,仍然爲null。再往下看第704行初始化了一個具有newCap容量的數組,而newCap就是剛剛694行的DEFAULT_INITIAL_CAPACITY(即16),705行將數組引用賦值給了table變量,至此一個具有默認初始容量的全新table數組誕生了。由於oldTab在剛進入resize方法的第一行就已經賦值爲null了,並且中間沒有被修改過,所以706行的if判斷不會通過,resize()方法完成!整個擴容過程完成!table數組初始化完成!

Case 2:調用有參構造函數新建HashMap

在HashMap中,有參構造函數一共有三個,這裏只拿public HashMap(int initialCapacity) {}來舉例。在上一篇文章中已經分析過,當用戶輸入一個非2的N次冪的容量時,HashMap會將該容量修改爲比輸入值大的最小的2的N次冪的值作爲哈希表的容量。我們再從頭走一遍resize的過程。由於HashMap的設計理念是懶初始化的思想,所以HashMap的所有構造方法都沒有去初始化table數組,所以678行oldTab仍然爲null,下面的oldCap仍然爲0,再下面的oldThr仍然爲0,所以682行和691行的判斷都不能通過,直接進入到694行else{}代碼塊裏面。等等,好像哪裏不對,這樣和Case 1的情況不一樣嗎?問題出在哪裏呢?問題就出在第680行。在默認構造器裏面成員變量threshold確實是沒有賦過值的,但是在構造器public HashMap(int initialCapacity) {}裏面情況就不一樣了,我們來看一下該構造器的代碼。

剛纔說的HashMap會把用戶輸入的任意容量轉換成2的N次冪,這個轉換就發生在tableSizeFor()方法裏面。通過這個方法的變量名稱initialCapacity和這個方法的名字tableSizeFor可以知道它是來進行容量轉換的,是將用戶輸入的容量值轉換成另一個符合約定的容量值。這裏有點不理解jdk作者的意圖:構造方法裏將計算後的數組容量賦值給一個代表擴容閾值的變量threshold(全局變量),又將threshold的值賦給代表容量的變量newCap(局部變量),並用一句註釋加以說明(691行):initial capacity was placed in threshold,意思就是初始容量放置在了threshold變量裏面。其實我們可以看出全局變量threshold起了一個傳導的作用:那就是在構造方法裏面計算出一個值,把它賦給一個全局變量保存一下,目的就是要將此值從構造器裏面傳出去,在需要的時候再賦給某一個局部變量。如果是這樣的話,那定義一個具有容量語義的全局變量(比如叫newCapTmp)去保存計算後的容量值可讀性應該更好一些。因爲threshold = capacity * load_factor,而load_factor默認是0.75當然用戶也可以自定義,即使是自定義也很少有人將加載因子賦值爲1,所以絕大多數情況下threshold不等於capacity。退一步說,即使是將加載因子賦值爲1,那麼在數值上threshold確實等於capacity,但實際上它倆表示的概念不一樣,概念不同的兩個變量之間互相賦值可能會引起一些混淆。以上僅代表本人觀點。

經過上面的分析我們知道了resize方法的第680行oldThr變量的值不是0,所以它可以通過691行的判斷,然後在692行將局部變量oldThr的值也就是成員變量threshold的值賦值給了一個叫newCap的局部變量裏面。然後就到了697行,newThr爲0所以可以通過判斷,重新計算newThr的值。後面的內容就和Case 1一樣了,注意706行至747行的邏輯也不會進去,所以再Case 2裏面也不用關心。

上面的Case 1和Case 2都是首次調用put方法時的擴容,文章開始時也說過引起擴容的時機除了第一次調用put方法之外還有就是當size > threshold的時候。下面就要分析當size > threshold時擴容是怎麼實現的。

當size > threshold時前面的內容就不分析了,也就是resize方法的678行到第705行和上面分析的過程差不多,讀者朋友可以自己先分析一下。這裏重點要說的是裏面687-689行,非首次擴容那麼oldTab肯定不是空,說先判斷oldCap是不是大於等於MAXIMUM_CAPACITY,也就是1 << 30,和十進制的1073741824,如果是的話就將老數組長度置爲Integer的最大值。然後重點是687-689行,這裏我們可以看到,HashMap擴容使容量一次擴兩倍,也就是代碼裏的無符號左移1位(<< 1 相當於乘以2),同時老的擴容閾值也擴爲原來的兩倍。在705行之前和上面的Case 1以及Case 2的情況差不多,重點是剩下的代碼。其實剩下的代碼就只有一個if語句塊了,也就是706到747行,但是這個if語句塊佔了resize方法篇幅的半壁江山,可見其重要性!那它是幹嘛的呢?其實我們大概掃一眼可以看出來是做數據遷移用的——將老數組的數據遷移到新數組上來。我們來一起看一下if裏面到底做了什麼。

爲了方便描述,把這個佔據“半壁江山”的if語句塊拿出來單獨分析(可能顯示的圖片不全,需要左右滑動看):

我們來看707行,這裏遍歷的是老數組,j每增加1就會讀取老數組中後一桶的數據,但是在++j之前會將老數組在位置位j的數據遷移到新數組當中。具體是怎麼遷移的呢?首先將老數組位置位j的數據取出來賦值給一個局部變量e,然後將該桶位的節點置爲null,我在截圖中第710行也提出了一個問題,爲什麼要這樣做?其實這麼做的原因就是有助於更快地GC。首先將老數組中指向該Node節點(假如是A節點)的引用給斷掉,因爲上面已經賦值給了局部變量e,也就是變量e持有了Node節點的引用,所以老數組已經沒有必要再持有了,在下一次循環後e會被賦爲另一個值,持有另一個Node(B節點)的引用,那麼e也會斷掉上一個Node(A節點)的引用,這樣就沒有任何一個變量持有A節點的引用,這樣GC可以快速地將A節點回收掉。相反,如果在710行沒有將oldTab[j]置爲null,那麼下一次循環e持有B節點的引用時,GC會判斷仍然有老數組持有A節點的引用(但此時A節點對老數組來說已經沒有任何作用了),這就給內存泄漏的發生埋下了隱患。好,我們繼續回到正題。我把整個if語句塊分成了三部分,我們來分別看一下:

Section 1:

判斷數組桶中第一個節點是不是有下一個節點,如果沒有就講該節點Node放到新數組newTab的相應位置上。這裏就有一個問題:什麼是相應位置?我們在712行代碼可以看到用原有key的hash值與新數組長度(擴容後位原數組的2倍)- 1 進行&運算,從而得到一個新數組下標,並將e放到新數組下標上。數組下標的計算方法在上一篇博客中我已經詳細地給大家分析過了,這裏就不再贅述。這裏重點想說的是:用原有key的hash值和擴爲兩倍之後新數組長度 - 1進行&運算的結果有什麼特點?我們知道,一個數在變成它自己的兩倍之後轉換成二進制表現爲有效位向左移動一位,說明什麼?說明在用新長度計算索引下標(e.hash & (newCap - 1))時得出來的下標值要麼是原下標,要麼是原下標+老數組長度。我們來具體看一下這個結論是怎麼得出來的。

我們假設原數組長度爲16,擴容後長度爲原來的兩倍即32,爲了方便描述我們把key的hash值表示爲H(k)。原來是H(k)與15進行&運算來計算下標,15的二進制的有效位只有後四位,也就是無論H(k)的值是什麼樣的,最終參與運算的是H(k)的後四位。由於16變32的二進制有效位向左移動1位:

16二進制表示:0001 0000

32二進制表示:0010 0000

也就是有效位多了一位,由原來的5位變成了6位,同理32 - 1的有效位也會比15 - 1的有效位多一位:

15二進制表示:0000 1111

31二進制表示:0001 1111

在計算索引下標時,原來H(k)只有後4位參與運算,現在在計算新數組的索引下標時H(k)就有後5位參與運算了。(敲黑板,劃重點!!!)這一點很關鍵!在jdk1.7中,擴容時原數組中每個節點都要在新數組中重新計算一遍索引下標,在jdk1.8中作者就變聰明瞭,基於上面的分析大家可以想一想在新數組中還有沒有必要重新計算一下新數組的下標?是不是沒有必要了!確實是沒有必要了!因爲在擴容之後數組的長度(包括長度 - 1)向左多出了一位,但後面的值和原來的可是一模一樣,也就是說在進行&運算的時候我們只需要和多出來的那一位進行計算就可以了。用上面舉的例子(長度由16擴爲32)來說,只要拿出H(k)的倒數第5位進行&運算就可以了,後4位不需關心,也無需計算,因爲算出來也還是原來的值。那既然只算倒數第5位,後4位是什麼已經不重要了,乾脆置成0得了。所以,H(k)和0001 1111進行&運算與H(k)和0001 0000進行&運算的結果是一樣的,這就是爲什麼第721行算索引下標的時候oldCap沒有減1的原因。

Section 2:

如果原數組在位置位j的地方是一棵紅黑樹,那麼就按照紅黑樹的插入方式進行遷移,這需要有紅黑樹的知識,後期有機會的話會和大家單獨分享紅黑樹的插入方式,這裏只是知道如果是紅黑樹就按照紅黑樹的插入方式進行遷移就可以了。如果不是紅黑樹,那麼說明它是一個單向鏈表,就會進入else{}代碼塊,也就是代碼區域3。

Section 3:

本區域是對鏈表進行遷移的。再講遷移具體過程之前,我們先回過頭看一下在Section 1中講的——在計算索引時爲什麼oldCap不減1的原因。下面來說一下這麼做要達到什麼目的。前面分析了H(k)只和數組長度最左邊的有效位進行&運算,那麼運算的結果要麼是0要麼是1,轉換成十進制有什麼特點呢?我們來推到一下:

假設H(k)的二進制表示爲(只取後8位):0001 0101

  • 與15(0000 1111)進行&運算,結果爲:i1 = 0000 0101,轉換成十進制爲5

  • 與31(0001 1111)進行&運算,結果爲:i2 = 0001 0101,轉換成十進制爲21

我們可以看到,計算結果後四位全一樣,i2比i1多了一個有效位,我們知道在二進制中多了1個有效位且多出的有效位是第N位,就相當於在原來的基礎上多了2^(N-1)。我們可以算一下:i2比i1多了1位,多出的這一位是第5位,轉換成十進制後就相當於i1 + 2^(5 - 1),也就是5 + 2^4 = 21,正好和i2轉換成十進制後的數值吻合,而2^4正好位原數組的長度。

假設H(k)的二進制表示爲(只取後8位):0000 0101

  • 與15(0000 1111)進行&運算,結果爲:i1 = 0000 0101,轉換成十進制爲5

  • 與31(0001 1111)進行&運算,結果爲:i2 = 0000 0101,轉換成十進制爲5

這裏i2=i1,說明什麼問題?說明新數組兩倍擴容之後,計算出的新索引值要麼和原數組索引一樣,要麼是原數組索引 + 原數組長度。好了,有了這個結論我們就可以來看Section 3中else{}的代碼塊了。

首先,先定義了四個局部變量,lo開頭的是用來保存計算出的新數組索引和老數組索引相同的Node,而hi開頭的是用來保存計算出的新數組索引和老數組索引不同的Node。然後就是一個do{}while(){}循環,假設原數組在下標位j的位置上是一個這樣的鏈表:A->B->C,我就不畫圖了,大家腦補一下:上面橫着放一個數組,在某一個桶的位置上掛一個縱向的單向鏈表,節點的順序是A->B->C。

首先拿出原鏈表第一個節點A的next節點B,然後賦值給next變量。721行剛纔也分析過了,計算索引爲0說明和原數組索引一樣,一樣的話就往lo開頭的變量裏面放;不一樣說明這個節點要放在新數組的新索引的地方,往hi開頭的變量放。代碼區域3.1和3.2的過程類似且都比較簡單就不在這裏展開了,具體過程大家可以根據ABC這三個節點在頭腦裏跟着while循環走兩遍。不過有四點需要注意的是:第一,每次插入都是給tail變量賦值,也就是說在鏈表遷移時jdk1.8的做法是往隊列尾插入數據,也就是我們說的尾插法,爲什麼要強調是jdk1.8呢,因爲在1.7中是從頭部插入的,而且遷移完成之後鏈表的順序被倒置了;第二,從while循環走出來之後,假如每一個節點的e.hash & oldCap全是0,那麼最後的結果會是A->B->C->C,所以736~743行將尾節點的next節點置爲null;第三,在第742行可以看到如果e.hash & oldCap不是0,那麼他會將新鏈表的頭節點放到新數組的新索引上,這個新索引就是j + oldCap,和上面分析的一致;第四,738行和742行是將新鏈表的頭節點放到新數組相應的位置上,這個動作是在while循環之後進行的,這個主要是解決jdk1.7中鏈表遷移成環的問題,這個問題等有機會我會單獨寫一篇博客分析這個問題,這裏大家知道一下就可以了。

整個HashMap的擴容過程就分析完了。

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