第十三章 HashMap&HashSet原始碼解析
HashMap原始碼解析
5.1、對於HashMap需要掌握以下幾點
- Map的建立:HashMap()
- 往Map中新增鍵值對:即put(Object key, Object value)方法
- 獲取Map中的單個物件:即get(Object key)方法
- 刪除Map中的物件:即remove(Object key)方法
- 判斷物件是否存在於Map中:containsKey(Object key)
- 遍歷Map中的物件:即keySet(),在實際中更常用的是增強型的for迴圈去做遍歷
- Map中物件的排序:主要取決於所採取的排序演算法
5.2、構建HashMap
原始碼:
一些屬性:
static final int DEFAULT_INITIAL_CAPACITY = 16; // 預設的初始化容量(必須是2的多少次方) static final int MAXIMUM_CAPACITY = 1 << 30; // 最大指定容量為2的30次方 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 預設的載入因子(用於resize) transient Entry[] table;// Entry陣列(陣列容量必須是2的多少次方,若有必要會擴容resize)--這就是HashMap的底層資料結構 transient int size; // 該map中存放的key-value對個數,該個數決定了陣列的擴容(而非table中的所佔用的桶的個數來決定是否擴容) // 擴容resize的條件:eg.capacity=16,load_factor=0.75,threshold=capacity*load_factor=12,即當該map中存放的key-value對個數size>=12時,就resize) int threshold; final float loadFactor; // 負載因子(用於resize) transient volatile int modCount;// 標誌位,用於標識併發問題,主要用於迭代的快速失敗(在迭代過程中,如果發生了put(新增而不是更新的時候)、remove操作,該值發生變化,快速失敗)
注意:
- map中存放的key-value對個數size,該個數決定了陣列的擴容(size>=threshold時,擴容),而非table中的所佔用的桶的個數來決定是否擴容
- 標誌位modCount採用volatile實現該變數的執行緒可見性(之後會在"Java併發"章節中去講)
- 陣列中的桶,指的就是table[i]
- threshold預設為0.75,這是綜合時間和空間的利用率來考慮的,通常不要變,如果該值過大,可能會造成連結串列太長,導致get、put等操作緩慢;如果太小,空間利用率不足。
無參構造器(也是當下最常用的構造器)
/** * 初始化一個負載因子、resize條件和Entry陣列 */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR;// 負載因子:0.75 threshold = (int) (DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);//當該map中存放的key-value對個數size>=12時,就resize table = new Entry[DEFAULT_INITIAL_CAPACITY];// 設定Entry陣列容量為16 init(); }
注意:
- init()為空方法
對於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))//loadFactor<0或者不是一個值 throw new IllegalArgumentException("Illegal load factor:"+loadFactor); /* * 下邊的邏輯是找一個2的幾次方的數,該數剛剛大於initialCapacity * eg.當指定initialCapacity為17,capacity就是32(2的五次方),而2的四次方(16)正好小於17 */ int capacity = 1; while (capacity < initialCapacity) capacity <<= 1;// capacity = capacity<<1 this.loadFactor = loadFactor; threshold = (int)(capacity * loadFactor); table = new Entry[capacity]; init(); } /** * 指定初始容量 */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR);//呼叫上邊的雙參構造器 }
注意:
- 利用上述兩個構造器構造出的陣列容量不一定是指定的初始化容量,而是一個剛剛大於指定初始化容量的2的幾次方的一個值。
- 在實際使用中,若我們能預判所要儲存的元素的多少,最好使用上述的單參構造器來指定初始容量,這樣的話,就可以避免就來擴容時帶來的消耗(這一點與ArrayList一樣)
HashMap的底層資料結構是一個Entry[],Entry是HashMap的一個內部類,原始碼如下:
static class Entry<K, V> implements Map.Entry<K, V> { final K key; // 該Entry的key V value; // 該Entry的value Entry<K, V> next; // 該Entry的下一個Entry(hash衝突時,形成連結串列) final int hash; // 該Entry的hash值 /** * Creates new entry. */ 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; } //為Entry設定新的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(); //在hashmap中可以存放null鍵和null值 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(); } }
注:這裡我去掉了兩個空方法。
- Entry是一個節點,在其中還儲存了下一個Entry的引用(用來解決put時的hash衝突問題),這樣的話,我們可以把hashmap看作是"一個連結串列陣列"
- Entry類中的equals()方法會在get(Object key)中使用
5.3、put(Object key, Object value)
原始碼:
put(Object key, Object value)
/** * 向map中新增新Entry * 步驟: * 1)HashMap可以新增null的key,key==null的Entry只會放在table[0]中,但是table[0]不僅僅可以存放key==null的Entry * 1.1、遍歷table[0]中的Entry鏈,若有key==null的值就用新值覆蓋舊值,並返回舊值value, * 1.2、若無,執行addEntry方法,用新的Entry替換掉原來舊的Entry賦值給table[0],而舊的Entry作為新的Entry的next,執行結束後,返回null * 2)新增key!=null的Entry時, * 2.1、先計算key.hashCode()的hash值, * 2.2、然後計算出將要放入的table的下標i, * 2.3、之後遍歷table[i]中的Entry鏈,若有相同key的值就用新值覆蓋舊值,並返回舊值value, * 2.4、若無,執行addEntry方法,用新的Entry替換掉原來舊的Entry賦值給table[i],而舊的Entry作為新的Entry的next,執行結束後,返回null */ public V put(K key, V value) { /******************key==null******************/ if (key == null) return putForNullKey(value); //將空key的Entry加入到table[0]中 /******************key!=null******************/ int hash = hash(key.hashCode()); //計算key.hashcode()的hash值,hash函式由hashmap自己實現 int i = indexFor(hash, table.length);//獲取將要存放的陣列下標 /* * for中的程式碼用於:當hash值相同且key相同的情況下,使用新值覆蓋舊值(其實就是修改功能) */ for (Entry<K, V> e = table[i]; e != null; e = e.next) {//注意:for迴圈在第一次執行時就會先判斷條件 Object k; //hash值相同且key相同的情況下,使用新值覆蓋舊值 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; //e.recordAccess(this); return oldValue;//返回舊值 } } modCount++; addEntry(hash, key, value, i);//增加一個新的Entry到table[i] return null;//如果沒有與傳入的key相等的Entry,就返回null }
注意:該方法頭部的註釋寫明瞭整個put(Object key, Object value)的流程,非常重要
putForNullKey(V value)
/** * 增加null的key到table[0] */ private V putForNullKey(V value) { //遍歷第一個陣列元素table[0]中的所有Entry節點 for (Entry<K, V> 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++; addEntry(0, null, value, 0);//將新節點Entry加入到Entry[]中 return null; }
addEntry(int hash, K key, V value, int bucketIndex)
/** * 新增新的Entry到table[bucketIndex] */ void addEntry(int hash, K key, V value, int bucketIndex) { /* * 這裡可以看出, * 1)新加入的Entry會放入鏈頭,也就是說將來遍歷的時候,最先加入map的反而是最後被遍歷到的 * 2)採用的是Entry替換的方式 * 2.1、當新增第一個Entry1時,table[bucketIndex]==null,也就是說Entry1的下一個Entry為null(鏈尾),之後把table[bucketIndex] = Entry1 * 2.2、當新增第二個Entry2時,table[bucketIndex]==Entry1,也就是說Entry2的下一個Entry為Entry1,之後把table[bucketIndex] = Entry2 * 2.3、當新增第三個Entry3時,table[bucketIndex]==Entry2,也就是說Entry3的下一個Entry為Entry2,之後把table[bucketIndex] = Entry3 */ Entry<K, V> e = table[bucketIndex];//新節點的下一個節點(當第一次在相應的陣列位置放置元素時,table[bucketIndex]==null) table[bucketIndex] = new Entry<K, V>(hash, key, value, e); if (size++ >= threshold)//key-value對個數大於等於threshold resize(2 * table.length);//擴容 }
注意:該方法頭部的註釋寫明瞭該方法的流程示例,可以自己畫個圖對比著理解
hash(int h)
/** * hash函式,用於計算key.hashCode()的hash值 * Note: null的key的hash為0,放在table[0]. */ static int hash(int h) { //這樣的hash函式應該可以儘量將hash值打散 h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
注意:在我們實際使用hashmap時,最好的情況是將key的hash值打散,使插入的這些Entry儘量落在不同的桶上(這樣做的主要目的是提高查詢效率),以上這個hash函式應該就是實現了這樣的功能,但是為什麼這樣的hash函式可以將hash值打散,求大神指點!!!
indexFor(int h, int length)
/** * "按位與"來獲取陣列下標 */ static int indexFor(int h, int length) { return h & (length - 1); }
注意:hashmap始終將自己的桶保持在2的n次方,這是為什麼?indexFor這個方法解釋了這個問題。“這個方法非常巧妙,它通過h & (table.length -1)來得到該物件的儲存位,而HashMap底層陣列的長度總是2的n次方,這是HashMap在速度上的優化。當length總是2的n次方時,h& (length-1)運算等價於對length取模,也就是h%length,但是&比%具有更高的效率”--http://tech.meituan.com/java-hashmap.html
說明:在上述的addEntry(int hash, K key, V value, int bucketIndex)方法中,我們可以看到,當為把新的Entry賦值給table[i]後,會判斷map中的key-value對是不是已經大於等於擴容條件值threshold了,若是,則需要呼叫resize函式,對Entry陣列進行擴容,擴為原來二倍。
resize(int newCapacity)
/** * 擴容步驟: * 1)陣列擴容為原來容量(eg.16)的二倍 * 2)將舊陣列中的所有Entry重新計算索引,加入新陣列 * 3)將新陣列的引用賦給舊陣列 * 4)重新計算擴容臨界值threshold */ void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; //如果舊的陣列的容量為2的30次方(這種情況,不考慮了,如果真達到這樣的情況,效能下降的就不像話了) if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity];//newCapacity==2*oldCapacity transfer(newTable);//將舊陣列中的所有Entry重新計算索引,加入新陣列 table = newTable;//將新陣列賦給就陣列 threshold = (int) (newCapacity * loadFactor);//重新計算threshold }
transfer(Entry[] newTable)
jdk中的實現:
/** * 將所有舊的陣列中的所有Entry移動到新陣列中去 */ void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) {//遍歷舊陣列 Entry<K, V> e = src[j];//獲得頭節點 if (e != null) { /* * 這樣寫,若同時有其他執行緒還在訪問這個元素,則訪問不到了,這裡這樣寫,是考慮到多執行緒情況下,我們一般不會會用HashMap * (檢視ConcurrentHashMap並未將舊陣列的值置為null) * 這裡將其置為null就方便gc回收 * 當然為了減小以上所說的影響,建議將src[j] = null;放在while迴圈結束後 */ src[j] = null; do { Entry<K, V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i];//把之前已經存在的newTable[i]的元素賦給當前節點的下一個節點 newTable[i] = e;//把當前節點賦給newTable[i] e = next; } while (e != null);//遍歷連結串列 } } }
我的修改:(注意:這是一個錯誤的修改,錯誤的根源在下邊我會給出)
/** * 將所有舊的陣列中的所有Entry移動到新陣列中去 */ void transfer(Entry[] newTable) { Entry[] src = table; //舊陣列 int newCapacity = newTable.length; //新陣列容量 for (int j = 0; j < src.length; j++) { Entry<K, V> e = src[j];//獲取舊陣列中的頭節點Entry if (e != null) { src[j] = null;//將舊陣列置空,讓gc回收注意:這個時候table的桶並沒有置空 /* * 根據舊的hash值與新的容量值進行重新定位(注意:並沒有重新計算hash值) * 1、那麼假設之前table[1]中存放的是Entry3,Entry3.next是Entry2,Entry2.next是Entry1,Entry1.next是null * 那麼假設重新計算後的i=3,那麼Entry3-->Entry2-->Entry1依舊會在一起,都放入newTable[3],這樣的話,我們只需要將鏈頭的Entry3賦值給newTable[3]即可 * 2、既然通過indexFor(e.hash, newCapacity)不能把同一個桶下的Entry打散,為什麼還要用呢? * 主要是擴容後,若不用newCapacity去計算下標的話,那麼擴容後,map中的Entry就都集中在了新陣列的前半部分,這樣就不夠散了 */ int i = indexFor(e.hash, newCapacity); newTable[i] = e;//將Entry3賦值給newTable[3] } } }
注意:
- 在這個方法中,並沒有重新計算hash值,只是重新計算了下標索引。
-
錯誤根源在於認為同一個桶下的所有Entry的hash值相同,事實上不相同,只是hash&(table.length-1)的結果相同,
所以當table.length發生變化時,同一個桶下各個Entry算出來的index會不同(即Entry3、Entry2、Entry1可能會落在新陣列的不同的桶上)
5.4、get(Object key)
原始碼:
get(Object key)
/** * 查詢指定key的value值 * 1、若key==null * 遍歷table[0],找出key==null的value,若沒找到,返回null * 2、若key!=null * 1)計算key.hashCode()的hash值 * 2)根據計算出的hash值和陣列容量,呼叫indexFor方法,獲得table的下標i,進而獲得桶table[i] * 3)遍歷該桶中的每一個Entry,找出key相等(==或equals)的Entry,獲取此Entry的value即可 * 4)最後,若沒有找到,返回null即可 */ public V get(Object key) { /****************查詢key==null的value****************/ if (key == null) return getForNullKey(); /****************查詢key!=null的value****************/ int hash = hash(key.hashCode());//獲取key.hashCode()的hash值 for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null;//若沒有指定key的Entry,則直接返回null }
注意:檢視程式碼頭部的註釋,表明了get的整個步驟
getForNullKey()
/** * 在table[0]中查詢key==null */ private V getForNullKey() { for (Entry<K, V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null;//找不到的話就返回null }
5.5、remove(Object key)
原始碼:
/** * 刪除指定key的Entry */ public V remove(Object key) { Entry<K, V> e = removeEntryForKey(key); return (e == null ? null : e.value);//返回刪除的節點(e為null的話,表示所給出的key不存在) } /** * 刪除指定key的Entry * 1)若刪除的是頭節點,例如Entry3,只需將Entry2賦值給table[i]即可 * 2)若刪除的是中間節點,例如Entry2,只需將Entry3.next指向Entry2.next(即Entry1)即可 * 3)若刪除的是尾節點,例如Entry1,只需將Entry2.next指向Entry1.next(即null)即可 */ final Entry<K, V> removeEntryForKey(Object key) { int hash = (key == null) ? 0 : hash(key.hashCode());//計算hash值 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--;//size-1 if (prev == e)//刪除頭節點,即示例中的Entry3 table[i] = next; else//刪除除了頭節點外的其他節點 prev.next = next; //e.recordRemoval(this); return e; } prev = e; e = next; } return e;//返回刪除的節點(e為null的話,表示所給出的key不存在) }
注:看註釋即可,最好用示例去套一下程式碼。
- 若刪除的key不存在於map中,返回null,不會拋異常。
5.6、containsKey(Object key)
原始碼:
/** * 判斷map是否包含指定可以的Entry */ public boolean containsKey(Object key) { return getEntry(key) != null; } /** * 判斷map是否包含指定可以的Entry,與get(Object key)基本相同(只是這裡將key==null與key!=null的情況寫在了一起,get(Object key)也可以這樣去做) * 1)計算key.hashCode()的hash值 * 2)根據計算出的hash值和陣列容量,呼叫indexFor方法,獲得table的下標i,進而獲得桶table[i] * 3)遍歷該桶中的每一個Entry,找出key相等(==或equals)的Entry,獲取此Entry,並返回此Entry * 4)最後,若沒有找到,返回null即可 */ final Entry<K, V> getEntry(Object key) { int hash = (key == null) ? 0 : hash(key.hashCode());//計算hash值 for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
注意:此方法與get(Object key)基本相同,只是只是這裡將key==null與key!=null的情況寫在了一起,get(Object key)也可以這樣去做來減少程式碼
5.7、keySet()
遍歷所有Entry連結串列,獲取每一個Entry的key,在整個過程中,如果發生了增刪操作,丟擲ConcurrentModificationException。
final Entry<K, V> nextEntry() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); Entry<K, V> e = next; if (e == null) throw new NoSuchElementException(); if ((next = e.next) == null) { Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } current = e; return e; }
總結:
- HashMap底層就是一個Entry陣列,Entry又包含next,事實上,可以看成是一個"連結串列陣列"
- 擴容:map中存放的key-value對個數size,該個數決定了陣列的擴容(size>=threshold時,擴容),而非table中的所佔用的桶的個數來決定是否擴容
- 擴容過程,不會重新計算hash值,只會重新按位與
- 在實際使用中,若我們能預判所要儲存的元素的多少,最好使用上述的單參構造器來指定初始容量
- HashMap可以插入null的key和value
- remove(Object key):若刪除的key不存在於map中,返回null,不會拋異常。
- HashMap執行緒不安全,若想要執行緒安全,最好使用ConcurrentHashMap
疑問:
在我們實際使用hashmap時,最好的情況是將key的hash值打散,使插入的這些Entry儘量落在不同的桶上(這樣做的主要目的是提高查詢效率),以下這個hash函式應該就是實現了這樣的功能,但是為什麼這樣的hash函式可以將hash值打散,求大神指點!!!
static int hash(int h) { //這樣的hash函式應該可以儘量將hash值打散 h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
jdk1.8對hashmap進行了改造,1.7中的hashmap最大的問題就是當連結串列比較長時,查詢效率急劇下降;所以在1.8中,當連結串列長度>=8是,連結串列轉為紅黑樹,提高查詢效率。
1 JDK8 中的 HashMap與 JDK7 的 HashMap 有什麼不一樣?
JDK8中新增了紅黑樹,JDK8是通過陣列+連結串列+紅黑樹來實現的
JDK7中連結串列的插入是用的頭插法,而JDK8中則改為了尾插法
JDK8中的因為使用了紅黑樹保證了插入和查詢了效率,所以實際上JDK8中的Hash演算法實現的複雜度降低了
JDK8中陣列擴容的條件也發了變化,只會判斷是否當前元素個數是否查過了閾值,而不再判斷當前put進來的元素對應的陣列下標位置是否有值
JDK7中是先擴容再新增新元素,JDK8中是先新增新元素然後再擴容
2 HashMap 中 put 方法流程
在IDEA中我們找到HashMap原始碼中的put方法原始碼:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put方法呼叫了putVal方法,繼續找到putVal方法的原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
/**
* Implements Map.put and related methods
* @param hash key的雜湊值
* @param key key值
* @param value 需要Put進去的value
* @param onlyIfAbsent 如果為true,不改變已存在的值
* @param evict 如果為false,則table 為creation 模式.
* @return 返回之前的值, 不存在則返回nullh
*/
final V putVal( int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//先判斷table是否為空或者長度為零
if ((tab = table) == null || (n = tab.length) == 0 )
//呼叫resize方法初始化陣列
n = (tab = resize()).length;
//計算陣列下標i=(tab.length - 1) & hash
if ((p = tab[i = (n - 1 ) & hash]) == null )
//如果陣列下標處元素為空,則將hash, key, value等值封裝成連結串列後儲存在此陣列下標處
tab[i] = newNode(hash, key, value, null );
else {
//陣列下標處元素不為空
Node<K,V> e; K k;
//若p = tab[i = (n - 1) & hash]的hash值等於入參hash同時其key值等於入參key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//將tab[i = (n - 1) & hash]賦值給區域性變數e
e = p;
//若陣列下標處的元素是一棵紅黑樹
elseif (p instanceof TreeNode)
//呼叫TreeNode#putTreeVal方法並將其返回值賦值給e,putTreeVal方法後面再講
e = ((TreeNode<K,V>)p).putTreeVal( this , tab, hash, key, value);
else { //陣列下標元素不同時滿足其hash值等於入參hash和其key值等於入參key,
//同時它也不是紅黑樹節點,此時它仍然是Node型別的節點
for ( int binCount = 0 ; ; ++binCount) {
if ((e = p.next) == null ) { //若p的下一個節點為空,並把p.next賦值給e
//封裝hash, key, value為一個新的Node節點賦值給p.next
p.next = newNode(hash, key, value, null );
//若binCount大於等於TREEIFY_THRESHOLD - 1
if (binCount >= TREEIFY_THRESHOLD - 1 ) // -1 for 1st
//呼叫treeifyBin方法後跳出迴圈
treeifyBin(tab, hash);
break ;
}
//若e的hash值等於入參hash,同時e的key值等於入參key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//跳出迴圈
break ;
//以上兩種情況都沒出現時將e賦值給p
p = e;
}
}
if (e != null ) { // 若對應的key值在HashMap中存在對映值
//將e的value值賦值給oldValue
V oldValue = e.value;
//若onlyIfAbsent==false或者oldValue == null
if (!onlyIfAbsent || oldValue == null )
//將新值value賦值給e.value
e.value = value;
//呼叫afterNodeAcces方法
afterNodeAccess(e);
//返回舊值
return oldValue;
}
}
//對應的key值在HashMap中不存在對映值,屬於新增一個元素,modCount值加1
++modCount;
if (++size > threshold)
//若size+1後大於閾值threshold,則呼叫resize方法擴容
resize();
//呼叫afterNodeInsertion方法
afterNodeInsertion(evict);
//返回null
return null ;
}
|
TreeNode#putTreeVal方法原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
/**
* Tree version of putVal.
*/
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的父節點不為null則呼叫root()方法獲得node節點,
//否則當前TreeNode即為root節點
TreeNode<K,V> root = (parent != null ) ? root() : this ;
//從紅黑樹的root節點開始遍歷
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
//若p節點的hash值大於入參hash值h則dir賦值為-1
if ((ph = p.hash) > h)
dir = - 1 ;
//若p節點的hash值小於入參hash值h則dir賦值為1
elseif (ph < h)
dir = 1 ;
//p.key賦值給pk
elseif ((pk = p.key) == k || (k != null && k.equals(pk)))
//若滿足(pk = p.key) == k或者k != null && k.equals(pk)
//則返回p節點
return p;
//若滿足kc為null同時呼叫comparableClassFor(k)方法的返回值賦給kc後的值也為null
//或者呼叫compareComparables(kc, k, pk)方法後的返回值賦值給dir後等於0
elseif ((kc == null &&
(kc = comparableClassFor(k)) == null ) ||
(dir = compareComparables(kc, k, pk)) == 0 ) {
if (!searched) {
//若searched==false
TreeNode<K,V> q, ch;
searched = true ;
//p.left或p.right賦值給ch後不為null,同時滿足呼叫ch.find(h, k, kc)方法後的
//返回值賦值給q後也不為null則返回q節點
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;
}
//呼叫tieBreakOrder(k, pk)方法的返回值賦值給dir
dir = tieBreakOrder(k, pk);
}
//定義紅黑樹節點xp,並將p賦值給xp
TreeNode<K,V> xp = p;
//若dir <= 0則將p.left賦值給p,否則將p.right賦值給p,以進行下一次遍歷
if ((p = (dir <= 0 ) ? p.left : p.right) == null ) {
//p節點為null走以下邏輯
//定義xpn節點,並將xp.next節點賦值給xpn
Node<K,V> xpn = xp.next;
//定義x節點為呼叫map.newTreeNode(h, k, v, xpn)方法後的返回值
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
//若dir<=0 則將x節點賦值給xp的左孩子節點,否則將x節點賦值給xp的右孩子節點
if (dir <= 0 )
xp.left = x;
else
xp.right = x;
//將x節點賦值給xp的next節點
xp.next = x;
//將xp節點同時賦值給x的prev節點和parent節點
x.parent = x.prev = xp;
if (xpn != null )
//若xpn節點不為null則將x節點賦值給xpn的prev節點
((TreeNode<K,V>)xpn).prev = x;
//確保root節點是根節點,裡面呼叫了插入節點後的平衡紅黑樹方法balanceInsertion(root, x)
moveRootToFront(tab, balanceInsertion(root, x));
//返回null
return null ;
}
}
}
|
以上涉及到紅黑樹中的複雜演算法待自己搞明白了紅黑樹資料結構再另外撰寫一篇文章釋出
綜上,JDK8中HashMap的put操作流程如下:
1) 對Key求Hash值,然後再計算下標:
2)如果沒有碰撞,直接放入桶中(碰撞的意思是計算得到的Hash值相同,需要放到同一個bucket中)
3)如果碰撞了,以連結串列的方式連結到後面
4)如果連結串列長度超過閥值( TREEIFY_THRESHOLD==8),就把連結串列轉成紅黑樹,連結串列長度低於6,就把紅黑樹轉回連結串列
5)如果節點已經存在就替換舊值
6)如果桶滿了(容量16*載入因子0.75),就需要 resize(擴容2倍後重排)
3 HashMap 的 get 方法流程
當我們呼叫get()方法,HashMap會使用鍵物件的hashcode找到bucket位置,找到bucket位置之後,會呼叫keys.equals()方法去找到連結串列中正確的節點,最終找到要找的值物件。
4 HashMap 擴容流程是怎樣的?
1)HashMap的擴容指的就是陣列的擴容, 因為陣列佔用的是連續記憶體空間,所以陣列的擴容其實只能新開一個新的陣列,然後把老陣列上的元素轉移到新陣列上來,這樣才是陣列的擴容
2)在HashMap中也是一樣,先新建一個2被陣列大小的陣列
3)然後遍歷老陣列上的每一個位置,如果這個位置上是一個連結串列,就把這個連結串列上的元素轉移到新陣列上去
4)在這個過程中就需要遍歷連結串列,當然jdk7,和jdk8在實現時是不一樣的,jdk7就是簡單的遍歷連結串列上的每一個元素,然後按每個元素的hashcode結合新陣列的長度重新計算得出一個下標,而重新得到的這個陣列下標很可能和之前的陣列下標是不一樣的,這樣子就達到了一種效果,就是擴容之後,某個連結串列會變短,這也就達到了擴容的目的,縮短連結串列長度,提高了查詢效率
5)而在jdk8中,因為涉及到紅黑樹,這個其實比較複雜,jdk8中其實還會用到一個雙向連結串列來維護紅黑樹中的元素,所以jdk8中在轉移某個位置上的元素時,會去判斷如果這個位置是一個紅黑樹,那麼會遍歷該位置的雙向連結串列,遍歷雙向連結串列統計哪些元素在擴容完之後還是原位置,哪些元素在擴容之後在新位置,這樣遍歷完雙向連結串列後,就會得到兩個子連結串列,一個放在原下標位置,一個放在新下標位置,如果原下標位置或新下標位置沒有元素,則紅黑樹不用拆分,否則判斷這兩個子連結串列的長度,如果超過八,則轉成紅黑樹放到對應的位置,否則把單向連結串列放到對應的位置
6)元素轉移完了之後,在把新陣列物件賦值給HashMap的table屬性,老陣列會被回收到垃圾收集器中
5 談談你對紅黑樹的理解
1)每個節點非紅即黑
2)根節點總是黑色的
3)如果節點是紅色的,則它的子節點必須是黑色的(反之不一定)
4)每個葉子節點都是黑色的空節點(NIL節點)
5)從根節點到葉節點或空子節點的每條路徑,必須包含相同數目的黑色節點(即相同的黑色高度)
為什麼 HashMap 的陣列的大小是2的冪次方數?
當某個key-value對需要儲存到陣列中時,需要先生成一個數組下標index,並且這個index不能越界。
在HashMap中,先得到key的hashcode,hashcode是一個數字,然後通過hashcode & (table.length - 1) 運算得到一個數組下標index,是通過位運算中的與運算計算出來一個數組下標的,而不是通過取餘,與運算比取餘運算速度更快,但是也有一個前提條件,那就是陣列的長度必須是一個2的冪次方數。
5.1、對於HashMap需要掌握以下幾點
- Map的建立:HashMap()
- 往Map中新增鍵值對:即put(Object key, Object value)方法
- 獲取Map中的單個物件:即get(Object key)方法
- 刪除Map中的物件:即remove(Object key)方法
- 判斷物件是否存在於Map中:containsKey(Object key)
- 遍歷Map中的物件:即keySet(),在實際中更常用的是增強型的for迴圈去做遍歷
- Map中物件的排序:主要取決於所採取的排序演算法
5.2、構建HashMap
原始碼:
一些屬性:
static final int DEFAULT_INITIAL_CAPACITY = 16; // 預設的初始化容量(必須是2的多少次方) static final int MAXIMUM_CAPACITY = 1 << 30; // 最大指定容量為2的30次方 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 預設的載入因子(用於resize) transient Entry[] table;// Entry陣列(陣列容量必須是2的多少次方,若有必要會擴容resize)--這就是HashMap的底層資料結構 transient int size; // 該map中存放的key-value對個數,該個數決定了陣列的擴容(而非table中的所佔用的桶的個數來決定是否擴容) // 擴容resize的條件:eg.capacity=16,load_factor=0.75,threshold=capacity*load_factor=12,即當該map中存放的key-value對個數size>=12時,就resize) int threshold; final float loadFactor; // 負載因子(用於resize) transient volatile int modCount;// 標誌位,用於標識併發問題,主要用於迭代的快速失敗(在迭代過程中,如果發生了put(新增而不是更新的時候)、remove操作,該值發生變化,快速失敗)
注意:
- map中存放的key-value對個數size,該個數決定了陣列的擴容(size>=threshold時,擴容),而非table中的所佔用的桶的個數來決定是否擴容
- 標誌位modCount採用volatile實現該變數的執行緒可見性(之後會在"Java併發"章節中去講)
- 陣列中的桶,指的就是table[i]
- threshold預設為0.75,這是綜合時間和空間的利用率來考慮的,通常不要變,如果該值過大,可能會造成連結串列太長,導致get、put等操作緩慢;如果太小,空間利用率不足。
無參構造器(也是當下最常用的構造器)
/** * 初始化一個負載因子、resize條件和Entry陣列 */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR;// 負載因子:0.75 threshold = (int) (DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);//當該map中存放的key-value對個數size>=12時,就resize table = new Entry[DEFAULT_INITIAL_CAPACITY];// 設定Entry陣列容量為16 init(); }
注意:
- init()為空方法
對於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))//loadFactor<0或者不是一個值 throw new IllegalArgumentException("Illegal load factor:"+loadFactor); /* * 下邊的邏輯是找一個2的幾次方的數,該數剛剛大於initialCapacity * eg.當指定initialCapacity為17,capacity就是32(2的五次方),而2的四次方(16)正好小於17 */ int capacity = 1; while (capacity < initialCapacity) capacity <<= 1;// capacity = capacity<<1 this.loadFactor = loadFactor; threshold = (int)(capacity * loadFactor); table = new Entry[capacity]; init(); } /** * 指定初始容量 */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR);//呼叫上邊的雙參構造器 }
注意:
- 利用上述兩個構造器構造出的陣列容量不一定是指定的初始化容量,而是一個剛剛大於指定初始化容量的2的幾次方的一個值。
- 在實際使用中,若我們能預判所要儲存的元素的多少,最好使用上述的單參構造器來指定初始容量,這樣的話,就可以避免就來擴容時帶來的消耗(這一點與ArrayList一樣)
HashMap的底層資料結構是一個Entry[],Entry是HashMap的一個內部類,原始碼如下:
static class Entry<K, V> implements Map.Entry<K, V> { final K key; // 該Entry的key V value; // 該Entry的value Entry<K, V> next; // 該Entry的下一個Entry(hash衝突時,形成連結串列) final int hash; // 該Entry的hash值 /** * Creates new entry. */ 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; } //為Entry設定新的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(); //在hashmap中可以存放null鍵和null值 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(); } }
注:這裡我去掉了兩個空方法。
- Entry是一個節點,在其中還儲存了下一個Entry的引用(用來解決put時的hash衝突問題),這樣的話,我們可以把hashmap看作是"一個連結串列陣列"
- Entry類中的equals()方法會在get(Object key)中使用
5.3、put(Object key, Object value)
原始碼:
put(Object key, Object value)
/** * 向map中新增新Entry * 步驟: * 1)HashMap可以新增null的key,key==null的Entry只會放在table[0]中,但是table[0]不僅僅可以存放key==null的Entry * 1.1、遍歷table[0]中的Entry鏈,若有key==null的值就用新值覆蓋舊值,並返回舊值value, * 1.2、若無,執行addEntry方法,用新的Entry替換掉原來舊的Entry賦值給table[0],而舊的Entry作為新的Entry的next,執行結束後,返回null * 2)新增key!=null的Entry時, * 2.1、先計算key.hashCode()的hash值, * 2.2、然後計算出將要放入的table的下標i, * 2.3、之後遍歷table[i]中的Entry鏈,若有相同key的值就用新值覆蓋舊值,並返回舊值value, * 2.4、若無,執行addEntry方法,用新的Entry替換掉原來舊的Entry賦值給table[i],而舊的Entry作為新的Entry的next,執行結束後,返回null */ public V put(K key, V value) { /******************key==null******************/ if (key == null) return putForNullKey(value); //將空key的Entry加入到table[0]中 /******************key!=null******************/ int hash = hash(key.hashCode()); //計算key.hashcode()的hash值,hash函式由hashmap自己實現 int i = indexFor(hash, table.length);//獲取將要存放的陣列下標 /* * for中的程式碼用於:當hash值相同且key相同的情況下,使用新值覆蓋舊值(其實就是修改功能) */ for (Entry<K, V> e = table[i]; e != null; e = e.next) {//注意:for迴圈在第一次執行時就會先判斷條件 Object k; //hash值相同且key相同的情況下,使用新值覆蓋舊值 if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; //e.recordAccess(this); return oldValue;//返回舊值 } } modCount++; addEntry(hash, key, value, i);//增加一個新的Entry到table[i] return null;//如果沒有與傳入的key相等的Entry,就返回null }
注意:該方法頭部的註釋寫明瞭整個put(Object key, Object value)的流程,非常重要
putForNullKey(V value)
/** * 增加null的key到table[0] */ private V putForNullKey(V value) { //遍歷第一個陣列元素table[0]中的所有Entry節點 for (Entry<K, V> 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++; addEntry(0, null, value, 0);//將新節點Entry加入到Entry[]中 return null; }
addEntry(int hash, K key, V value, int bucketIndex)
/** * 新增新的Entry到table[bucketIndex] */ void addEntry(int hash, K key, V value, int bucketIndex) { /* * 這裡可以看出, * 1)新加入的Entry會放入鏈頭,也就是說將來遍歷的時候,最先加入map的反而是最後被遍歷到的 * 2)採用的是Entry替換的方式 * 2.1、當新增第一個Entry1時,table[bucketIndex]==null,也就是說Entry1的下一個Entry為null(鏈尾),之後把table[bucketIndex] = Entry1 * 2.2、當新增第二個Entry2時,table[bucketIndex]==Entry1,也就是說Entry2的下一個Entry為Entry1,之後把table[bucketIndex] = Entry2 * 2.3、當新增第三個Entry3時,table[bucketIndex]==Entry2,也就是說Entry3的下一個Entry為Entry2,之後把table[bucketIndex] = Entry3 */ Entry<K, V> e = table[bucketIndex];//新節點的下一個節點(當第一次在相應的陣列位置放置元素時,table[bucketIndex]==null) table[bucketIndex] = new Entry<K, V>(hash, key, value, e); if (size++ >= threshold)//key-value對個數大於等於threshold resize(2 * table.length);//擴容 }
注意:該方法頭部的註釋寫明瞭該方法的流程示例,可以自己畫個圖對比著理解
hash(int h)
/** * hash函式,用於計算key.hashCode()的hash值 * Note: null的key的hash為0,放在table[0]. */ static int hash(int h) { //這樣的hash函式應該可以儘量將hash值打散 h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
注意:在我們實際使用hashmap時,最好的情況是將key的hash值打散,使插入的這些Entry儘量落在不同的桶上(這樣做的主要目的是提高查詢效率),以上這個hash函式應該就是實現了這樣的功能,但是為什麼這樣的hash函式可以將hash值打散,求大神指點!!!
indexFor(int h, int length)
/** * "按位與"來獲取陣列下標 */ static int indexFor(int h, int length) { return h & (length - 1); }
注意:hashmap始終將自己的桶保持在2的n次方,這是為什麼?indexFor這個方法解釋了這個問題。“這個方法非常巧妙,它通過h & (table.length -1)來得到該物件的儲存位,而HashMap底層陣列的長度總是2的n次方,這是HashMap在速度上的優化。當length總是2的n次方時,h& (length-1)運算等價於對length取模,也就是h%length,但是&比%具有更高的效率”--http://tech.meituan.com/java-hashmap.html
說明:在上述的addEntry(int hash, K key, V value, int bucketIndex)方法中,我們可以看到,當為把新的Entry賦值給table[i]後,會判斷map中的key-value對是不是已經大於等於擴容條件值threshold了,若是,則需要呼叫resize函式,對Entry陣列進行擴容,擴為原來二倍。
resize(int newCapacity)
/** * 擴容步驟: * 1)陣列擴容為原來容量(eg.16)的二倍 * 2)將舊陣列中的所有Entry重新計算索引,加入新陣列 * 3)將新陣列的引用賦給舊陣列 * 4)重新計算擴容臨界值threshold */ void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; //如果舊的陣列的容量為2的30次方(這種情況,不考慮了,如果真達到這樣的情況,效能下降的就不像話了) if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity];//newCapacity==2*oldCapacity transfer(newTable);//將舊陣列中的所有Entry重新計算索引,加入新陣列 table = newTable;//將新陣列賦給就陣列 threshold = (int) (newCapacity * loadFactor);//重新計算threshold }
transfer(Entry[] newTable)
jdk中的實現:
/** * 將所有舊的陣列中的所有Entry移動到新陣列中去 */ void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) {//遍歷舊陣列 Entry<K, V> e = src[j];//獲得頭節點 if (e != null) { /* * 這樣寫,若同時有其他執行緒還在訪問這個元素,則訪問不到了,這裡這樣寫,是考慮到多執行緒情況下,我們一般不會會用HashMap * (檢視ConcurrentHashMap並未將舊陣列的值置為null) * 這裡將其置為null就方便gc回收 * 當然為了減小以上所說的影響,建議將src[j] = null;放在while迴圈結束後 */ src[j] = null; do { Entry<K, V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i];//把之前已經存在的newTable[i]的元素賦給當前節點的下一個節點 newTable[i] = e;//把當前節點賦給newTable[i] e = next; } while (e != null);//遍歷連結串列 } } }
我的修改:(注意:這是一個錯誤的修改,錯誤的根源在下邊我會給出)
/** * 將所有舊的陣列中的所有Entry移動到新陣列中去 */ void transfer(Entry[] newTable) { Entry[] src = table; //舊陣列 int newCapacity = newTable.length; //新陣列容量 for (int j = 0; j < src.length; j++) { Entry<K, V> e = src[j];//獲取舊陣列中的頭節點Entry if (e != null) { src[j] = null;//將舊陣列置空,讓gc回收注意:這個時候table的桶並沒有置空 /* * 根據舊的hash值與新的容量值進行重新定位(注意:並沒有重新計算hash值) * 1、那麼假設之前table[1]中存放的是Entry3,Entry3.next是Entry2,Entry2.next是Entry1,Entry1.next是null * 那麼假設重新計算後的i=3,那麼Entry3-->Entry2-->Entry1依舊會在一起,都放入newTable[3],這樣的話,我們只需要將鏈頭的Entry3賦值給newTable[3]即可 * 2、既然通過indexFor(e.hash, newCapacity)不能把同一個桶下的Entry打散,為什麼還要用呢? * 主要是擴容後,若不用newCapacity去計算下標的話,那麼擴容後,map中的Entry就都集中在了新陣列的前半部分,這樣就不夠散了 */ int i = indexFor(e.hash, newCapacity); newTable[i] = e;//將Entry3賦值給newTable[3] } } }
注意:
- 在這個方法中,並沒有重新計算hash值,只是重新計算了下標索引。
-
錯誤根源在於認為同一個桶下的所有Entry的hash值相同,事實上不相同,只是hash&(table.length-1)的結果相同,
所以當table.length發生變化時,同一個桶下各個Entry算出來的index會不同(即Entry3、Entry2、Entry1可能會落在新陣列的不同的桶上)
5.4、get(Object key)
原始碼:
get(Object key)
/** * 查詢指定key的value值 * 1、若key==null * 遍歷table[0],找出key==null的value,若沒找到,返回null * 2、若key!=null * 1)計算key.hashCode()的hash值 * 2)根據計算出的hash值和陣列容量,呼叫indexFor方法,獲得table的下標i,進而獲得桶table[i] * 3)遍歷該桶中的每一個Entry,找出key相等(==或equals)的Entry,獲取此Entry的value即可 * 4)最後,若沒有找到,返回null即可 */ public V get(Object key) { /****************查詢key==null的value****************/ if (key == null) return getForNullKey(); /****************查詢key!=null的value****************/ int hash = hash(key.hashCode());//獲取key.hashCode()的hash值 for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) return e.value; } return null;//若沒有指定key的Entry,則直接返回null }
注意:檢視程式碼頭部的註釋,表明了get的整個步驟
getForNullKey()
/** * 在table[0]中查詢key==null */ private V getForNullKey() { for (Entry<K, V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null;//找不到的話就返回null }
5.5、remove(Object key)
原始碼:
/** * 刪除指定key的Entry */ public V remove(Object key) { Entry<K, V> e = removeEntryForKey(key); return (e == null ? null : e.value);//返回刪除的節點(e為null的話,表示所給出的key不存在) } /** * 刪除指定key的Entry * 1)若刪除的是頭節點,例如Entry3,只需將Entry2賦值給table[i]即可 * 2)若刪除的是中間節點,例如Entry2,只需將Entry3.next指向Entry2.next(即Entry1)即可 * 3)若刪除的是尾節點,例如Entry1,只需將Entry2.next指向Entry1.next(即null)即可 */ final Entry<K, V> removeEntryForKey(Object key) { int hash = (key == null) ? 0 : hash(key.hashCode());//計算hash值 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--;//size-1 if (prev == e)//刪除頭節點,即示例中的Entry3 table[i] = next; else//刪除除了頭節點外的其他節點 prev.next = next; //e.recordRemoval(this); return e; } prev = e; e = next; } return e;//返回刪除的節點(e為null的話,表示所給出的key不存在) }
注:看註釋即可,最好用示例去套一下程式碼。
- 若刪除的key不存在於map中,返回null,不會拋異常。
5.6、containsKey(Object key)
原始碼:
/** * 判斷map是否包含指定可以的Entry */ public boolean containsKey(Object key) { return getEntry(key) != null; } /** * 判斷map是否包含指定可以的Entry,與get(Object key)基本相同(只是這裡將key==null與key!=null的情況寫在了一起,get(Object key)也可以這樣去做) * 1)計算key.hashCode()的hash值 * 2)根據計算出的hash值和陣列容量,呼叫indexFor方法,獲得table的下標i,進而獲得桶table[i] * 3)遍歷該桶中的每一個Entry,找出key相等(==或equals)的Entry,獲取此Entry,並返回此Entry * 4)最後,若沒有找到,返回null即可 */ final Entry<K, V> getEntry(Object key) { int hash = (key == null) ? 0 : hash(key.hashCode());//計算hash值 for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
注意:此方法與get(Object key)基本相同,只是只是這裡將key==null與key!=null的情況寫在了一起,get(Object key)也可以這樣去做來減少程式碼
5.7、keySet()
遍歷所有Entry連結串列,獲取每一個Entry的key,在整個過程中,如果發生了增刪操作,丟擲ConcurrentModificationException。
final Entry<K, V> nextEntry() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); Entry<K, V> e = next; if (e == null) throw new NoSuchElementException(); if ((next = e.next) == null) { Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } current = e; return e; }
總結:
- HashMap底層就是一個Entry陣列,Entry又包含next,事實上,可以看成是一個"連結串列陣列"
- 擴容:map中存放的key-value對個數size,該個數決定了陣列的擴容(size>=threshold時,擴容),而非table中的所佔用的桶的個數來決定是否擴容
- 擴容過程,不會重新計算hash值,只會重新按位與
- 在實際使用中,若我們能預判所要儲存的元素的多少,最好使用上述的單參構造器來指定初始容量
- HashMap可以插入null的key和value
- remove(Object key):若刪除的key不存在於map中,返回null,不會拋異常。
- HashMap執行緒不安全,若想要執行緒安全,最好使用ConcurrentHashMap
疑問:
在我們實際使用hashmap時,最好的情況是將key的hash值打散,使插入的這些Entry儘量落在不同的桶上(這樣做的主要目的是提高查詢效率),以下這個hash函式應該就是實現了這樣的功能,但是為什麼這樣的hash函式可以將hash值打散,求大神指點!!!
static int hash(int h) { //這樣的hash函式應該可以儘量將hash值打散 h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
jdk1.8對hashmap進行了改造,1.7中的hashmap最大的問題就是當連結串列比較長時,查詢效率急劇下降;所以在1.8中,當連結串列長度>=8是,連結串列轉為紅黑樹,提高查詢效率。
1 JDK8 中的 HashMap與 JDK7 的 HashMap 有什麼不一樣?
JDK8中新增了紅黑樹,JDK8是通過陣列+連結串列+紅黑樹來實現的
JDK7中連結串列的插入是用的頭插法,而JDK8中則改為了尾插法
JDK8中的因為使用了紅黑樹保證了插入和查詢了效率,所以實際上JDK8中的Hash演算法實現的複雜度降低了
JDK8中陣列擴容的條件也發了變化,只會判斷是否當前元素個數是否查過了閾值,而不再判斷當前put進來的元素對應的陣列下標位置是否有值
JDK7中是先擴容再新增新元素,JDK8中是先新增新元素然後再擴容
2 HashMap 中 put 方法流程
在IDEA中我們找到HashMap原始碼中的put方法原始碼:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put方法呼叫了putVal方法,繼續找到putVal方法的原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
/**
* Implements Map.put and related methods
* @param hash key的雜湊值
* @param key key值
* @param value 需要Put進去的value
* @param onlyIfAbsent 如果為true,不改變已存在的值
* @param evict 如果為false,則table 為creation 模式.
* @return 返回之前的值, 不存在則返回nullh
*/
final V putVal( int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//先判斷table是否為空或者長度為零
if ((tab = table) == null || (n = tab.length) == 0 )
//呼叫resize方法初始化陣列
n = (tab = resize()).length;
//計算陣列下標i=(tab.length - 1) & hash
if ((p = tab[i = (n - 1 ) & hash]) == null )
//如果陣列下標處元素為空,則將hash, key, value等值封裝成連結串列後儲存在此陣列下標處
tab[i] = newNode(hash, key, value, null );
else {
//陣列下標處元素不為空
Node<K,V> e; K k;
//若p = tab[i = (n - 1) & hash]的hash值等於入參hash同時其key值等於入參key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//將tab[i = (n - 1) & hash]賦值給區域性變數e
e = p;
//若陣列下標處的元素是一棵紅黑樹
elseif (p instanceof TreeNode)
//呼叫TreeNode#putTreeVal方法並將其返回值賦值給e,putTreeVal方法後面再講
e = ((TreeNode<K,V>)p).putTreeVal( this , tab, hash, key, value);
else { //陣列下標元素不同時滿足其hash值等於入參hash和其key值等於入參key,
//同時它也不是紅黑樹節點,此時它仍然是Node型別的節點
for ( int binCount = 0 ; ; ++binCount) {
if ((e = p.next) == null ) { //若p的下一個節點為空,並把p.next賦值給e
//封裝hash, key, value為一個新的Node節點賦值給p.next
p.next = newNode(hash, key, value, null );
//若binCount大於等於TREEIFY_THRESHOLD - 1
if (binCount >= TREEIFY_THRESHOLD - 1 ) // -1 for 1st
//呼叫treeifyBin方法後跳出迴圈
treeifyBin(tab, hash);
break ;
}
//若e的hash值等於入參hash,同時e的key值等於入參key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//跳出迴圈
break ;
//以上兩種情況都沒出現時將e賦值給p
p = e;
}
}
if (e != null ) { // 若對應的key值在HashMap中存在對映值
//將e的value值賦值給oldValue
V oldValue = e.value;
//若onlyIfAbsent==false或者oldValue == null
if (!onlyIfAbsent || oldValue == null )
//將新值value賦值給e.value
e.value = value;
//呼叫afterNodeAcces方法
afterNodeAccess(e);
//返回舊值
return oldValue;
}
}
//對應的key值在HashMap中不存在對映值,屬於新增一個元素,modCount值加1
++modCount;
if (++size > threshold)
//若size+1後大於閾值threshold,則呼叫resize方法擴容
resize();
//呼叫afterNodeInsertion方法
afterNodeInsertion(evict);
//返回null
return null ;
}
|
TreeNode#putTreeVal方法原始碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
/**
* Tree version of putVal.
*/
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的父節點不為null則呼叫root()方法獲得node節點,
//否則當前TreeNode即為root節點
TreeNode<K,V> root = (parent != null ) ? root() : this ;
//從紅黑樹的root節點開始遍歷
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
//若p節點的hash值大於入參hash值h則dir賦值為-1
if ((ph = p.hash) > h)
dir = - 1 ;
//若p節點的hash值小於入參hash值h則dir賦值為1
elseif (ph < h)
dir = 1 ;
//p.key賦值給pk
elseif ((pk = p.key) == k || (k != null && k.equals(pk)))
//若滿足(pk = p.key) == k或者k != null && k.equals(pk)
//則返回p節點
return p;
//若滿足kc為null同時呼叫comparableClassFor(k)方法的返回值賦給kc後的值也為null
//或者呼叫compareComparables(kc, k, pk)方法後的返回值賦值給dir後等於0
elseif ((kc == null &&
(kc = comparableClassFor(k)) == null ) ||
(dir = compareComparables(kc, k, pk)) == 0 ) {
if (!searched) {
//若searched==false
TreeNode<K,V> q, ch;
searched = true ;
//p.left或p.right賦值給ch後不為null,同時滿足呼叫ch.find(h, k, kc)方法後的
//返回值賦值給q後也不為null則返回q節點
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;
}
//呼叫tieBreakOrder(k, pk)方法的返回值賦值給dir
dir = tieBreakOrder(k, pk);
}
//定義紅黑樹節點xp,並將p賦值給xp
TreeNode<K,V> xp = p;
//若dir <= 0則將p.left賦值給p,否則將p.right賦值給p,以進行下一次遍歷
if ((p = (dir <= 0 ) ? p.left : p.right) == null ) {
//p節點為null走以下邏輯
//定義xpn節點,並將xp.next節點賦值給xpn
Node<K,V> xpn = xp.next;
//定義x節點為呼叫map.newTreeNode(h, k, v, xpn)方法後的返回值
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
//若dir<=0 則將x節點賦值給xp的左孩子節點,否則將x節點賦值給xp的右孩子節點
if (dir <= 0 )
xp.left = x;
else
xp.right = x;
//將x節點賦值給xp的next節點
xp.next = x;
//將xp節點同時賦值給x的prev節點和parent節點
x.parent = x.prev = xp;
if (xpn != null )
//若xpn節點不為null則將x節點賦值給xpn的prev節點
((TreeNode<K,V>)xpn).prev = x;
//確保root節點是根節點,裡面呼叫了插入節點後的平衡紅黑樹方法balanceInsertion(root, x)
moveRootToFront(tab, balanceInsertion(root, x));
//返回null
return null ;
}
}
}
|
以上涉及到紅黑樹中的複雜演算法待自己搞明白了紅黑樹資料結構再另外撰寫一篇文章釋出
綜上,JDK8中HashMap的put操作流程如下:
1) 對Key求Hash值,然後再計算下標:
2)如果沒有碰撞,直接放入桶中(碰撞的意思是計算得到的Hash值相同,需要放到同一個bucket中)
3)如果碰撞了,以連結串列的方式連結到後面
4)如果連結串列長度超過閥值( TREEIFY_THRESHOLD==8),就把連結串列轉成紅黑樹,連結串列長度低於6,就把紅黑樹轉回連結串列
5)如果節點已經存在就替換舊值
6)如果桶滿了(容量16*載入因子0.75),就需要 resize(擴容2倍後重排)
3 HashMap 的 get 方法流程
當我們呼叫get()方法,HashMap會使用鍵物件的hashcode找到bucket位置,找到bucket位置之後,會呼叫keys.equals()方法去找到連結串列中正確的節點,最終找到要找的值物件。
4 HashMap 擴容流程是怎樣的?
1)HashMap的擴容指的就是陣列的擴容, 因為陣列佔用的是連續記憶體空間,所以陣列的擴容其實只能新開一個新的陣列,然後把老陣列上的元素轉移到新陣列上來,這樣才是陣列的擴容
2)在HashMap中也是一樣,先新建一個2被陣列大小的陣列
3)然後遍歷老陣列上的每一個位置,如果這個位置上是一個連結串列,就把這個連結串列上的元素轉移到新陣列上去
4)在這個過程中就需要遍歷連結串列,當然jdk7,和jdk8在實現時是不一樣的,jdk7就是簡單的遍歷連結串列上的每一個元素,然後按每個元素的hashcode結合新陣列的長度重新計算得出一個下標,而重新得到的這個陣列下標很可能和之前的陣列下標是不一樣的,這樣子就達到了一種效果,就是擴容之後,某個連結串列會變短,這也就達到了擴容的目的,縮短連結串列長度,提高了查詢效率
5)而在jdk8中,因為涉及到紅黑樹,這個其實比較複雜,jdk8中其實還會用到一個雙向連結串列來維護紅黑樹中的元素,所以jdk8中在轉移某個位置上的元素時,會去判斷如果這個位置是一個紅黑樹,那麼會遍歷該位置的雙向連結串列,遍歷雙向連結串列統計哪些元素在擴容完之後還是原位置,哪些元素在擴容之後在新位置,這樣遍歷完雙向連結串列後,就會得到兩個子連結串列,一個放在原下標位置,一個放在新下標位置,如果原下標位置或新下標位置沒有元素,則紅黑樹不用拆分,否則判斷這兩個子連結串列的長度,如果超過八,則轉成紅黑樹放到對應的位置,否則把單向連結串列放到對應的位置
6)元素轉移完了之後,在把新陣列物件賦值給HashMap的table屬性,老陣列會被回收到垃圾收集器中
5 談談你對紅黑樹的理解
1)每個節點非紅即黑
2)根節點總是黑色的
3)如果節點是紅色的,則它的子節點必須是黑色的(反之不一定)
4)每個葉子節點都是黑色的空節點(NIL節點)
5)從根節點到葉節點或空子節點的每條路徑,必須包含相同數目的黑色節點(即相同的黑色高度)
為什麼 HashMap 的陣列的大小是2的冪次方數?
當某個key-value對需要儲存到陣列中時,需要先生成一個數組下標index,並且這個index不能越界。
在HashMap中,先得到key的hashcode,hashcode是一個數字,然後通過hashcode & (table.length - 1) 運算得到一個數組下標index,是通過位運算中的與運算計算出來一個數組下標的,而不是通過取餘,與運算比取餘運算速度更快,但是也有一個前提條件,那就是陣列的長度必須是一個2的冪次方數。
HashSet原始碼解析
6.1、對於HashSet需要掌握以下幾點
- HashSet的建立:HashSet()
- 往HashSet中新增單個物件:即add(E)方法
- 刪除HashSet中的物件:即remove(Object key)方法
- 判斷物件是否存在於HashSet中:containsKey(Object key)
注:HashSet沒有獲取單個物件的方法,需要使用iterator
6.2、構建HashSet
原始碼:
//HashSet底層資料結構:通過hashmap的key不可重複的原則,使得存放入HashSet中的值不重複 private transient HashMap<E, Object> map; //預設的hashmap的value private static final Object PRESENT = new Object(); /** * 可存放16個元素 */ public HashSet() { map = new HashMap<E, Object>(); } /** * 指定hashset的容量和負載因子 */ public HashSet(int initialCapacity, float loadFactor) { map = new HashMap<E, Object>(initialCapacity, loadFactor); } /** * 指定hashset的容量 */ public HashSet(int initialCapacity) { map = new HashMap<E, Object>(initialCapacity); }
注:HashSet的底層是HashMap,其依靠HashMap的key不可重複,來保證將來加入到HashSet中的元素也不重複(會將元素作為key放到hashmap中,參照6.3)。
6.3、add(E e)
原始碼:
add(E e)
/** * 往set中新增值 */ public boolean add(E e) { //檢視hashmap的put方法,若覆蓋已有key的舊值,會返回舊值;若沒有相應的key則返回null return map.put(e, PRESENT) == null; }
注意:這裡呼叫了HashMap的put(K key, V value)
6.4、remove(Object key)
原始碼:
/** * 刪除指定元素 */ public boolean remove(Object o) { return map.remove(o) == PRESENT; }
注:這裡呼叫了HashMap的remove(Object key)
6.5、contains(Object key)
原始碼:
/** * set中是否包含指定元素 */ public boolean contains(Object o) { return map.containsKey(o); }
注意:這裡呼叫了HashMap的containsKey(Object key)
總結:
- HashSet底層就是HashMap
- 其依靠HashMap的key不可重複,來保證將來加入到HashSet中的元素也不重複(會將元素作為key放到hashmap中)
- HashSet執行緒不安全
6.1、對於HashSet需要掌握以下幾點
- HashSet的建立:HashSet()
- 往HashSet中新增單個物件:即add(E)方法
- 刪除HashSet中的物件:即remove(Object key)方法
- 判斷物件是否存在於HashSet中:containsKey(Object key)
注:HashSet沒有獲取單個物件的方法,需要使用iterator
6.2、構建HashSet
原始碼:
//HashSet底層資料結構:通過hashmap的key不可重複的原則,使得存放入HashSet中的值不重複 private transient HashMap<E, Object> map; //預設的hashmap的value private static final Object PRESENT = new Object(); /** * 可存放16個元素 */ public HashSet() { map = new HashMap<E, Object>(); } /** * 指定hashset的容量和負載因子 */ public HashSet(int initialCapacity, float loadFactor) { map = new HashMap<E, Object>(initialCapacity, loadFactor); } /** * 指定hashset的容量 */ public HashSet(int initialCapacity) { map = new HashMap<E, Object>(initialCapacity); }
注:HashSet的底層是HashMap,其依靠HashMap的key不可重複,來保證將來加入到HashSet中的元素也不重複(會將元素作為key放到hashmap中,參照6.3)。
6.3、add(E e)
原始碼:
add(E e)
/** * 往set中新增值 */ public boolean add(E e) { //檢視hashmap的put方法,若覆蓋已有key的舊值,會返回舊值;若沒有相應的key則返回null return map.put(e, PRESENT) == null; }
注意:這裡呼叫了HashMap的put(K key, V value)
6.4、remove(Object key)
原始碼:
/** * 刪除指定元素 */ public boolean remove(Object o) { return map.remove(o) == PRESENT; }
注:這裡呼叫了HashMap的remove(Object key)
6.5、contains(Object key)
原始碼:
/** * set中是否包含指定元素 */ public boolean contains(Object o) { return map.containsKey(o); }
注意:這裡呼叫了HashMap的containsKey(Object key)
總結:
- HashSet底層就是HashMap
- 其依靠HashMap的key不可重複,來保證將來加入到HashSet中的元素也不重複(會將元素作為key放到hashmap中)
- HashSet執行緒不安全
7.1、List(允許重複元素)
- ArrayList:
- 底層資料結構:Object[]
- 在查詢(get)、遍歷(iterator)、修改(set)使用的比較多的情況下,用ArrayList
- 可擴容,容量無限
- LinkedList
- 底層資料結構:環形雙向連結串列
- 在增加(add)、刪除(remove)使用比較多的情況下,用LinkedList
- 連結串列,容量無限
說明:
1)add(E e):在陣列末尾插入元素,ArrayList需要考慮擴容問題,一旦擴容就要進行陣列複製,LinkedList不需要;
2)add(int index):在陣列中間插入元素,ArrayList需要考慮將該index及其後的陣列元素全部複製後移一位,LinkedList不需要
7.2、Set(不允許重複元素,所以可用於去重操作)
- HashSet:
- 底層資料結構:HashMap
- 可看做容量無限
- TreeSet:
- 底層資料結構:TreeMap
- 容量無限
7.3、Map(key-value)
- HashMap:
- 底層資料結構:連結串列陣列
- 可擴容,且最大容量極大,可看做容量無限
- TreeMap:
- 底層資料結構:紅黑樹
- 可以實現按key排序(在使用中,要麼使用TreeMap(Comparator),要麼讓key物件實現Comparable)
- 紅黑樹,容量無限
注意:
- 以上全部執行緒不安全
- 對於查詢和刪除較為頻繁,且元素數量較多(元素數量>100)的情況下,Set和Map效能要比List好一些(單執行緒情況下)
7.1、List(允許重複元素)
- ArrayList:
- 底層資料結構:Object[]
- 在查詢(get)、遍歷(iterator)、修改(set)使用的比較多的情況下,用ArrayList
- 可擴容,容量無限
- LinkedList
- 底層資料結構:環形雙向連結串列
- 在增加(add)、刪除(remove)使用比較多的情況下,用LinkedList
- 連結串列,容量無限
說明:
1)add(E e):在陣列末尾插入元素,ArrayList需要考慮擴容問題,一旦擴容就要進行陣列複製,LinkedList不需要;
2)add(int index):在陣列中間插入元素,ArrayList需要考慮將該index及其後的陣列元素全部複製後移一位,LinkedList不需要
7.2、Set(不允許重複元素,所以可用於去重操作)
- HashSet:
- 底層資料結構:HashMap
- 可看做容量無限
- TreeSet:
- 底層資料結構:TreeMap
- 容量無限
7.3、Map(key-value)
- HashMap:
- 底層資料結構:連結串列陣列
- 可擴容,且最大容量極大,可看做容量無限
- TreeMap:
- 底層資料結構:紅黑樹
- 可以實現按key排序(在使用中,要麼使用TreeMap(Comparator),要麼讓key物件實現Comparable)
- 紅黑樹,容量無限
注意:
- 以上全部執行緒不安全
- 對於查詢和刪除較為頻繁,且元素數量較多(元素數量>100)的情況下,Set和Map效能要比List好一些(單執行緒情況下)