1. 程式人生 > >HashMap分析(JDK1.8)

HashMap分析(JDK1.8)

這裡是基於JDK1.8。

可以看出HashMap繼承了AbstractMap,實現了Map。

先看看HashMap中的幾個關鍵的屬性:

預設初始容量是16

也很好理解,1的二進位制還是1:

向左位移四位:

最大容量很大:

負載因子,主要用來擴充套件HashMap的容量,建議不要進行修改:

初始容量是16,那麼就是在容量到達12的時候開始進行擴容。擴容越大,資料會越平均,檢索速度會越快,但是佔用的空間會比較大。

連結串列節點轉化為樹形節點的閾值,即當連結串列節點到達8的時候就會轉化為樹形的結構:

樹節點轉化為連結串列節點的閾值:

樹的最小容量:

先看看put()方法:

主要是呼叫了putVal()方法,中間又呼叫了hash()方法,先看看hash()方法:

當key==null的時候就返回0(從這裡也可以看出來HashMap的key是可以為null的),不為null就行計算key的hashCode()賦值給h,隨後h和h向右偏移16位的值做一個抑或(可以參看https://blog.csdn.net/Dongguabai/article/details/83148609)。

這裡選擇位移16位是因為h是一個int型別的值,int值的取值範圍是32位,向右位移16位剛好是32位的一半。

假設這是完整的資料,左邊是屬於高位,右邊是屬於低位:

向右位移16位就相當於整個低位的資料就沒有了,高位的資料都到低位這邊來了,原來的高位資料再用0去填充,然後新值和舊值再去做抑或:

這樣的好處就是在沒有外部資料接入的情況下,充分的使用了hashCode算出來的值進行計算。這樣計算出來的hash值會相對的分散,只有儘量分散才儘可能的可以減少hash衝突,雜湊之後就不容易重複。

可以自己寫個Demo測試一下:

在看putVal()方法前先看看Node,Node是HashMap中定義的一個類:

有hash值、K和V,還有一個next,就是指向下一個Node,這就明顯是一個連結串列。

再看看putVal()方法:

//從這裡可以看出HashMap的一個結構,陣列加上鍊表:

將table賦值給tab,如果是空的就執行下面的邏輯:

關於table,在第一次使用時初始化,分配時,長度總是兩個冪,也可以為0。

初始肯定是空的就會執行resize()方法進行擴容,肯定會執行下面這一段:

這裡的容量的初始值是16。

newThr閾值就是初始值乘以負載因子。

綜合也可以看出HashMap在new出來的時候,並沒有建立一個16位長度的Node陣列,而是在第一次put的時候才會建立一個初始的空間,裡面有懶載入的思想,就是你用的時候才去初始容量。

再接著看putVal()方法:

首先,put的資料肯定要落到陣列中的某一個節點中去,那具體是落到哪一個節點中去呢,就是通過這一段程式碼計算出來的。

先看這段:

i = (n - 1) & hash

i的值等於(n-1)&hash,這其實是一個取%的過程。因為最大容量就是16,而hash值必然是一個比較大的資料,這裡使用了一個&運算(可以參看:https://blog.csdn.net/Dongguabai/article/details/83150402),因為&運算的效率是高於%的。

這裡n-1,初始值的n是16,減一就是15:

再去&一個值:

這裡n必須要是2的倍數,因為2次冪有個特徵,就是1的後面全是0:

比如32是這樣:

2是這樣:

而減去1了之後就是1變成0,後面全部是都是1:

再配合&運算,就可以%了。

再接著程式碼往下看:

找到了索引之後,如果是空的,就執行下面的方法,去建立一個Node物件並賦值,要注意的是這個時候的next是null。

如果不為空就執行else裡面的程式碼:

會判斷節點是不是一個TreeNode的型別:

這個p是哪裡來的呢:

目前暫時還是Node還不是TreeNode,先分析不是TreeNode的情況:

從這裡大概可以進一步看出結構就是陣列加上鍊表(JDK1.8就是簡單的陣列加連結串列):

繼續看程式碼:

如果next Node是空的,就會建立一個新的Node放進去,即如果陣列中的位置被佔用了,就會到next Node。而且這一段程式碼是在一個for迴圈裡面,簡單點說,就是因為這個連結串列可能很長,就會一直找,直到找個那個next Node是空的Node就放進next Node中去,這個說法也不準確。在這段程式碼中看到了一個熟悉的變數TREEIFY_THRESHOLD,即到了8的時候會把這個Node轉化為一個TreeNode。

也就是說,當節點數大於等於7的時候,就會轉換成樹形結構(紅黑樹):

那麼為什麼要這麼做呢,我們把可以先回顧一下put資料的流程,首先要通過hash()方法,然後&運算之後找到陣列座標,找到座標,找到座標之後還要再遍歷這個連結串列,時間複雜度是o(N)。而樹檢索的時間複雜度是o(logN),也就是說在一定的長度內連結串列資料是很快的,但是超過一定長度,樹會比較快。

TreeNode,有左、右、父節點、過度節點和是否為red屬性:

看看這個轉化為樹的方法:

首先會判斷容量,如果比最小容量還要小,那就要進行擴容處理。然後會將連結串列結構轉化為紅黑樹的結構。

在一個do...while()迴圈中不停的轉換。看看這個treeify()方法:

接著看putVal()方法:

如果沒有達到閾值就直接賦值即可。

接著看:

還會判斷老的值是不是等於新的值,如果是的話,就會覆蓋老的value。這也和我們平時使用的時候HashMap的特性有關,當你連續put兩次相同key值的資料的時候,後面一次的value會覆蓋前一次的value。

接著看:

當容量大於閾值的時候,就會走這個resize()方法:

會判斷容量是否到達了最大的容量,如果容量到達了最大的容量就不進行擴容了。也就是說不能橫向擴容了,只能縱向通過連結串列或者紅黑樹進行資料增加了。

接著看:

先oldCap<<1,即將容量擴1倍,進行一些判斷後會將當前的閾值翻倍。即容量的變化是,當容量超過12會進行擴容,然後是16、32、64...直到到達了1<<30。

到這裡,就擴容完成了,那擴容完成之後要幹嘛呢,其實就像Redis、MySql、Oracle等,在資料到達一定量的時候,都會需要進行擴容,而擴容之後為了資料的平衡,都會進行一些複製操作。

假設現在容量到達了16,需要進行擴容處理:

這時候會有一個數據遷移的過程:

目的主要是將資料平均分佈,這樣才能提升檢索速度,如果資料都集中在一個連結串列上面,這樣檢索速度會很慢。

再來回過頭看擴容之後的處理流程:

首先會構建一個新的Node陣列,容量就是擴容後的容量。

新的陣列索引計算是通過hash值和新的容量減一再進行一個&運算。

如果當前Node是TreeNode的話,就會“切樹”。

接著看:

如果e.hash和老的容量進行&運算等於0,就不遷移,不等於0就遷移。遷移之後,Node陣列中的某個Node上的鏈條上的資料就散開分配了。

接著看:

也可以看出,“新家” 的位置就是老的容量加上當前迴圈到的j(其實這個j就是這個資料之前再陣列中的索引)。

HashMap的get()方法的實現就比較簡單了:

就是從Node中找資料: