一、HashMap概述
HashMap是基於哈希表的Map接口實現,此實現提供所有可選的映射操作,並允許使用null值和null鍵。HashMap與HashTable的作用大致相同,但是它不是線程安全的。此類不保證映射的順序,特別是它不保證該順序恆久不變。
遍歷HashMap的時間復雜度與其的容量(capacity)和現有元素的個數(size)成正比。如果要保證遍歷的高效性,初始容量(capacity)不能設置太高或者平衡因子(load factor)不能設置太低。
二、HashMap的介紹
1.HashMap是存儲鍵值對(key,value)的一種數據結構。
2.每一個元素都是一個key-value。
3.HashMap最多只允許一個key為null,允許多個key的value值為null。
4.HashMap是非線程安全的,只適用於單線程環境。
5.HashMap實現了Serializable、Cloneable接口,因此它支持序列化和克隆。
二、HashMap在JDK1.7和JDK1.8的區別
JDK1.7的HashMap是基於一個數組加多個單鏈表來實現的,hash值沖突時,就將對應節點以鏈表的形式存儲,這樣子HashMap在性能上就存在一定的問題,為什么這么說呢?
因為如果成百上千個節點在hash時發生碰撞,那么如果要查找其中一個節點,最差的情況下要查找的節點就是鏈表末尾的節點,那么最差情況下的時間復雜度為 O(n) ,這樣毫無疑問會造成性能低下。
因此這問題在JDK1.8中得到了很好解決的方案,在JDK1.8中采用的是位桶+鏈表/紅黑樹的結構實現,而在JDK1.8中的時候鏈表長度達到一個闕值(通常節點數量 > 8 )的時候就會轉換成紅黑樹結構,至於紅黑樹,自己去了解后再來看此篇文章,眾所周知紅黑樹的時間復雜度為 log n ,這無疑是對性能的一次大提升。相對於JDK1.7的位桶+鏈表的實現方式來說,性能誰優誰劣,可想而知。
接下來從底層結構、put和get方法、hash數組索引、擴容機制等幾個方面來分析HashMap的實現原理:
首先看一下JDK1.7中HashMap的底層結構圖,如下所示:

接下來,讓我們先看看JDK1.7中HashMap類中的成員變量,如下:
/** 初始容量,默認16 */ (1) static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** 最大初始容量,2^30 */ (2) static final int MAXIMUM_CAPACITY = 1 << 30; /** 負載因子,默認0.75,負載因子越小,hash沖突機率越低 */ (3) static final float DEFAULT_LOAD_FACTOR = 0.75f; /** 初始化一個Entry的空數組 */ (4) static final Entry<?,?>[] EMPTY_TABLE = {}; /** 將初始化好的空數組賦值給table,table數組是HashMap實際存儲數據的地方,並不在EMPTY_TABLE數組中 */ (5) transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; /** HashMap實際存儲的元素個數 */ (6) transient int size; /** 臨界值(HashMap 實際能存儲的大小),公式為(threshold = capacity * loadFactor) */ (7) int threshold; /** 負載因子 */ (8) final float loadFactor; /** HashMap的結構被修改的次數,用於迭代器 */ (9) transient int modCount;
代碼(1)初始化桶大小,因為底層是數組,所以這是數組默認的大小。即16。
代碼(2)桶最大值。即2的30次方
代碼(3)默認的負載因子(0.75),負載因子越小,hash沖突機率越低 。
代碼(4)將初始化好的空數組賦值給table,table數組是HashMap實際存儲數據的地方,並不在EMPTY_TABLE數組中 。HashMap內部的存儲結構是一個數組,此處數組為空,即沒有初始化之前的狀態
代碼(5)table 真正存放數據的數組。
代碼(6)Map 存放數量的大小。HashMap實際存儲的元素個數。實際存儲的key-value鍵值對的個數
代碼(7)桶大小,可在初始化時顯式指定。當table == {}時,該值為初始容量(初始容量默認為16);當table被填充了,也就是為table分配內存空間后,threshold一般為 capacity*loadFactory。HashMap在進行擴容時需要參考threshold。
代碼(8)負載因子,可在初始化時顯式指定。代表了table的填充度有多少
代碼(9)HashMap的結構被修改的次數,用於迭代器。用於快速失敗,由於HashMap非線程安全,在對HashMap進行迭代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException
接下來,讓我們看看HashMap的構造函數,如下所示:
//計算Hash值時的key transient int hashSeed = 0; //通過初始容量和狀態因子構造HashMap public HashMap(int initialCapacity, float loadFactor) {
//(1) if (initialCapacity < 0)//參數有效性檢查 throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
//(2) if (initialCapacity > MAXIMUM_CAPACITY)//參數有效性檢查 initialCapacity = MAXIMUM_CAPACITY;
//(3) if (loadFactor <= 0 || Float.isNaN(loadFactor))//參數有效性檢查 throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; threshold = initialCapacity;
//(4) init(); } //(5) public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //(6) public HashMap() { this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } //(7) public HashMap(Map<? extends K, ? extends V> m) { this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
//(8) inflateTable(threshold);
//(9) putAllForCreate(m); }
代碼(1)校驗初始化容量大小。非法參數則拋異常
代碼(2)初始化容量是否大於容量的最大值,如果大於,將初始化容量設置成最大值。
代碼(3)校驗加載因子,不合法的加載因子則拋異常。
代碼(4)init方法在HashMap中沒有實際實現,不過在其子類如 linkedHashMap中就會有對應實現
代碼(5)通過擴容因子構造HashMap,容量取默認值,即16 。
代碼(6)裝載因子取0.75,容量取16,構造HashMap 。
代碼(7)通過其他Map來初始化HashMap,容量通過其他Map的size來計算,裝載因子取0.75 。
代碼(8)初始化HashMap底層的數組結構。
代碼(9)添加m中的元素。
給定的默認容量為 16,負載因子為 0.75。Map 在使用過程中不斷的往里面存放數據,當數量達到了 16 * 0.75 = 12 就需要將當前 16 的容量進行擴容,而擴容這個過程涉及到 rehash、復制數據等操作,所以非常消耗性能。
因此通常建議能提前預估 HashMap 的大小最好,盡量的減少擴容帶來的性能損耗。
根據代碼可以看到其實真正存放數據的是
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
這個數組,那么它又是如何定義的呢?
// 靜態內部類 static class Entry<K,V> implements Map.Entry<K,V> {
//(1) final K key;
//(2) V value;
//(3) Entry<K,V> next; // 只想下一個entry節點
//(4) int hash; /** * 構造函數,每次都用新的節點指向鏈表的頭結點。新節點作為鏈表新的頭結點 */ Entry(int h, K k, V v, Entry<K,V> 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; } 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() { return (key==null ? 0 : key.hashCode()) ^ (value==null ? 0 : value.hashCode()); } public final String toString() { return getKey() + "=" + getValue(); } //(5) void recordAccess(HashMap<K,V> m) { } //(6) void recordRemoval(HashMap<K,V> m) { } }
代碼(1)key 就是寫入時的鍵。
代碼(2)value 自然就是值。
代碼(3)開始的時候就提到 HashMap 是由數組和鏈表組成,所以這個 next 就是用於實現鏈表結構。
代碼(4)hash 存放的是當前 key 的 hashcode。
代碼(5)每當Entry中的值被已在HashMap中的鍵k的put(k,v)調用覆蓋時,都會調用此方法。
代碼(6)每當從表中刪除Entry時,都會調用此方法。
了解了基本結構,那來看看其中重要的put 方法和get方法:
1.put 方法 源碼如下:
public V put(K key, V value) {
//(1) if (table == EMPTY_TABLE) { inflateTable(threshold); }
//(2) if (key == null) return putForNullKey(value);
//(3) int hash = hash(key);
//(4) int i = indexFor(hash, table.length);
//(5) for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k;
//(6) if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } //(7) modCount++;
//(8) addEntry(hash, key, value, i); return null; }
代碼(1)如果table數組為空數組{},進行數組填充(為table分配實際內存空間),入參為threshold,此時threshold為initialCapacity 默認是1<<4(=16)。、
代碼(2)若“key為null”,則將該鍵值對添加到table[0]處,遍歷該鏈表,如果有存在key為null的entry,則將value替換。沒有就創建新Entry對象放在鏈表表頭,所以table[0]的位置上,永遠最多存儲1個Entry對象,形成不了鏈表。key為null的Entry存在這里。
代碼(3)若key不為null,則計算該key的hash值,然后將其添加到該哈希值對應的數組索引處的鏈表中。
代碼(4)根據hash值計算桶號。
代碼(5)遍歷該桶中的鏈表。
代碼(6)如果其hash值相等且鍵也相等,將新值替換舊值,並返回舊值。
代碼(7)保證並發訪問時,若HashMap內部結構發生變化,快速響應失敗。即修改次數+1
代碼(8)如果桶是空的,說明當前位置沒有數據存入;新增一個 Entry 對象寫入當前位置。
接下來我們進入到代碼(1)中的數組空間分配的方法 inflateTable(threshold),源碼如下:
private void inflateTable(int toSize) { int capacity = roundUpToPowerOf2(toSize);//(1) threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//(2) table = new Entry[capacity];//(3) initHashSeedAsNeeded(capacity);//(4) }
代碼(1)capacity一定是2的次冪
代碼(2)此處為threshold賦值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不會超過MAXIMUM_CAPACITY,除非loadFactor大於1。
代碼(3)分配空間。
代碼(4)選擇合適的Hash因子。
inflateTable這個方法用於為主干數組table在內存中分配存儲空間,通過roundUpToPowerOf2(toSize)可以確保capacity為大於或等於toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32。roundUpToPowerOf2(toSize)源碼如下所示:
private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; }
roundUpToPowerOf2中的這段處理使得數組長度一定為2的次冪,Integer.highestOneBit是用來獲取最左邊的bit(其他bit位為0)所代表的數值。
接下來我們看看put方法中的hash方法的計算,源碼如下:
//(1) final int hash(Object k) {
//(2) int h = hashSeed;
//(3) if (0 != h && k instanceof String) {//這里針對String優化了Hash函數,是否使用新的Hash函數和Hash因子有關 return sun.misc.Hashing.stringHash32((String) k); } //(4) h ^= k.hashCode(); //(5) h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
h ^= k.hashCode(): 0101 1101 0010 1111 1100 0110 0011 0101 ------------------------------------------------------------ h >>> 20: : 0000 0000 0000 0000 0000 0101 1101 0010 h >>> 12 : 0000 0000 0000 0101 1101 0010 1111 1100 ------------------------------------------------------------ (h >>> 20) ^ (h >>> 12) : 0000 0000 0000 0101 1101 0111 0010 1110 ----------------------------------------------------------- h ^= (h >>> 20) ^ (h >>> 12) : 0101 1101 0010 1010 0001 0001 0001 1011 ----------------------------------------------------------- (h >>> 7) : 0000 0000 1011 1010 0101 0100 0010 0010 (h >>> 4) : 0000 0101 1101 0010 1010 0001 0001 0001 ----------------------------------------------------------- (h >>> 7) ^ (h >>> 4) :0000 0101 0110 1000 1111 0101 0011 0011 ----------------------------------------------------------- h ^ (h >>> 7) ^ (h >>> 4) :0101 1000 0100 0010 1110 0100 0010 1000 ----------------------------------------------------------- h & (length-1) :0000 0000 0000 0000 0000 0000 0000 1000 = 8
代碼(1)判斷k的數據類型選擇不同的hash計算方式。用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進制位的調整等來保證最終獲取的存儲位置盡量分布均勻。
代碼(2)隨機種子,用來降低沖突發生的幾率
代碼(3)這里針對String優化了Hash函數,是否使用新的Hash函數和Hash因子有關。
代碼(5)這個函數確保哈希碼在每個位的倍數不變的情況下只會發生有限數量的碰撞(默認負載系數大約為8)。
從上面的操作看以看出,影響HashMap元素的存儲位置的只有key的值,與value值無關。
就這樣,通過高低位之間進行異或用來加大低位的隨機性,以減少沖突的幾率。
通過hash函數得到散列值后,再通過indexFor進一步處理來獲取實際的存儲位置,其實現如下:
//返回數組下標 static int indexFor(int h, int length) {
//(1) return h & (length-1); }
代碼(1)把hash值和數組的長度進行“與”操作。
該方法用於確定元素存放於數組的位置,但是參數h是一個由hash方法計算而來的int類型數據,如果直接拿h作為下標訪問HashMap主數組的話,考慮到2進制32位帶符號的int值范圍從-2147483648到2147483648,該值可能會很大,所以這個值不能直接使用,要用它對數組的長度進行取模運算,得到的余數才能用來當做數組的下標,這就是indexFor方法做的事情。(因為length總是為2的N次方,所以h & (length-1)操作等價於hash % length操作, 但&操作性能更優)。
該方法也是HashMap的數組長度為什么總是2的N次方的原因。2的N次方 - 1的二進制碼是一個“低位掩碼”,“與”操作后會把hash值的高位置零,只保留低位的值,使用這種方法使值縮小。以初始長度16為例,16-1=15。2進制表示是00000000 00000000 00001111。和某散列值做“與”操作如下,結果就是截取了最低的四位值。例子如下:
10100101 11000100 00100101 & 00000000 00000000 00001111 ---------------------------------- 00000000 00000000 00000101 //高位全部歸零,只保留末四位
這樣,就算差距很大的兩個數,只要低位相同,那么就會產生沖突,會對性能造成很大的影響,於是,hash方法的作用就體現出來了。
接着我們再看看普通方法中調用的addEntry方法,如下:
void addEntry(int hash, K key, V value, int bucketIndex) {
//(1) if ((size >= threshold) && (null != table[bucketIndex])) {
//(2) resize(2 * table.length);
//(3) hash = (null != key) ? hash(key) : 0;
//(4) bucketIndex = indexFor(hash, table.length); } //(5) createEntry(hash, key, value, bucketIndex); } void createEntry(int hash, K key, V value, int bucketIndex) {
//(6) Entry<K,V> e = table[bucketIndex];
//(7) table[bucketIndex] = new Entry<>(hash, key, value, e);
//元素個數加1 size++; }
代碼(1)當調用 addEntry 寫入 Entry 時需要判斷是否需要擴容。
代碼(2)當size超過臨界閾值threshold,並且即將發生哈希沖突時進行擴容,新容量為舊容量的2倍。
代碼(3)擴容后,重新計算哈希值。
代碼(4)擴容后重新計算插入的位置下標,即重新計算桶號。
代碼(5)createEntry 中會將當前位置的桶傳入到新建的桶中,如果當前桶有值就會在位置形成鏈表。
代碼(6)獲取待插入位置元素
代碼(7)這里執行鏈接操作,使得新插入的元素指向原有元素。這保證了新插入的元素總是在鏈表的頭。
發生哈希沖突並且size大於閾值的時候,需要進行數組擴容,擴容時,需要新建一個長度為之前數組2倍的新的數組,然后將當前的Entry數組中的元素全部傳輸過去,擴容后的新數組長度為之前的2倍,所以擴容相對來說是個耗資源的操作。接下來讓我們看看resize(2 * table.length)的擴容方法,源碼如下:
//按新的容量擴容Hash表
void resize(int newCapacity) {
//(1)
Entry[] oldTable = table;
//(2)
int oldCapacity = oldTable.length;
//(3)
if (oldCapacity == MAXIMUM_CAPACITY) {
//(4)
threshold = Integer.MAX_VALUE;//修改擴容閥值
return;
}
//(5)
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
//(6)計算是否需要對鍵重新進行哈希碼的計算
useAltHashing |= sun.misc.VM.isBooted() && (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
//(7)
transfer(newTable, rehash);
//(8)
table = newTable;
//(9)
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
代碼(1)先獲取老的數據
代碼(2)獲取老的容量值。
代碼(3)如果老的容量值已經到了最大容量值,則修改擴容闕值
代碼(5)創建新的結構
代碼(6)計算是否需要對鍵重新進行哈希碼的計算
代碼(7)將老的表中的數據拷貝到新的結構中。將原有所有的桶遷移至新的桶數組中 ,在遷移時,桶在桶數組中的絕對位置可能會發生變化 *,這就是為什么HashMap不能保證存儲條目的順序不能恆久不變的原因
代碼(8)修改HashMap的底層數組
代碼(9)修改閥值
接下來讓我們進入到tranfer方法中,看看是如何進行桶遷移至新的桶數組中的,源碼如下:
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; //(1) for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next;
//(2) if (rehash) { //(3) e.hash = null == e.key ? 0 : hash(e.key); } //(4) int i = indexFor(e.hash, newCapacity); //(5) e.next = newTable[i]; //(6) newTable[i] = e;
//(7) e = next; } } }
代碼(1)遍歷當前的table,將里面的元素添加到新的newTable中。
代碼(2)如果是重新Hash
代碼(3)重新計算hash值。
代碼(4)計算桶號
代碼(5)元素連接到桶中,這里相當於單鏈表的插入,總是插入在最前面
代碼(6)存放在數組下標i中,所以擴容后鏈表的順序與原來相反。
代碼(7)繼續下一個元素。
接着,讓我們看看HashMap的put方法,源碼如下:
//(1)
public V get(Object key) {
//(2) if (key == null) return getForNullKey();
//(3) Entry<K,V> entry = getEntry(key); //(4) return null == entry ? null : entry.getValue(); } final Entry<K,V> getEntry(Object key) {
//(5) if (size == 0) { return null; } //(6) int hash = (key == null) ? 0 : hash(key);
//(7) for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k;
//(8) if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; }
//(9) return null; }
//獲取key為null的實體 private V getForNullKey() { if (size == 0) {//如果元素個數為0,則直接返回null return null; } //key為null的元素存儲在table的第0個位置 for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null)//判斷是否為null return e.value;//返回其值 } return null; }
代碼(1)獲取key值為key的元素值。
代碼(2)如果Key值為空,則獲取對應的值,這里也可以看到,HashMap允許null的key,其內部針對null的key有特殊的邏輯。
代碼(3)如果建不為null,獲取Entry實體。
代碼(4)判斷是否為空,不為空,則獲取對應的值。
代碼(5)元素個數為 0 ,直接返回null。
代碼(6)計算key的hash值。
代碼(7)根據key和表的長度,定位到Hash桶。
代碼(8)遍歷直到 key 及 hashcode 相等時候就返回值。
代碼(9)啥也沒取到直返回null。
接着讓我們看最后一個HashMap的方法,remove方法,源碼如下:
final Entry<K,V> removeEntryForKey(Object key) { //計算鍵的hash值 int hash = (key == null) ? 0 : hash(key); //計算桶號 int i = indexFor(hash, table.length); //記錄待刪除節點的上一個節點 Entry<K,V> prev = table[i]; //待刪除節點 Entry<K,V> e = prev; while (e != null) { Entry<K,V> next = e.next; Object k; //是否是將要刪除的節點 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; //將要刪除的節點是否為鏈表的頭部 if (prev == e) //鏈表的頭部指向下一節點 table[i] = next; else //上一節點的NEXT為將要刪除節點的下一節點 prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; }
JDK1.8中,HashMap的變化。
到目前為止,不知道大家有沒有發現JDK1.7中需要優化的地方?
正如開篇提到的當 Hash 沖突嚴重時,在桶上形成的鏈表會變的越來越長,這樣在查詢時的效率就會越來越低;時間復雜度為 O(N)。
因此JDK1.8中中采用的是位桶+鏈表/紅黑樹的結構實現,而在JDK1.8中的時候鏈表長度達到一個闕值(通常節點數量 > 8 )的時候就會轉換成紅黑樹結構,至於紅黑樹,自己去了解后再來看此篇文章,眾所周知紅黑樹的時間復雜度為 log n ,這無疑是對性能的一次大提升。相對於JDK1.7的位桶+鏈表的實現方式來說,性能誰優誰劣,可想而知。
JDK1.8中的HashMap的結構圖如下:

接着讓我們看看JDK1.8中的HashMap的成員變量。源碼如下:
//(1) 默認的初始容量是16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //(2) 桶最大容量 static final int MAXIMUM_CAPACITY = 1 << 30; //(3) 默認的負載因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //(4) 當桶(bucket)上的結點數大於這個值時會轉成紅黑樹 static final int TREEIFY_THRESHOLD = 8; //(5) 當桶(bucket)上的結點數小於這個值時樹轉鏈表 static final int UNTREEIFY_THRESHOLD = 6; //(6) 桶中結構轉化為紅黑樹對應的table的最小大小 static final int MIN_TREEIFY_CAPACITY = 64; //(7) 存儲元素的數組,總是2的冪次倍 transient Node<k,v>[] table; //(8) 存放具體元素的集 transient Set<map.entry<k,v>> entrySet; //(9) 存放元素的個數,注意這個不等於數組的長度。 transient int size; //(10) 每次擴容和更改map結構的計數器 transient int modCount; //(11) 臨界值 當實際大小(容量*填充因子)超過臨界值時,會進行擴容 int threshold; //(12) 負載因子 final float loadFactor;
代碼(1)初始化桶大小,因為底層是數組,所以這是數組默認的大小。即16。
代碼(2)桶最大值。即2的30次方
代碼(3)默認的負載因子(0.75),負載因子越小,hash沖突機率越低 。
代碼(4)用於判斷是否需要將鏈表轉換為紅黑樹的閾值。當桶(bucket)上的結點數大於這個值時會轉成紅黑樹
代碼(5)當桶(bucket)上的結點數小於這個值時樹轉鏈表
代碼(6)桶中結構轉化為紅黑樹對應的table的最小大小
代碼(7)存儲元素的數組,總是2的冪次倍。當table == {}時,該值為初始容量(初始容量默認為16);當table被填充了,也就是為table分配內存空間后,threshold一般為 capacity*loadFactory。HashMap在進行擴容時需要參考threshold。
代碼(8)存放具體元素的集。
代碼(9)存放元素的個數,注意這個不等於數組的長度。
代碼(10)HashMap的結構被修改的次數,用於迭代器。用於快速失敗,由於HashMap非線程安全,在對HashMap進行迭代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException。
代碼(11)臨界值 當實際大小(容量*填充因子)超過臨界值時,會進行擴容。、
代碼(12)負載因子。
可以看到JDK1.8中HashMap的成員變量和 1.7 大體上都差不多。
構造函數稍微有點變化,JDK1.8的構造函數源碼如下:
public HashMap(int initialCapacity, float loadFactor) { // 桶初始容量不能小於0,否則報錯 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // 桶初始容量不能大於最大值,否則為最大值 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //校驗負載因子 負載因子不能小於或等於0,不能為非數字 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 初始化負載因子 this.loadFactor = loadFactor; // 初始化threshold大小 this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { // 調用HashMap(int, float)型構造函數,通過擴容因子構造HashMap,容量取默認值,即16 。 this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { // 初始化負載因子,裝載因子取0.75,容量取16,構造HashMap this.loadFactor = DEFAULT_LOAD_FACTOR; } //通過其他Map來初始化HashMap,容量通過其他Map的size來計算,裝載因子取0.75 public HashMap(Map<? extends K, ? extends V> m) { // 初始化負載因子 this.loadFactor = DEFAULT_LOAD_FACTOR; // 將m中的所有元素添加至HashMap中 putMapEntries(m, false); }
不同在於:
1.初始化threshold大小 this.threshold = tableSizeFor(initialCapacity);有所不同,tableSizeFor(initialCapacity)返回大於initialCapacity的最小的二次冪數值。源碼如下:
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; }
>>> 操作符表示無符號右移,高位取0。
2.通過其他Map來初始化HashMap,putMapEntries(Map<? extends K, ? extends V> m, boolean evict)函數將m的所有元素存入本HashMap實例中。源碼如下:
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); if (s > 0) { // 判斷table是否已經初始化 if (table == null) { // pre-size // 未初始化,s為m的實際元素個數 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 計算得到的t大於閾值,則初始化閾值 if (t > threshold) threshold = tableSizeFor(t); } // 已初始化,並且m元素個數大於閾值,進行擴容處理 else if (s > threshold) resize(); // 將m中的所有元素添加至HashMap中 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } }
接着我們再看JDK1.8中的Hash算法,源碼如下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
首先獲取對象的hashCode()值,然后將hashCode值右移16位,然后將右移后的值與原來的hashCode做異或運算,返回結果。(其中h>>>16,在JDK1.8中,優化了高位運算的算法,使用了零擴展,無論正數還是負數,都在高位插入0)。
接着再看看JDK1.8中的put方法和get方法中的變化。
先看put方法,如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // (1) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // (2) if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 桶中已經存在元素 else { Node<K,V> e; K k; // (3) if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 將第一個元素賦值給e,用e來記錄 e = p; // (4) else if (p instanceof TreeNode) // 放入樹中 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 該鏈為鏈表 // 為鏈表結點 else { //(5) for (int binCount = 0; ; ++binCount) { // 到達鏈表的尾部 if ((e = p.next) == null) { // 在尾部插入新結點 p.next = newNode(hash, key, value, null); // (6) if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); // 跳出循環 break; } // (7) if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) // 相等,跳出循環 break; // 用於遍歷桶中的鏈表,與前面的e = p.next組合,可以遍歷鏈表 p = e; } } // (8) if (e != null) { // 記錄e的value V oldValue = e.value; // onlyIfAbsent為false或者舊值為null if (!onlyIfAbsent || oldValue == null) //用新值替換舊值 e.value = value; // 訪問后回調 afterNodeAccess(e); // 返回舊值 return oldValue; } } // 結構性修改 ++modCount; // (9) if (++size > threshold) resize(); // 插入后回調 afterNodeInsertion(evict); return null; }
看似要比 1.7 的復雜,我們一步步拆解:
代碼(1)判斷當前桶是否為空,空的就需要初始化(resize 中會判斷是否進行初始化)。
代碼(2)根據當前 key 的 hashcode 定位到具體的桶中並判斷是否為空,為空表明沒有 Hash 沖突就直接在當前位置創建一個新桶即可。
代碼(3)如果當前桶有值( Hash 沖突),那么就要比較當前桶中的 key、key 的 hashcode 與寫入的 key 是否相等,相等就賦值給 e,在第 8 步的時候會統一進行賦值及返回。
代碼(4)如果當前桶為紅黑樹,那就要按照紅黑樹的方式寫入數據。
代碼(5)如果是個鏈表,就需要將當前的 key、value 封裝成一個新節點寫入到當前桶的后面(形成鏈表)。
代碼(6)接着判斷當前鏈表的大小是否大於預設的閾值,大於時就要轉換為紅黑樹。
代碼(7)判斷鏈表中結點的key值與插入的元素的key值是否相等
代碼(8)接着判斷當前鏈表的大小是否大於預設的閾值,大於時就要轉換為紅黑樹。
代碼(9)最后判斷是否需要進行擴容。超過最大容量就擴容,實際大小大於閾值則擴容。
HashMap的數據存儲實現原理
流程:
1. 根據key計算得到key.hash = (h = k.hashCode()) ^ (h >>> 16);
2. 根據key.hash計算得到桶數組的索引index = key.hash & (table.length - 1),這樣就找到該key的存放位置了:
① 如果該位置沒有數據,用該數據新生成一個節點保存新數據,返回null;
② 如果該位置有數據是一個紅黑樹,那么執行相應的插入 / 更新操作;
③ 如果該位置有數據是一個鏈表,分兩種情況一是該鏈表沒有這個節點,另一個是該鏈表上有這個節點,注意這里判斷的依據是key.hash是否一樣:
如果該鏈表沒有這個節點,那么采用尾插法新增節點保存新數據,返回null;如果該鏈表已經有這個節點了,那么找到該節點並更新新數據,返回老數據。
注意:
HashMap的put會返回key的上一次保存的數據,比如:
HashMap<String, String> map = new HashMap<String, String>();
System.out.println(map.put("a", "A")); // 打印null
System.out.println(map.put("a", "AA")); // 打印A
System.out.println(map.put("a", "AB")); // 打印AA
接着看紅黑樹如何插入數據的, putTreeVal(this, tab, hash, key, value) 源碼如下:
/** * Tree version of putVal. * 紅黑樹插入會同時維護原來的鏈表屬性, 即原來的next屬性 */ final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, int h, K k, V v) { Class<?> kc = null; boolean searched = false; // 查找根節點, 索引位置的頭節點並不一定為紅黑樹的根結點 TreeNode<K,V> root = (parent != null) ? root() : this; for (TreeNode<K,V> p = root;;) { // 將根節點賦值給p, 開始遍歷 int dir, ph; K pk; if ((ph = p.hash) > h) // 如果傳入的hash值小於p節點的hash值 dir = -1; // 則將dir賦值為-1, 代表向p的左邊查找樹 else if (ph < h) // 如果傳入的hash值大於p節點的hash值, dir = 1; // 則將dir賦值為1, 代表向p的右邊查找樹 // 如果傳入的hash值和key值等於p節點的hash值和key值, 則p節點即為目標節點, 返回p節點 else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; // 如果k所屬的類沒有實現Comparable接口 或者 k和p節點的key相等 else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) { if (!searched) { // 第一次符合條件, 該方法只有第一次才執行 TreeNode<K,V> q, ch; searched = true; // 從p節點的左節點和右節點分別調用find方法進行查找, 如果查找到目標節點則返回 if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null)) return q; } // 否則使用定義的一套規則來比較k和p節點的key的大小, 用來決定向左還是向右查找 dir = tieBreakOrder(k, pk); // dir<0則代表k<pk,則向p左邊查找;反之亦然 } TreeNode<K,V> xp = p; // xp賦值為x的父節點,中間變量,用於下面給x的父節點賦值 // dir<=0則向p左邊查找,否則向p右邊查找,如果為null,則代表該位置即為x的目標位置 if ((p = (dir <= 0) ? p.left : p.right) == null) { // 走進來代表已經找到x的位置,只需將x放到該位置即可 Node<K,V> xpn = xp.next; // xp的next節點 // 創建新的節點, 其中x的next節點為xpn, 即將x節點插入xp與xpn之間 TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); if (dir <= 0) // 如果時dir <= 0, 則代表x節點為xp的左節點 xp.left = x; else // 如果時dir> 0, 則代表x節點為xp的右節點 xp.right = x; xp.next = x; // 將xp的next節點設置為x x.parent = x.prev = xp; // 將x的parent和prev節點設置為xp // 如果xpn不為空,則將xpn的prev節點設置為x節點,與上文的x節點的next節點對應 if (xpn != null) ((TreeNode<K,V>)xpn).prev = x; moveRootToFront(tab, balanceInsertion(root, x)); // 進行紅黑樹的插入平衡調整 return null; } } }
1.查找當前紅黑樹的根結點,將根結點賦值給p節點,開始進行查找
2.如果傳入的hash值小於p節點的hash值,將dir賦值為-1,代表向p的左邊查找樹
3.如果傳入的hash值大於p節點的hash值, 將dir賦值為1,代表向p的右邊查找樹
4.如果傳入的hash值等於p節點的hash值,並且傳入的key值跟p節點的key值相等, 則該p節點即為目標節點,返回p節點
5.如果k所屬的類沒有實現Comparable接口,或者k和p節點的key使用compareTo方法比較相等:第一次會從p節點的左節點和右節點分別調用find方法(見上文代碼塊2)進行查找,如果查找到目標節點則返回;如果不是第一次或者調用find方法沒有找到目標節點,則調用tieBreakOrder方法(見下文代碼塊5)比較k和p節點的key值的大小,以決定向樹的左節點還是右節點查找。
6.如果dir <= 0則向左節點查找(p賦值為p.left,並進行下一次循環),否則向右節點查找,如果已經無法繼續查找(p賦值后為null),則代表該位置即為x的目標位置,另外變量xp用來記錄查找的最后一個節點,即下文新增的x節點的父節點。
7.以傳入的hash、key、value參數和xp節點的next節點為參數,構建x節點(注意:xp節點在此處可能是葉子節點、沒有左節點的節點、沒有右節點的節點三種情況,即使它是葉子節點,它也可能有next節點,紅黑樹的結構跟鏈表的結構是互不影響的,不會因為某個節點是葉子節點就說它沒有next節點,紅黑樹在進行操作時會同時維護紅黑樹結構和鏈表結構,next屬性就是用來維護鏈表結構的),根據dir的值決定x決定放在xp節點的左節點還是右節點,將xp的next節點設為x,將x的parent和prev節點設為xp,如果原xp的next節點(xpn)不為空, 則將該節點的prev節點設置為x節點, 與上面的將x節點的next節點設置為xpn對應。
8.進行紅黑樹的插入平衡調整,
接下來讓我們看看鏈表如何轉紅黑樹,treeifyBin(tab, hash),源碼如下:
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // table為空或者table的長度小於64, 進行擴容 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); // 根據hash值計算索引值, 遍歷該索引位置的鏈表 else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); // 鏈表節點轉紅黑樹節點 if (tl == null) // tl為空代表為第一次循環 hd = p; // 頭結點 else { p.prev = tl; // 當前節點的prev屬性設為上一個節點 tl.next = p; // 上一個節點的next屬性設置為當前節點 } tl = p; // tl賦值為p, 在下一次循環中作為上一個節點 } while ((e = e.next) != null); // e指向下一個節點 // 將table該索引位置賦值為新轉的TreeNode的頭節點 if ((tab[index] = hd) != null) hd.treeify(tab); // 以頭結點為根結點, 構建紅黑樹 }
1.校驗table是否為空,如果長度小於64,則調用resize方法(見下文resize方法)進行擴容。
2.根據hash值計算索引值,將該索引位置的節點賦值給e節點,從e節點開始遍歷該索引位置的鏈表。
3.調用replacementTreeNode方法(該方法就一行代碼,直接返回一個新建的TreeNode)將鏈表節點轉為紅黑樹節點,將頭結點賦值給hd節點,每次遍歷結束將p節點賦值給tl,用於在下一次循環中作為上一個節點進行一些鏈表的關聯操作(p.prev = tl 和 tl.next = p)。
4.將table該索引位置賦值為新轉的TreeNode的頭節點hd,如果該節點不為空,則以hd為根結點,調用treeify方法(見下文代碼塊7)構建紅黑樹。
接着看如何構建紅黑樹treeify(Node<K,V>[] tab),源碼如下:
final void treeify(Node<K,V>[] tab) { // 構建紅黑樹 TreeNode<K,V> root = null; for (TreeNode<K,V> x = this, next; x != null; x = next) {// this即為調用此方法的TreeNode next = (TreeNode<K,V>)x.next; // next賦值為x的下個節點 x.left = x.right = null; // 將x的左右節點設置為空 if (root == null) { // 如果還沒有根結點, 則將x設置為根結點 x.parent = null; // 根結點沒有父節點 x.red = false; // 根結點必須為黑色 root = x; // 將x設置為根結點 } else { K k = x.key; // k賦值為x的key int h = x.hash; // h賦值為x的hash值 Class<?> kc = null; // 如果當前節點x不是根結點, 則從根節點開始查找屬於該節點的位置 for (TreeNode<K,V> p = root;;) { int dir, ph; K pk = p.key; if ((ph = p.hash) > h) // 如果x節點的hash值小於p節點的hash值 dir = -1; // 則將dir賦值為-1, 代表向p的左邊查找 else if (ph < h) // 與上面相反, 如果x節點的hash值大於p節點的hash值 dir = 1; // 則將dir賦值為1, 代表向p的右邊查找 // 走到這代表x的hash值和p的hash值相等,則比較key值 else if ((kc == null && // 如果k沒有實現Comparable接口 或者 x節點的key和p節點的key相等 (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) // 使用定義的一套規則來比較x節點和p節點的大小,用來決定向左還是向右查找 dir = tieBreakOrder(k, pk); TreeNode<K,V> xp = p; // xp賦值為x的父節點,中間變量用於下面給x的父節點賦值 // dir<=0則向p左邊查找,否則向p右邊查找,如果為null,則代表該位置即為x的目標位置 if ((p = (dir <= 0) ? p.left : p.right) == null) { x.parent = xp; // x的父節點即為最后一次遍歷的p節點 if (dir <= 0) // 如果時dir <= 0, 則代表x節點為父節點的左節點 xp.left = x; else // 如果時dir > 0, 則代表x節點為父節點的右節點 xp.right = x; // 進行紅黑樹的插入平衡(通過左旋、右旋和改變節點顏色來保證當前樹符合紅黑樹的要求) root = balanceInsertion(root, x); break; } } } } moveRootToFront(tab, root); // 如果root節點不在table索引位置的頭結點, 則將其調整為頭結點 }
1.從調用此方法的節點作為起點,開始進行遍歷,並將此節點設為root節點,標記為黑色(x.red = false)。
2.如果當前節點不是根結點,則從根節點開始查找屬於該節點的位置(該段代碼跟之前的代碼塊2和代碼塊4的查找代碼類似)。
3.如果x節點(將要插入紅黑樹的節點)的hash值小於p節點(當前遍歷到的紅黑樹節點)的hash值,則向p節點的左邊查找。
4.與3相反,如果x節點的hash值大於p節點的hash值,則向p節點的右邊查找。
5.如果x的key沒有實現Comparable接口,或者x節點的key和p節點的key相等,使用tieBreakOrder方法(見上文代碼塊5)來比較x節點和p節點的大小,以決定向左還是向右查找(dir <= 0向左,否則向右)。
6.如果dir <= 0則向左節點查找(p賦值為p.left,並進行下一次循環),否則向右節點查找,如果已經無法繼續查找(p賦值后為null),則代表該位置即為x的目標位置,另外變量xp用來記錄最后一個節點,即為下文新增的x節點的父節點。
7.將x的父節點設置為xp,根據dir的值決定x決定放在xp節點的左節點還是右節點,最后進行紅黑樹的插入平衡調整。
8.調用moveRootToFront方法(如下:)將root節點調整到索引位置的頭結點。
/** * 如果當前索引位置的頭節點不是root節點, 則將root的上一個節點和下一個節點進行關聯, * 將root放到頭節點的位置, 原頭節點放在root的next節點上 */ static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) { int n; if (root != null && tab != null && (n = tab.length) > 0) { int index = (n - 1) & root.hash; TreeNode<K,V> first = (TreeNode<K,V>)tab[index]; if (root != first) { // 如果root節點不是該索引位置的頭節點 Node<K,V> rn; tab[index] = root; // 將該索引位置的頭節點賦值為root節點 TreeNode<K,V> rp = root.prev; // root節點的上一個節點 // 如果root節點的下一個節點不為空, // 則將root節點的下一個節點的prev屬性設置為root節點的上一個節點 if ((rn = root.next) != null) ((TreeNode<K,V>)rn).prev = rp; // 如果root節點的上一個節點不為空, // 則將root節點的上一個節點的next屬性設置為root節點的下一個節點 if (rp != null) rp.next = rn; if (first != null) // 如果原頭節點不為空, 則將原頭節點的prev屬性設置為root節點 first.prev = root; root.next = first; // 將root節點的next屬性設置為原頭節點 root.prev = null; } assert checkInvariants(root); // 檢查樹是否正常 }
1.校驗root是否為空、table是否為空、table的length是否大於0。
2.根據root節點的hash值計算出索引位置,判斷該索引位置的頭節點是否為root節點,如果不是則進行以下操作將該索引位置的頭結點替換為root節點。
3.將該索引位置的頭結點賦值為root節點,如果root節點的next節點不為空,則將root節點的next節點的prev屬性設置為root節點的prev節點。
4.如果root節點的prev節點不為空,則將root節點的prev節點的next屬性設置為root節點的next節點(3和4兩個操作是一個完整的鏈表移除某個節點過程)。
5.如果原頭節點不為空,則將原頭節點的prev屬性設置為root節點
6.將root節點的next屬性設置為原頭節點(5和6兩個操作將first節點接到root節點后面)
7.root此時已經被放到該位置的頭結點位置,因此將prev屬性設為空。
8.調用checkInvariants方法 檢查樹是否正常。
接着我們在看看JDK1.8中的get方法,源碼如下:
public V get(Object key) { Node<k,v> e;
//(1) return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //(2) if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // (3) if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; //(4) 桶中不止一個結點 if ((e = first.next) != null) { //(5) 為紅黑樹結點 if (first instanceof TreeNode) // 在紅黑樹中查找 return ((TreeNode<K,V>)first).getTreeNode(hash, key); //(6) 否則,在鏈表中查找 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
get 方法看起來就要簡單許多了。
代碼(1)首先將 key hash 之后取得所定位的桶。
代碼(2)如果桶為空則直接返回 null 。
代碼(3)否則判斷桶的第一個位置(有可能是鏈表、紅黑樹)的 key 是否為查詢的 key,是就直接返回 value。
代碼(4)如果第一個不匹配,則判斷它的下一個是紅黑樹還是鏈表。
代碼(5)紅黑樹就按照樹的查找方式返回值。
代碼(6)不然就按照鏈表的方式遍歷匹配返回值。
最后我們看看JDK1.8中的resize()擴容方法,源碼如下:
①.在jdk1.8中,resize方法是在hashmap中的鍵值對大於閥值時或者初始化時,就調用resize方法進行擴容;
②.每次擴展的時候,都是擴展2倍;
③.擴展后Node對象的位置要么在原位置,要么移動到原偏移量兩倍的位置。
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table;//oldTab指向hash桶數組 int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) {//如果oldCap不為空的話,就是hash桶數組不為空 if (oldCap >= MAXIMUM_CAPACITY) {//如果大於最大容量了,就賦值為整數最大的閥值 threshold = Integer.MAX_VALUE; return oldTab;//返回 }//如果當前hash桶數組的長度在擴容后仍然小於最大容量 並且oldCap大於默認值16 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold 雙倍擴容閥值threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶數組 table = newTab;//將新數組的值復制給舊的hash桶數組 if (oldTab != null) {//進行擴容操作,復制Node對象值到新的hash桶數組,注意這個地方,這里並發可能會造成數據丟失 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) {//如果舊的hash桶數組在j結點處不為空,復制給e oldTab[j] = null;//將舊的hash桶數組在j結點處設置為空,方便gc if (e.next == null)//如果e后面沒有Node結點 newTab[e.hash & (newCap - 1)] = e;//直接對e的hash值對新的數組長度求模獲得存儲位置 else if (e instanceof TreeNode)//如果e是紅黑樹的類型,那么添加到紅黑樹中 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next;//將Node結點的next賦值給next if ((e.hash & oldCap) == 0) {//如果結點e的hash值與原hash桶數組的長度作與運算為0 if (loTail == null)//如果loTail為null loHead = e;//將e結點賦值給loHead else loTail.next = e;//否則將e賦值給loTail.next loTail = e;//然后將e復制給loTail } else {//如果結點e的hash值與原hash桶數組的長度作與運算不為0 if (hiTail == null)//如果hiTail為null hiHead = e;//將e賦值給hiHead else hiTail.next = e;//如果hiTail不為空,將e復制給hiTail.next hiTail = e;//將e復制個hiTail } } while ((e = next) != null);//直到e為空 if (loTail != null) {//如果loTail不為空 loTail.next = null;//將loTail.next設置為空 newTab[j] = loHead;//將loHead賦值給新的hash桶數組[j]處 } if (hiTail != null) {//如果hiTail不為空 hiTail.next = null;//將hiTail.next賦值為空 newTab[j + oldCap] = hiHead;//將hiHead賦值給新的hash桶數組[j+舊hash桶數組長度] } } } } } return newTab; }
從這兩個核心方法(get/put)可以看出 1.8 中對大鏈表做了優化,修改為紅黑樹之后查詢效率直接提高到了 O(logn)。
但是 HashMap 原有的問題也都存在,比如在並發場景下使用時容易出現死循環(JDK1.8中不會出現死循環了,但是並發可能會出現數據丟失,主要因為擴容的時候復制Node對象值到新的hash桶數組
會可能出現數據丟失)。如下:
我們再回頭看一下我們的 transfer代碼中的這個細節:
while(null != e) { Entry<K,V> next = e.next;// <--假設線程一執行到這里就被調度掛起了 if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; }
線程一剛執行上面第一行代碼被調度掛起,線程二執行完成了。Jdk 1.8以前,導致死循環的主要原因是擴容后,節點的順序會反掉,如下圖:擴容前節點A在節點C前面,而擴容后節點C在節點A前面。於是有如下的圖:

因為Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash后,指向了線程二重組后的鏈表。我們可以看到鏈表的順序被反轉后。
接着線程一調度回來繼續執行。
1.先是執行 newTalbe[i] = e;
2.然后是e = next,導致了e指向了key(7),
3.而下一次循環的next = e.next導致了next指向了key(3)

線程一接着工作。把key(7)摘下來,放到newTable[i]的第一個,然后把e和next往下移。

環形鏈接出現。
e.next = newTable[i] 導致 key(3).next 指向了 key(7)
注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。

