java集合架構08——HashMap和源碼分析
1.HashMap簡介
首先看一下HashMap的繼承關係
java.lang.Object ? java.util.AbstractMap ? java.util.HashMappublic class HashMap extends AbstractMap implements Map, Cloneable, Serializable { }我們可以看出,HashMap不僅繼承了AbstractMap,而且實現了Map、Cloneable和Serializable介面,所以HashMap也可以序列化。另外,HashMap是非同步的,但是我們可以通過Collections類的靜態方法synchronizedMap獲得安全執行緒的HashMap。即:
Map map = Collections.synchronizedMap(new HashMap());
下面先總覽一下HashMap都有哪些API,然後我們詳細分析它們。
void clear()Object clone()boolean containsKey(Object key)boolean containsValue(Object value)Set> entrySet()V get(Object key)boolean isEmpty()Set keySet()V put(K key, V value)void putAll(Map map)V remove(Object key)int size()Collection values()
2.HashMap的資料結構2.1儲存結構
HashMap儲存資料的數組定義如下,裡面存放的是Entry實體:
transient Entry[] table
HashMap的底層主要是基於數組合練不來實現的,它之所以有相當快的查詢速度主要是因為它是通過計算散列碼來決定儲存位置的。HashMap中主要是通過key的hashCode來計算hash值,然後通過hash值選擇不同的數組來儲存。只要hashCode相同,計算出來的hash值就一樣,如果儲存物件多了,就有可能不同的對象計算出來的hash值是相同的,這就出現了所謂的hash衝突,解決hash衝突的方法很多。HashMap的底層是通過鏈表來解決hash衝突的。下面看一下它的儲存結構圖:
圖中紫色部分代表雜湊表,其實就是雜湊數組,數組的每個元素都是一個單鏈表的頭結點,鏈表是用來解決hash衝突的,如果不同的key映射到了數組的同一位置,那麼就將其放入單鏈表中。下面的圖可能在代碼的角度更能說明問題:
下面我們閱讀一下數組中儲存的Entry實體類源碼。
2.2Entry實體
/** * Entry其實是個單向鏈表:它是“HashMap鏈式儲存法”對應的鏈表。 * 它實現了Map.Entry介面,也就是實現了getKey()、getValue()、setValue(V value) * equals(Object o)和hashCode()這些方法。**/static class Entry implements Map.Entry { final K key; V value; Entry next; //指向下一個節點 int hash; /** * 構造方法,建立一個Entry * 參數:雜湊值h,索引值k,值v和下一個節點n */ Entry(int h, K k, V v, Entry n) { value = v; next = n; key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } //判斷兩個Entry是否相等,必須key和value都相等,才返回true public final boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry e = (Map.Entry)o; Object k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { Object v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true; } return false; } public final int hashCode() { //實現hashCode return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); } public final String toString() { return getKey() + "=" + getValue(); } /** * 當向HashMap中添加元素時,即調用put(k,v)時, * 對已經在HashMap中k位置進行v的覆蓋時,會調用此方法 * 這裡沒做任何處理 */ void recordAccess(HashMap m) { } /** * 當從HashMap中刪除了一個Entry時,會調用該函數 * 這裡沒做任何處理 */ void recordRemoval(HashMap m) { }}從Entry實體源碼中可以看出,HashMap其實就是一個儲存Entry的數組,Entry對象包含了鍵和值,其中next也是一個Entry對象,用來處理hash衝突的,形成一個鏈表。這樣一來,我們對HashMap就有很好的理解了。下面我們詳細分析HashMap中的源碼。
3. HashMap源碼分析
之前分析源碼都是將所有源碼全部貼上來,然後分析部分放到源碼內部,這樣看起來有點太多,一下子好幾百行源碼看的有點懵。這章開始採用分段式分析,將源碼分分類,然後各部分突破,這樣看起來更加清晰明朗。
3.1成員屬性
先看看HashMap的幾個關鍵屬性:
//預設初始容量是16,必須是2的冪static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16//最大容量(必須是2的冪且小於2的30次方,傳入容量過大會被這個值替換)static final int MAXIMUM_CAPACITY = 1 << 30;//預設載入因子,所謂載入因子是指雜湊表在其容量自動增加之前可以達到多滿的一種尺度static final float DEFAULT_LOAD_FACTOR = 0.75f;//儲存Entry的預設空數組static final Entry[] EMPTY_TABLE = {};//儲存Entry的數組,長度為2的冪。HashMap採用拉鏈法實現的,每個Entry的本質是個單向鏈表transient Entry[] table = (Entry[]) EMPTY_TABLE;//HashMap的大小,即HashMap儲存的索引值對數量transient int size;//HashMap的閾值,用於判斷是否需要調整HashMap的容量int threshold;//載入因子實際大小final float loadFactor;//HashMap被修改的次數,用於fail-fast機制transient int modCount;我們主要來看看loadFactor屬性,loadFactor表示Hash表中元素的填滿程度。
若載入因子設定過大,則填滿的元素越多,無疑空間利用率變高了,但是衝突的機會增加了,衝突的越多,鏈表就會變得越長,那麼尋找效率就會變得更低;
若載入因子設定過小,則填滿的元素越少,那麼空間利用率變低了,表中資料將變得更加稀疏,但是衝突的機會減小了,這樣鏈表就不會太長,尋找效率變得更高。
這看起來有點繞口,我舉個簡單的例子,如果數組容量為100,載入因子設定為80,即裝滿了80個才開始擴容,但是在裝的過程中,可能有很多key對應相同的hash值,這樣就會放到同一個鏈表中(因為沒到80個不能擴容),這樣就會導致很多鏈表都變得很長,也就是說,不同的key對應相同的hash值比數組填滿到80個更加容易出現。
但是如果設定載入因子為10,那麼數組填滿10個就開始擴容了,10個相對來說是很容易填滿的,而且在10個內出現相同的hash值機率比上面的情況要小的多,一旦擴容之後,那麼計算hash值又會跟原來不一樣,就不會再衝突了,這樣保證了鏈表不會很長,甚至就一個表頭都有可能,但是空間利用率很低,因為始終有很多空間沒利用就開始擴容。
因此,就需要在“減小衝突”和“空間利用率”之間尋找一種平衡,這種平衡就是資料結構中有名的“時-空”矛盾的平衡。如果機器記憶體足夠,並且想要提高查詢速度的話可以將載入因子設定小一點;相反如果機器記憶體緊張,並且對查詢速度沒什麼要求的話可以將載入因子設定大一點。一般我們都使用它的預設值,即0.75。
3.2構造方法
下面看看HashMap的幾個構造方法:
/************************** 建構函式 *******************************/public HashMap(int initialCapacity, float loadFactor) {//帶有初始容量和載入因子//確保容量數字合法if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);if (initialCapacity > MAXIMUM_CAPACITY)initialCapacity = MAXIMUM_CAPACITY;if (loadFactor <= 0 || Float.isNaN(loadFactor))throw new IllegalArgumentException("Illegal load factor: " + loadFactor);this.loadFactor = loadFactor; //將閾值設定為初始容量,這裡不是真正的閾值,是為了擴充table的,後面這個閾值會重新計算 threshold = initialCapacity; init();//一個空方法用於未來的子物件擴充}public HashMap(int initialCapacity) { //帶有初始容量,載入因子設為預設值this(initialCapacity, DEFAULT_LOAD_FACTOR);}public HashMap() { //初始容量和載入因子均為預設值this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);}//構造一個映射關係與指定 Map 相同的新 HashMappublic HashMap(Map m) {this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);inflateTable(threshold);putAllForCreate(m);}我們可以看到,在構造HashMap的時候,如果我們指定了載入因子和初始容量的話就調用第一個構造方法,否則就用預設的。預設的初始容量為16載入因子為0.75。
3.3存取方法
存取部分重點分析一下put和get方法,因為這兩個方法也是最常用的。其他的存取方法,我放到代碼中分析。首先看看HashMap中是如何儲存資料的,看put方法:
public V put(K key, V value) {if (table == EMPTY_TABLE) { //如果雜湊表沒有初始化(table為空白)inflateTable(threshold); //用構造時的閾值(其實就是初始容量)擴充table}//如果key==null,就將value加到table[0]的位置//該位置永遠只有一個value,新傳進來的value會覆蓋舊的valueif (key == null) return putForNullKey(value);int hash = hash(key); //根據索引值計算hash值int i = indexFor(hash, table.length); //搜尋指定hash在table中的索引//迴圈遍曆Entry數組,若該key對應的索引值對已經存在,則用新的value取代舊的valuefor (Entry e = table[i]; e != null; e = e.next) {Object k;if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {V oldValue = e.value;e.value = value;e.recordAccess(this);return oldValue; //並返回舊的value}}modCount++;//如果在table[i]中沒找到對應的key,那麼就直接在該位置的鏈表中添加此EntryaddEntry(hash, key, value, i);return null;}我們下面一步步分析put方法內部都幹了些啥:
首先檢測table是不是為空白table,如果是空table,說明並沒有給table初始化,所以調用inflateTable(threadshold)方法給table初始化。該方法如下:
//擴充tableprivate void inflateTable(int toSize) {// Find a power of 2 >= toSizeint capacity = roundUpToPowerOf2(toSize); //擷取和toSize最接近的2的冪作為容量//重新計算閾值 threshold = 容量 * 載入因子threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);table = new Entry[capacity]; //用該容量初始化tableinitHashSeedAsNeeded(capacity);}//將初始容量轉變成2的冪private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY //如果容量超過了最大值,設定為最大值 //否則設定為最接近給定值的2的次冪數 : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;}在inflateTable方法內,首先初始化數組容量大小,數組容量永遠是2的冪(下面會分析為什麼要這樣)。所以調用roundUpToPowerOf2方法將傳進來的容量轉換成最接近2的次冪的值,然後重新計算閾值threadshold =容量 x載入因子,最後初始化table。所以剛開始初始化table不是在HashMap的建構函式裡,因為建構函式中僅僅簡單的將傳進去的容量作為閾值。真正初始化table是在第一次往HashMap中put資料的時候。
初始化好了table後,就開始往table中存入資料了,table中存的是Entry實體,而put方法傳進來的是key和value,所以接下來要做兩件事:
1.找到table數組中要存入的位置;
2.將key和value封裝到Entry中存入。
我們再回到put方法中,先來分析第一步,找儲存的位置就要依靠key的值了,因為需要用key的值來計算hash值,根據hash值來決定在table中的位置。首先當key為null時,調用putForNullKey方法,該方法內部實現如下:
//傳進key==null的Entryprivate V putForNullKey(V value) {for (Entry e = table[0]; e != null; e = e.next) {if (e.key == null) { V oldValue = e.value;e.value = value;e.recordAccess(this);return oldValue;}}modCount++;//如果table[0]處沒有key為nulladdEntry(0, null, value, 0);//如果鍵為null的話,則hash值為0return null;}從方法中可以看出,null的hash值為0,所以首先會定位到table[0]處,然後依次查詢是否有key==null的鍵,如果有,將對應的value用新的value值取代,同時返回舊的value值。如果沒有key==null的鍵,那麼調用addEntry方法,將空鍵和值封裝到Entry中放到table[0]的位置,addEntry方法如下:
//向HashMap中添加Entryvoid addEntry(int hash, K key, V value, int bucketIndex) {if ((size >= threshold) && (null != table[bucketIndex])) {resize(2 * table.length); //擴容2倍hash = (null != key) ? hash(key) : 0;bucketIndex = indexFor(hash, table.length);}createEntry(hash, key, value, bucketIndex);}//建立一個Entryvoid createEntry(int hash, K key, V value, int bucketIndex) {Entry e = table[bucketIndex];//先把table中該位置原來的Entry儲存//在table中該位置建立一個Entry,將原來的Entry掛到該Entry的nexttable[bucketIndex] = new Entry<>(hash, key, value, e);//所以table中的每個位置永遠只儲存一個最新加進來的Entry,其他Entry是一個掛一個,這樣掛上去的size++;}從該方法中可以看出,第一個參數是hash值,中間兩個是key和value,最後一個是插入table的索引位置。插入之前先判斷容量是否足夠,若不夠,HashMap中是2倍擴容。若夠了,addEntry中先計算hash值,然後通過調用indexFor方法返回在索引的位置,這兩個方法如下:
final int hash(Object k) {int h = hashSeed;if (0 != h && k instanceof String) {return sun.misc.Hashing.stringHash32((String) k);}h ^= k.hashCode(); // 預先處理hash值,避免較差的離散hash序列,導致table沒有充分利用 h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);}//這個方法有點意思,也是為什麼容量要設定為2的冪的原因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);}indexFor方法返回索引的位置,裡面只做了一件事:h & (length-1)。這究竟做了什嗎?為什麼這句能解釋容量必須為2的冪呢?我們詳細分析下:
首先,h & (length-1)相當於h & length,但是h % length效率比較低(HashTable中是這兒乾的)。為啥h & (length-1)相當於h % length呢?現在假設length為2的冪,那麼length就可以表示成100......00的形式(表示至少1個0),那麼length-1就是01111....11。對於任意小於length的數h來說,與01111...11做&後都是h本身,對於h=length來說,&後結果為0,對於大於length的數h,&過後相當於h-j*length,也就是h % length。這也就是為啥容量必須為2的冪了,為了最佳化,好做&運算,效率高。
其次,length為2的次冪的話,是偶數,這樣length-1為奇數,奇數的最後一位是1,這樣便保證了h & (length-1)的最後一位可能為0也可能為1(取決於h的值),即結果可能為奇數,也可能為偶數,這樣便可以保證散列的均勻性,即均勻分布在數組table中;而如果length為奇數的話,很明顯length-1為偶數,它的最後一位是0,這樣h & (length-1)的最後一位肯定為0,級只能為偶數,這樣任何hash值都會被映射到數組的偶數下標位置上,這便浪費了近一半的空間!因此,length去2的整數次冪,也是為了使不同hash值發生碰撞的機率較小,這樣就能使元素在雜湊表中均勻的散列。
再回到addEntry方法中,接下來就調用createEntry方法在table數組適當的位置開創一個Entry了,new Entry的時候,將next置為原本在該位置的Entry即可,這樣,原來的Entry就掛到現在的Entry上了,以後只要在該位置新new一個Entry,就將原來的掛上去,這樣一個掛一個,形成了一個鏈表。但是table中永遠儲存的是最新的Entry,並非一個真正的鏈表資料結構,只是這麼多Entry是一個個連在一起的,跟鏈表很像而已。
現在往上回到put方法,我們剛剛分析完了key==null的情況,接著往下走,下面其實跟剛剛分析的一樣了,先計算hash值,然後找到在table中的位置,然後開始判斷是否已經有相同的key的Entry放在那了,如果有,用新的value取代舊的value,如果沒有,用傳進來的key和value新new一個Entry放到table中,並與原來的Entry掛上。過程跟上面分析的一模一樣,唯一不同的就是key!=null。這裡就不再贅述了。
分析了put方法,看get方法應該很容易理解了。下面再看看HashMap中讀取資料的get方法:
public V get(Object key) {if (key == null)return getForNullKey(); //hey==null時,從table[0]中取Entry entry = getEntry(key);//key!=null->getEntryreturn null == entry ? null : entry.getValue();}private V getForNullKey() {if (size == 0) {return null;}for (Entry e = table[0]; e != null; e = e.next) {if (e.key == null)return e.value;//從table[0]中取key==null的value值}return null;}final Entry getEntry(Object key) {if (size == 0) {return null;}//取值與上面put中傳值相反int hash = (key == null) ? 0 : hash(key);for (Entry e = table[indexFor(hash, table.length)]; e != null; e = e.next) {Object k;// 如果hash值相等,並且key相等則證明這個桶裡的東西是我們想要的if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;}return null;}