本文分析HashMap的實現原理。
數據結構(散列表)
HashMap是一個散列表(也叫哈希表),用來存儲鍵值對(key-value)映射。散列表是一種數組和鏈表的結合體,結構圖如下:
簡單來說散列表就是一個數組(上圖縱向),數組的每個元素是一個鏈表(上圖橫向),類似二維數組。鏈表的每個節點就是我們存儲的key-value數據(源碼中將key和value封裝成Entry對象作為鏈表的節點)。
哈希算法
對于散列表,不管是存值還是取值,都需要通過Key來定位散列表中的一個具體的位置(即某個鏈表的某個節點),計算這個位置的方法就是哈希算法。
大概過程是這樣的:
- 用Key的hash值對數組長度做取余操作得到一個整數,這個整數作為數組中的索引得到這個索引位置的鏈表。
- 得到鏈表之后,就可以存值和取值了。
如果是存值,直接把數據插入到鏈表的頭部或者尾部即可(或者已存在就替換);
如果是取值,就遍歷鏈表,通過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。
LinkedHashMap有一個屬性可以設置兩種排序方式:
private final boolean accessOrder;
false表示插入順序,true表示最近最少使用次序,后者就是LruCatch的實現原理。
LinkedHashMap和LruCatch的具體實現細節這里就不分析了。