HashMap原理(一)

目錄

  • HashMap的底層數據結構
  • 爲什麼默認初始大小爲16以及爲什麼默認加載因子爲0.75
  • 爲什麼MashMap的容量是2的N次冪
  • HashMap的hash值是怎麼算出來的,爲什麼這麼算
  • HashMap的put()過程

 

一、HashMap的底層數據結構

HashMap的底層數據結構是數組+單向鏈表+紅黑樹(jdk1.8及以後版本纔有紅黑樹結構)。

二、爲什麼默認初始大小爲16以及爲什麼默認加載因子爲0.75

默認初始大小爲16以及默認加載因子是0.75,它倆都是一個經驗值,是在時間和空間上權衡的一種最佳實現。首先默認初始大小爲16而不是2、4、8、32等等,是因爲如果太小那麼會頻繁地擴容,擴容涉及開闢新的內存空間以及新舊數組、鏈表和紅黑樹的數據遷移,時間上比較耗時;如果初始大小太大勢必會造成空間的浪費(如果實際裝不了這麼多元素的話),所以基於時間和空間的考量認爲16是一個比較適中的值。其次,默認加載因子是0.75也是這個道理。先說一下默認加載因子是幹什麼用的,它是用來度量HashMap的容量在裝到多滿時需要進行擴容,那爲什麼不是0.1、0.2……0.9、1.0呢?HashMap判斷什麼時候需要擴容有兩個關鍵參數:一個是size,一個是threshold。分別解釋一下這個兩個概念:size是HashMap實際存儲元素的個數,是數組元素個數+鏈表元素個數+紅黑樹元素個數的總和,而capacity是數組的長度或者說桶的個數,這是兩個概念;threshold是一個閾值,他的計算公式是threshold = capacity * load_factor,當size>threshold的時候就會觸發一次擴容操作。如果load_factor太小,就會導致計算後的threshold太小。那麼threshold太小會怎樣?假如說load_factor爲0.1,也就是說capacity使用了10%的時候就要擴容,那麼本次擴容後到達新capacity的10%的時候又會進擴容,所以每次都有90%的空間是浪費的,但是這樣的好處就是碰撞的概率減小了。相反,load_factor如果太大,比如說1.0,也就是100%,那麼當數組空間全部被用滿時才擴容(這裏的前提是hash的分佈是均勻的,因爲實際比較的是size與threshold的大小關係,當load_factor的大小爲1.0時,那麼threshold就等於capacity,當分佈絕對均勻時,那麼size=capacity就認爲數組的所有的桶都已經用完了),這樣雖然空間利用率提高了,將空間的浪費降到了最低,但是hash碰撞的概率提高了。這裏說一點有關hash的題外話:

(1)什麼是hash函數?在非哈希表中,元素本身和該元素在表裏面的位置是沒有關係的;而在哈希表中,元素本身以及它在哈希表中的位置是有關係的,這種關係是用hash函數來描述的:y=f(x)=H(x),x爲存放在hash表中某一個桶裏的key,H是hash函數,最終結果y描述的是key本身和key在hash表中位置的一種對應關係。

(2)什麼是hash值?hash值是將任意長度的輸入值轉換爲指定長度的int數字,相當於將任意大小的容量壓縮成指定容量大小,這樣勢必會導致同一個hash值會對應多個不同的原輸入值。

(3)什麼是hash碰撞?hash碰撞說白了就是不同的原輸入值經過hash函數的計算最終得到的是同一個hash值。

(4)hash碰撞取決於什麼因素?hash碰撞取決於兩個因素:

a)hash表實際存放的元素的個數比上hash表的長度,即a=n/m,n是hash表實際存放的元素的個數,m是hash表的長度。當a比較大時,也就是n比較大(這裏當m是個定值),說明hash表的空間利用率比較大,但是hash碰撞的概率也比較大;當a比較小時,也就是n比較小,說明hash表的空間利用率比較小,但是hash碰撞的概率也隨之減小。在“hash表的空間利用率”和“hash碰撞的概率”之間做一個權衡,最佳實踐是範圍最好落在0.6-0.9之間,那麼Java在這裏給出的值是0.75。

(5)如何解決hash碰撞?有四種方式:

具體可以參考:https://www.cnblogs.com/wuchaodzxx/p/7396599.html

這篇文章只講了三種方式,第四種方式是建立一個公共溢出區域,就是把衝突的元素都放在另一個地方,不在hash表裏面。那麼在Java的HashMap中用的是拉鍊法,也就是爲什麼HashMap的數據結構會用到單向鏈表,實際是解決hash碰撞的一種手段。

三、爲什麼MashMap的容量是2的N次冪

我們在看HashMap的源碼的時候會看到這樣一句註釋:

爲什麼官方要求HashMap的數組長度必須爲2的n次冪呢?

在講這個問題之前先說一個預備知識點,就是Java的位運算。Java的位運算有與運算(&)、或運算(|)、非運算(~)、異或運算(^)、左位移運算(<<)、右位移運算(>>)和無符號右位移運算(>>>)。下面簡單介紹一下以上各種位運算(詳細介紹請參考其他資料):

(1)&:兩個操作數對應位上都爲1則結果爲1

(2)|:兩個操作數對應位上都爲0則結果爲0

(3)~:將操作數每一位取反

(4)^ :兩個操作數對應位上不一樣則結果爲1(異爲1)

(5)<<:將二進制數向左移動指定位數,低位補0,高位截斷

(6)>>:將二進制數向右移動指定位數,高位補充符號位,低位截斷

(7)>>>:將二進制數向右移動指定位數,高位補0,低位截斷

這裏說一下HashMap是怎麼定位到某一個具體的元素(節點,數據被封裝進Node對象裏面,後面會介紹)的呢?首先是用hash算法定位到被查找的元素在數組的哪一個桶裏,然後再遍歷該桶上的鏈表。爲什麼要先用hash算法來定位桶呢?這又涉及到一個跟本文主題關係不大的另外一個知識點——hash算法的優勢。由於元素本身和元素在hash表中的位置存在一種函數關係,那麼用hash算法可以快速定位到某一元素在hash表中的位置。如果沒有hash算法,那麼要定位hash表中的某一個元素,需要一個一個地遍歷每一個桶,直到找到爲止,那麼時間複雜度很大程度上取決於遍歷的次數。好,回到正題,假如現在讓你設計一個HashMap,你怎麼讓key的hash值分佈在0~數組長度之內?我們的第一反應應該是讓H(key)對數組長度取模,模值就是數組的下標,即index = H(key) % length。前面已經說過hash算法是將任意輸入值轉換爲固定長度的int值,其實我們並不需要關心hash算法是怎麼做到的,我們需要關心的是如何利用這個hash值計算出的索引落在0~數組長度的範圍內以及如何儘量保證分佈式均勻的。假如數組長度是16,如果通過hash值計算出的索引下標值爲17,那明顯這個結果是無意義的。剛纔說的方法 H(key) % length確實可以達到目的,但是jdk的作者出於效率的考慮決定採用二進制移位的方式來達到相同的目的。因爲取模運算需要和十進制進行轉換,而移位操作是直接操作二進制數,效率要高於取模運算。那麼jdk的作者是通過什麼方法來達到目的的呢?

答案就是tab[i = (n - 1) & hash。hash是通過HashMap的一個混淆hash算法算出來的,後面會單獨介紹在HashMap中hash值的計算方式。我們把2的N次冪轉換成二進制可以發現:第N+1位是1,後面全是0,比如16的二進制是0001 0000。源碼裏的n是什麼?n是數組的長度或者說是hash表中桶的個數,那麼如果n是2的N次冪,那麼n - 1也就是2^N - 1,轉換成二進制就是第N位及其後面的值全是1,如15的二進制表示爲0000 1111。下面的內容是關鍵:一個數X與二進制位全是1的數進行&運算,結果爲Y,那麼最終的運算的結果和X相等,即Y=X,而且由於是與2^N - 1進行&運算,那麼運算結果(數組下標)最大就是2^N - 1,而2^N是數組長度,說明最終計算得到的下標會落在0~數組長度之內而不會越界。這裏的X帶入到源碼裏就是上面紅框框出來的hash。說明什麼?說明最終計算出的索引下標index的值只取決於hash值本身,進一步說,index分佈是不是均勻取決於hash值本身是不是均勻,厲害就厲害在這裏!只要保證hash值本身的分佈是均勻的,那麼index的分佈就是均勻的,hash碰撞的機率就會減少,這裏不得不佩服HashMap的設計者是如此地高明!666!那麼爲了讓hash值分佈更加均勻,HashMap自己定義了一個hash算法,下一個主題會詳細分析這個算法爲什麼會使散列的分佈更加均勻,這裏暫且略過。那麼爲什麼MashMap的容量必須是2的n次冪呢?我們來反推,要想達到前面講的Y=X的效果,那麼必須讓hash值與一個二進制位全是1的數進行&運算,那麼什麼樣的數二進制位全是1呢?我們來推導一下:

  • 如果二進制只有一個有效位,而且是1,即0000 0001,轉換成十進制是1,即2^1 - 1
  • 如果二進制有兩個有效位,而且都是1,即0000 0011,轉換成十進制是3,即2^2 - 1
  • 如果二進制有三個有效位,而且都是1,即0000 0111,轉換成十進制是7,即2^3 - 1
  • 如果二進制有四個有效位,而且都是1,即0000 1111,轉換成十進制是15,即2^4 - 1
  • ……
  • 如果二進制有十個有效位,而且都是1,即0011 1111 1111,轉換成十進制是1023,即2^10 - 1

所以我們發現,二進制位全是1的數轉換成十進制是2^N - 1,將這個結論帶入到源碼中就是截圖裏面的n - 1,而n就是數組長度。這就徹底解釋了爲什麼官方要求數組的長度必須是power of two(2的N次冪)。也通過前面的分析,我們可以知道其實(n - 1) & hash=H(key) % length,和取模運算是一個效果但效率比它高。

四、HashMap的hash值是怎麼算出來的,爲什麼這麼算

上一個主題分析了爲什麼MashMap的容量是2的N次冪,最終得出了一個結論:通過(n - 1) & hash算出來的數組下標是否均勻直接取決於hash值本身是否均勻,這個主題就是來分析jdk的作者是如何讓hash值分佈得更加均勻的。

在HashMap的源碼裏,put方法是這樣的:

在HashMap裏自定義了一個hash方法,我們來看一下這個方法:

這個代碼只有兩行,在第二行裏有兩個關鍵的地方:一個是異或運算(^),一個是無符號右移(>>>)16位。(對於不熟悉位運算操作的朋友可以看一下我寫的上一個主題,裏面做了簡單介紹,由於這些知識不是本篇文章的主題,所以一帶而過,想要了解更詳細的內容請參考其他資料,致歉!)

這裏的重點在於三元運算符的後面,首先通過key的hashcode()方法獲得了一個int值也就是hash值,並且賦值給了變量h,那爲什麼用h和它自己右移16位後的結果進行異或(^)運算呢?所有的這些都是基於一個假設,這個假設不是jdk作者憑空想出來的,估計也是根據大量的實踐統計出來的,什麼假設呢?那就是HashMap實際存放的元素個數,也就是size的值,(注意我不是口吃……)在絕絕絕絕絕絕絕大多數的場景裏不會超過65536,也就是2的16次冪,以下的分析也是基於這個假設來進行的。2的16次冪,對於32位操作系統來說,第17位是1後面全是0,當然前面也會補0,即0000 0000 0000 0001 |0000 0000 0000 0000,注意這裏我在第16位和17位中間加了一個分隔符(|)目的是爲了方便描述,這樣就將這32位分爲高16位和低16位。這個假設是:大多數場景都會小於這個值,也就是高16位全是0,低16位任意。(敲黑板,劃重點!!!)我們再回顧一下數組下標的計算過程:(n - 1) & hash,可以看到最終是跟hash值做&運算的。那麼既然高16位全是0,那麼最終的&運算的結果高16位也全是0,也就是hash值的高16位壓根兒就不起作用,也就是實際參與數組下標運算的只有hash值的低16位。在上一個主題也分析了最終index的值是不是均勻直接取決於hash值本身是不是均勻,那如何讓hash值本身分佈得更加均勻呢?要說jdk的作者就是高明,不佩服不行!如何充分利用hash值的高16位而不浪費掉又要使最終的散列值分佈更加均勻同時還要保證性能呢?那就是無符號右移(>>>)16位,使hash值自己的高16位和自己的低16位進行異或運算,這樣既可以使高16位參與到運算中來而不是到一邊去休息,又由於是直接操作二進制數所以性能也是槓槓的。但是無符號右移16位只是讓高16位參與到運算中來,不能保證計算的最終結果會比“讓高16位休息”的計算結果分佈更加均勻,使分佈更加均勻的手段是異或(^)運算,即(高16位)^ (低16位)。因爲與運算(&)會使最終的結果偏1,或運算(|)運算會使最終的結果偏0,只有異或運算(^)會使最終結果分佈更加均勻,所以(h = key.hashCode()) ^ (h >>> 16)就是這麼來的。

五、HashMap的put()過程

在介紹HashMap的put過程之前,先介紹下HashMap的體系結構。

在java.util.HashMap類中比較重要的元素有這麼幾個:

DEFAULT_INITIAL_CAPACITY初始化默認數組長度,值爲16

MAXIMUM_CAPACITY:數組最大長度,值爲Integer的最大值

DEFAULT_LOAD_FACTOR:默認加載因子,值爲0.75

TREEIFY_THRESHOLD:由單向鏈表轉爲紅黑樹的閾值,值爲8

UNTREEIFY_THRESHOLD:有紅黑樹轉爲單向鏈表的閾值,值爲6

table:哈希表,就是實際存放數據的數組,我們說的HashMap底層數據結構是數組+單項鍊表+紅黑樹,其中的數組就是這個table

entrySet:這個不用說,是存放鍵值對的集合

size:HashMap中存放數據的總個數,注意區別數組的長度,capacity是數組的長度

modCount:修改次數,每做一次增刪改修改次數就會+1,此變量只增不減

threshold:擴容閾值,當size > threshold時引發HashMap擴容

loadFactor:用戶自定義加載因子

還有一個靜態內部類很重要:

這個靜態內部類Node實現了Map接口的內部接口Entry,而Entry是一個鍵值對的數據結構。在Node(以下稱爲節點)中,存儲了key的hash值,key值,value值,和該節點的下一個節點(單向鏈表)。

還有幾個構造方法,其中有兩個比較有代表性:

先說第二個,第二個是無參構造方法,這裏面只幹了一件事,那就是把默認加載因子複製給自定義加載因子變量,其它的什麼都沒做。也許你會問:你指望它還做什麼?其實有個非常重要的事情沒有做:那就是最最最最最重要的用於存放數據的數組沒有初始化,也就是說當你new了一個HashMap時,用於存放數據的數組居然還是null!再來看截圖裏面第一個構造方法,它傳入一個用戶指定大小的容量,也就是數組長度。在上面的專題裏已經分析了,jdk官方聲明數組的長度MUST be a power of two,那如果用戶傳進去的不是2的N次冪怎麼辦,比如傳一個13,傳一個100。呵呵,jdk作者早替你想好了,如果傳進去的不是2^N,那麼它會進行一系列的轉換,最終將數組長度轉換爲比用戶傳進來的長度大的最小的2的N次冪,比如比13大的最小的2的N次冪是16,那麼最終數組的初始大小爲16。整個的轉換過程比較複雜,看其源碼的話是下面這一段:

好,以上都是本主題的鋪墊,旨在讓這篇文章的讀者對HashMap的類結構有一個大致的瞭解。下面回到主題上來,說一下整個put的過程。

put方法會先計算key的hash值,前面已經分析過,這裏就不再分析了,然後調用putVal方法。我們來看一下putVal方法。

我用不同的顏色將putVal方法分成了4個大的區域,起鬨黃色的第三個區域又細分爲三個小區域。我們一個一個來看。先看第一個區域。前面我埋下了一個伏筆,就是在調用HashMap默認構造器的時候並沒有初始化數組,其實其它構造器也沒有對數組初始化,也就是說你調用任何一個HashMap的的構造函數都不會導致數組的初始化。那什麼時候初始化的呢?不初始化不行啊,數組是HashMap最核心的數據結構啊,單向鏈表和紅黑樹都是掛在數組上面的啊,數組爲null怎麼能行呢?其實數組的初始話是在第一次調用put的時候進行的,也就是在區域一中的resize()方法。這種思想就是延遲加載的思想。後面會有一個單獨的專題來講解HashMap是怎麼擴容的,然後會帶着大家分析resize()的源碼,這裏就知道它是一個擴容方法就可以了。區域一的代碼主要就是判斷是不是第一次調用put方法,如果是(table爲空),那麼就需要初始化數組並給數組一個默認初始大小或者一個經轉換後的用戶指定大小。然後將數組的長度賦給變量n。區域一結束,下面是區域二。

在區域二中,有一段上面已經大篇幅分析過的代碼:i = (n - 1) & hash,這裏就不再贅述,可以查看上面的專題。這裏要說的是第630行的if判斷條件是什麼意思。經過複雜的運算得出數組的下標並賦值給變量i,tab[i]表示數組的第i個元素,判斷它是不是空,如果是空說明該位置沒有任何元素,那麼就新建一個Node節點房改該數組的位置上。如果不爲空,則進入區域三。

我們來看區域3.1。進入了區域3.1說明什麼?說明下表爲i的數組位置已經有一個節點了,換句話說hash碰撞了對不對?前面已經分析過HashMap解決hash碰撞的方式採用的是拉鍊法,也就是用單向鏈表的方式來解決散列衝突。那既然桶位i的位置有一個節點了,把要插入的新節點掛在老節點的下面就行了。不錯,但先彆着急,我們先看一下新老節點的key是不是相等,比較的方式是比較Node節點裏面存儲的key的hash值和要插入的新節點的hash值是不是一樣,並且兩者的key是不是equals。如果是相同的,就把老節點賦值給臨時變量e,然後就到了代碼554行,用新value來替換老的value,這也就是在我們實際使用的過程當中如果key一樣就會用新的K-V來替換老的K-V的原因。如果534行的判斷不通過,索命不存在一樣的key的節點,救會進入到代碼區域3.2或者3.3。

如果key不一樣,那麼就要把新節點掛到桶位爲i的位置上,但是這涉及到一個問題:這個位置上的數據結構到底是單向量表還是一個紅黑樹?所以還需要根據類型來判斷從而採用不同的插入方式。如果類行爲紅黑樹,就按照紅黑樹的方式來插入數據(這要有紅黑樹的知識),也就是代碼區域3.2,否則就是單向鏈表,代碼區域3.3。但先鏈表的插入規則是判斷每一個節點的next節點是不是空,如果爲空說明在插入前該節點是單向鏈表的尾節點,否則就順着每個節點的next節點往下遍歷直到爲空時就掛在後面。最後沒有被框出來的是代碼區域4。這裏主要是改變修改次數以防止多線程環境下出現問題,還有就是判斷是不是要擴容。整個put方法結束。

 

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