Java-HashMap原理解析

本文分析HashMap的實現原理。html

數據結構(散列表)

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

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

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


哈希算法

對於散列表,不論是存值仍是取值,都須要經過Key來定位散列表中的一個具體的位置(即某個鏈表的某個節點),計算這個位置的方法就是哈希算法。數據結構

大概過程是這樣的:code

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

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

經過上面的哈希算法,能夠有以下結論:對象

  • 不一樣的key具體相同的哈希值叫作哈希衝突HashMap解決哈希衝突的方法是鏈表法,將具備相同哈希值的key放在同一個鏈表中,而後利用key類的equals方法來肯定具體是哪一個節點。
  • Key的惟一性是經過哈希值和equals方法共同決定的,因此想要用一個類做爲HashMap的鍵,必須重寫這個類的hashCode和equals方法。同理,HashSet是基於HashMap實現的,它沒有重複元素的特色是利用HashMap沒有重複鍵實現的。因此,Set集合裏面的元素類,也必須同時實現hashCode方法和equals方法。
  • HashMap存儲的數據是無序的。


爲何HashMap大小是2的整數次冪的時候效率最高

哈希算法主要分兩步操做:1.經過哈希值定位一個鏈表; 2.遍歷鏈表,經過equals方法找到具體節點。爲了使哈希算法效率最高,應該儘可能讓數據在哈希表中均勻分佈,由於那樣能夠避免出現過長的鏈表,也就下降了遍歷鏈表的代價。
如何保證均勻分佈?前面的哈希算法說到,經過取餘操做將Key的哈希值轉換成數組下標,這樣能夠認爲是均勻的。可是,源碼中並無直接用%操做符取餘,而是使用了更高效的與運算,源碼以下:blog

/**
 * 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結構圖

在散列表的基礎上加上了雙向循環鏈表(圖中黃色箭頭和綠色箭頭),因此能夠拆分紅一個散列表和一個雙向鏈表,雙向鏈表以下:

雙向循環鏈表圖

上面兩張圖片來自:http://www.javashuo.com/article/p-wdfeiofg-ds.html

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

LinkedHashMap有一個屬性能夠設置兩種排序方式:

private final boolean accessOrder;

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

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

相關文章
相關標籤/搜索