Java HashMap原理解析

本文分析HashMap的實現原理。

數據結構(散列表)

HashMap是一個散列表(也叫哈希表),用來存儲鍵值對(key-value)映射。散列表是一種數組和鏈表的結合體,結構圖如下:

來自百度百科的哈希表結構圖

簡單來說散列表就是一個數組(上圖縱向),數組的每個元素是一個鏈表(上圖橫向),類似二維數組。鏈表的每個節點就是我們存儲的key-value數據(源碼中將key和value封裝成Entry對象作為鏈表的節點)。


哈希算法

對于散列表,不管是存值還是取值,都需要通過Key來定位散列表中的一個具體的位置(即某個鏈表的某個節點),計算這個位置的方法就是哈希算法。

大概過程是這樣的:

  1. 用Key的hash值對數組長度做取余操作得到一個整數,這個整數作為數組中的索引得到這個索引位置的鏈表。
  2. 得到鏈表之后,就可以存值和取值了。
    如果是存值,直接把數據插入到鏈表的頭部或者尾部即可(或者已存在就替換);
    如果是取值,就遍歷鏈表,通過key的equals方法找到具體的節點。

例如一個key-value對要存到上圖的散列表里,假設key的哈希值是17,由圖可知(縱向)數組長度是16,那么17對16取余結果是1,數組中索引1位置的鏈表是 1->337->353 ,所以這個key-value對存儲到這個鏈表里面(插到頭還是尾可能不同Java版本不一樣)。如果是取值,就遍歷這個鏈表,由于這個鏈表每個節點的key的哈希值都一樣,所以根據equals方法來確定具體是哪個節點。

通過上面的哈希算法,可以有如下結論:

  • 不同的key具體相同的哈希值叫做哈希沖突HashMap解決哈希沖突的方法是鏈表法,將具有相同哈希值的key放在同一個鏈表中,然后利用key類的equals方法來確定具體是哪個節點。
  • Key的唯一性是通過哈希值和equals方法共同決定的,所以想要用一個類作為HashMap的鍵,必須重寫這個類的hashCode和equals方法。同理,HashSet是基于HashMap實現的,它沒有重復元素的特點是利用HashMap沒有重復鍵實現的。所以,Set集合里面的元素類,也必須同時實現hashCode方法和equals方法。
  • HashMap存儲的數據是無序的。


為什么HashMap大小是2的整數次冪的時候效率最高

哈希算法主要分兩步操作:1.通過哈希值定位一個鏈表; 2.遍歷鏈表,通過equals方法找到具體節點。為了使哈希算法效率最高,應該盡量讓數據在哈希表中均勻分布,因為那樣可以避免出現過長的鏈表,也就降低了遍歷鏈表的代價。
如何保證均勻分布?前面的哈希算法說到,通過取余操作將Key的哈希值轉換成數組下標,這樣可以認為是均勻的。但是,源碼中并沒有直接用%操作符取余,而是使用了更高效的與運算,源碼如下:

/**
 * Returns index for hash code h.
 */
static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

這樣就多了一些限制,因為只有當length是2的整數次冪的時候,h & (length-1) = h % length才成立。當然,如果length不是2的整數次冪,h & (length-1)的結果也一定比length小,將Key轉換成數組下標也沒什么問題,但是,這樣會導致元素分布不均勻嚴重影響散列表的訪問效率。看下面的一個示例代碼:

示例代碼

解釋一下圖中的代碼,隨機生成一組Key,然后利用與運算,把key全部轉換成一個數組容量的索引,這樣就得到一組索引值,這組索引中不相同的值越多,說明分布越均勻,輸出結果的result就是 “這組索引中不相同的值的數量”。
從運行結果來看,容量是64的時候相比于其他幾個容量大小,分布是最均勻的。容量是65的時候,每次結果都是2,原因很簡單,當容量是65的時候,下標=h&64,64的二進制是1000000,很明顯,與它進行與運算的結果只有兩種情況,0和64,也就是說,如果HashMap大小被指定成65,對于任意Key,只會存儲到散列表數組的第0個或第64個鏈表中,浪費了63個空間,同時也導致0和64兩個鏈表過長,取值的時候遍歷鏈表的代價很高。容量66和67的結果是4同理。如果容量是64,那么下標=h&63,63的二進制是111111,每一位都是1,好處就是對于任意Key,與63做與運算的結果可能是1-63的任意數,很多Key的話自然就能分布均勻。
通過這個示例代碼的分析就可以找到一個規律了,容量length=2^n 是分布最均勻,因為length-1的二進制每一位都是1;相反的length=2^n+1是分布最不均勻的,因為length-1的二進制中的1數量最少。

結論:HashMap大小是2的整數次冪的時候效率最高,因為這個時候元素在散列表中的分布最均勻。

從上面的分析來看,使用與運算雖然效率高了,但是增加了使用限制,如果用%取余的做法,那么對于任何大小的容量都能做到均勻分布,可以把圖中代碼int a = keySet[j] & (c - 1); 改成 int a = keySet[j] % c;試一下。


HashMap的容量

通過上面的分析,容量是2的整數次冪的時候效率最高,那么很容易想到,如果隨著數據量的增長,HashMap需要擴容的時候是2倍擴容,區別于ArrayList的1.5倍擴容。
那么什么時候擴容呢?首先說明一下,我們所說的HashMap的容量是指散列表中數組的大小,這個大小不能決定HashMap能存多少數據,因為只要鏈表足夠長,存多少數據都沒問題。但是,數據量很大的時候,如果數組太小,就會導致鏈表很長,get元素的效率就會降低,所以我們應該在適當的時候擴容。源碼默認的做法是,當數據量達到容量的75%的時候擴容,這個值稱為負載因子,75%應該是大量實驗后統計得到的最優值,沒有特殊情況不要通過構造方法指定為其他值。
擴容是有代價了,會導致所有已存的數據重新計算位置,所以,和ArrayList一樣,當知道大概的數據量的時候,可以指定HashMap的大小盡量避免擴容,指定大小要注意75%這個負載因子,比如數據量是63個的話,HashMap的大小應該是128而不是64。

對于容量的計算,源碼已經封裝好了一個方法

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

此方法在HashMap的構造方法中被調用,所以指定容量的時候無需自己計算,比如數據量是63,直接new HashMap<>(63)即可。


HashMap的遍歷

前面提到一點,散列表中的鏈表的節點是Entry對象,通過Entry對象可以得到Key和Value。HashMap的遍歷方法有很多,大概可以分為3種,分別是通過map.entrySet()、map.keySet()、map.values()三種方式遍歷。比較效率的話,map.values()方式無法得到key,這里不考慮。比較map.entrySet()和map.keySet()的話,結合散列表的結構特點,很明顯map.entrySet()直接遍歷Entry集合(所有鏈表節點)取出Key和Value即可(一次循環),map.keySet()遍歷的是Key,得到Key之后在通過Key去遍歷相應的鏈表找到具體的節點(多個循環),所以前者效率高。


擴展:LinkedHashMap和LruCatch

對于LinkedHashMap的理解,我覺得一張圖就夠了:

LinkedHashMap結構圖

在散列表的基礎上加上了雙向循環鏈表(圖中黃色箭頭和綠色箭頭),所以可以拆分成一個散列表和一個雙向鏈表,雙向鏈表如下:

雙向循環鏈表圖

上面兩張圖片來自:https://www.cnblogs.com/xiaoxi/p/6170590.html

然后使用散列表操作數據,使用雙向循環鏈表維護順序,就實現了LinkedHashMap。

LinkedHashMap有一個屬性可以設置兩種排序方式:

private final boolean accessOrder;

false表示插入順序,true表示最近最少使用次序,后者就是LruCatch的實現原理。

LinkedHashMap和LruCatch的具體實現細節這里就不分析了。


最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,835評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,676評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,730評論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,118評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,873評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,266評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,330評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,482評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,036評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,846評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,025評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,575評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,279評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,684評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,953評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,751評論 3 394
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,016評論 2 375

推薦閱讀更多精彩內容

  • HashMap 是 Java 面試必考的知識點,面試官從這個小知識點就可以了解我們對 Java 基礎的掌握程度。網...
    野狗子嗷嗷嗷閱讀 6,681評論 9 107
  • 前言 今天來介紹下HashMap,之前的List,講了ArrayList、LinkedList,就前兩者而言,反映...
    嘟爺MD閱讀 2,889評論 2 56
  • 曾經天真的以為會是他手心里的寶、以為是那個可以隨時安心停靠的港灣、以為會是一輩子的依靠……那么多的以為終究只是一廂...
    獨孤晚晴閱讀 295評論 0 1
  • 月亮沒那么圓了 月餅沒那么甜了 人兒都走散了 故鄉的天不見了 故鄉的人別離了 別了只有再見了 再見又該很久了
    L志華閱讀 138評論 0 0
  • 前幾天朋友倩向我抱怨一位追求者的行動讓她煩惱不已。原來那男生從她高中開始喜歡她,現在大學又在同一個城市,于是便一直...
    snowinglemon閱讀 553評論 0 4