刨死你係列——HashMap(jdk1.8)
本文的原始碼是基於JDK1.8版本,在學習HashMap之前,先了解陣列和連結串列的知識。
陣列:
陣列具有遍歷快,增刪慢的特點。陣列在堆中是一塊連續的儲存空間,遍歷時陣列的首地址是知道的(首地址=首地址+元素位元組數 * 下標),所以遍歷快(陣列遍歷的時間複雜度為O(1) );增刪慢是因為,當在中間插入或刪除元素時,會造成該元素後面所有元素地址的改變,所以增刪慢(增刪的時間複雜度為O(n) )。
連結串列:
連結串列具有增刪快,遍歷慢的特點。連結串列中各元素的記憶體空間是不連續的,一個節點至少包含節點資料與後繼節點的引用,所以在插入刪除時,只需修改該位置的前驅節點與後繼節點即可,連結串列在插入刪除時的時間複雜度為O(1)。但是在遍歷時,get(n)元素時,需要從第一個開始,依次拿到後面元素的地址,進行遍歷,直到遍歷到第n個元素(時間複雜度為O(n) ),所以效率極低。
HashMap:
Hash表是一個數組+連結串列的結構,這種結構能夠保證在遍歷與增刪的過程中,如果不產生hash碰撞,僅需一次定位就可完成,時間複雜度能保證在O(1)。 在jdk1.7中,只是單純的陣列+連結串列的結構,但是如果散列表中的hash碰撞過多時,會造成效率的降低,所以在JKD1.8中對這種情況進行了控制,當一個hash值上的連結串列長度大於8時,該節點上的資料就不再以連結串列進行儲存,而是轉成了一個紅黑樹。
hash碰撞:
hash是指,兩個元素通過hash函式計算出的值是一樣的,是同一個儲存地址。當後面的元素要插入到這個地址時,發現已經被佔用了,這時候就產生了hash衝突
hash衝突的解決方法:
開放定址法(查詢產生衝突的地址的下一個地址是否被佔用,直到尋找到空的地址),再雜湊法,鏈地址法等。hashmap採用的就是鏈地址法,jdk1.7中,當衝突時,在衝突的地址上生成一個連結串列,將衝突的元素的key,通過equals進行比較,相同即覆蓋,不同則新增到連結串列上,此時如果連結串列過長,效率就會大大降低,查詢和新增操作的時間複雜度都為O(n);但是在jdk1.8中如果連結串列長度大於8,連結串列就會轉化為紅黑樹,下圖就是1.8版本的(圖片來源https://segmentfault.com/a/1190000012926722),時間複雜度也降為了O(logn),效能得到了很大的優化。
下面通過原始碼分析一下,HashMap的底層實現
首先,hashMap的主幹是一個Node陣列(jdk1.7及之前為Entry陣列)每一個Node包含一個key與value的鍵值對,與一個next指向下一個node,hashMap由多個Node物件組成。
Node是HhaspMap中的一個靜態內部類 :
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } //hashCode等其他程式碼 }
再看下hashMap中幾個重要的欄位:
//預設初始容量為16,0000 0001 左移4位 0001 0000為16,主幹陣列的初始容量為16,而且這個陣列 //必須是2的倍數(後面說為什麼是2的倍數) static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //最大容量為int的最大值除2 static final int MAXIMUM_CAPACITY = 1 << 30; //預設載入因子為0.75 static final float DEFAULT_LOAD_FACTOR = 0.75f; //閾值,如果主幹陣列上的連結串列的長度大於8,連結串列轉化為紅黑樹 static final int TREEIFY_THRESHOLD = 8; //hash表擴容後,如果發現某一個紅黑樹的長度小於6,則會重新退化為連結串列 static final int UNTREEIFY_THRESHOLD = 6; //當hashmap容量大於64時,連結串列才能轉成紅黑樹 static final int MIN_TREEIFY_CAPACITY = 64; //臨界值=主幹陣列容量*負載因子 int threshold;
HashMap的構造方法:
//initialCapacity為初始容量,loadFactor為負載因子 public HashMap(int initialCapacity, float loadFactor) { //初始容量小於0,丟擲非法資料異常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //初始容量最大為MAXIMUM_CAPACITY if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //負載因子必須大於0,並且是合法數字 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; //將初始容量轉成2次冪 this.threshold = tableSizeFor(initialCapacity); } //tableSizeFor的作用就是,如果傳入A,當A大於0,小於定義的最大容量時, // 如果A是2次冪則返回A,否則將A轉化為一個比A大且差距最小的2次冪。 //例如傳入7返回8,傳入8返回8,傳入9返回16 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.75 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //預設構造方法,負載因子為0.75,初始容量為DEFAULT_INITIAL_CAPACITY=16,初始容量在第一次put時才會初始化 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } //傳入一個MAP集合的構造方法 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
HashMap的put()方法
put 方法的原始碼分析是本篇的一個重點,因為通過該方法我們可以窺探到 HashMap 在內部是如何進行資料儲存的,所謂的陣列+連結串列+紅黑樹的儲存結構是如何形成的,又是在何種情況下將連結串列轉換成紅黑樹來優化效能的。帶著一系列的疑問,我們看這個 put 方法:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
也就是put方法呼叫了putVal方法,其中傳入一個引數位hash(key),我們首先來看看hash()這個方法。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
此處如果傳入的int型別的值:①向一個Object型別賦值一個int的值時,會將int值自動封箱為Integer。②integer型別的hashcode都是他自身的值,即h=key;h >>> 16為無符號右移16位,低位擠走,高位補0;^ 為按位異或,即轉成二進位制後,相異為1,相同為0,由此可發現,當傳入的值小於 2的16次方-1 時,呼叫這個方法返回的值,都是自身的值。
然後再執行putVal方法:
//onlyIfAbsent是true的話,不要改變現有的值 //evict為true的話,表處於建立模式 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為空,長度為0,呼叫resize方法,調整table的長度(resize方法在下圖中) if ((tab = table) == null || (n = tab.length) == 0) /* 這裡呼叫resize,其實就是第一次put時,對陣列進行初始化。 如果是預設構造方法會執行resize中的這幾句話: newCap = DEFAULT_INITIAL_CAPACITY; 新的容量等於預設值16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); threshold = newThr; 臨界值等於16*0.75 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; 將新的node陣列賦值給table,然後return newTab 如果是自定義的構造方法則會執行resize中的: int oldThr = threshold; newCap = oldThr; 新的容量等於threshold,這裡的threshold都是2的倍數,原因在 於傳入的數都經過tableSizeFor方法,返回了一個新值,上面解釋過 float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); threshold = newThr; 新的臨界值等於 (int)(新的容量*負載因子) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; return newTab; */ n = (tab = resize()).length; //將呼叫resize後構造的陣列的長度賦值給n if ((p = tab[i = (n - 1) & hash]) == null) //將陣列長度與計算得到的hash值比較 tab[i] = newNode(hash, key, value, null);//位置為空,將i位置上賦值一個node物件 else { //位置不為空 Node<K,V> e; K k; if (p.hash == hash && // 如果這個位置的old節點與new節點的key完全相同 ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 則e=p else if (p instanceof TreeNode) // 如果p已經是樹節點的一個例項,既這裡已經是樹了 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //p與新節點既不完全相同,p也不是treenode的例項 for (int binCount = 0; ; ++binCount) { //一個死迴圈 if ((e = p.next) == null) { //e=p.next,如果p的next指向為null p.next = newNode(hash, key, value, null); //指向一個新的節點 if (binCount >= TREEIFY_THRESHOLD - 1) // 如果連結串列長度大於等於8 treeifyBin(tab, hash); //將連結串列轉為紅黑樹 break; } if (e.hash == hash && //如果遍歷過程中連結串列中的元素與新新增的元素完全相同,則跳出迴圈 ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; //將p中的next賦值給p,即將連結串列中的下一個node賦值給p, //繼續迴圈遍歷連結串列中的元素 } } if (e != null) { //這個判斷中程式碼作用為:如果新增的元素產生了hash衝突,那麼呼叫 //put方法時,會將他在連結串列中他的上一個元素的值返回 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) //判斷條件成立的話,將oldvalue替換 //為newvalue,返回oldvalue;不成立則不替換,然後返回oldvalue e.value = value; afterNodeAccess(e); //這個方法在後面說 return oldValue; } } ++modCount; //記錄修改次數 if (++size > threshold) //如果元素數量大於臨界值,則進行擴容 resize(); //下面說 afterNodeInsertion(evict); return null; }
註釋已經很詳細了,咱們說一下這個初始化的問題
//如果 table 還未被初始化,那麼初始化它 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
resize()擴容機制,單元素如何雜湊到新的陣列中,連結串列中的元素如何雜湊到新的陣列中,紅黑樹中的元素如何雜湊到新的陣列中?
//上圖中說了預設構造方法與自定義構造方法第一次執行resize的過程,這裡再說一下擴容的過程 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { //擴容肯定執行這個分支 if (oldCap >= MAXIMUM_CAPACITY) { //當容量超過最大值時,臨界值設定為int最大值 threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //擴容容量為2倍,臨界值為2倍 newThr = oldThr << 1; } else if (oldThr > 0) // 不執行 newCap = oldThr; else { // 不執行 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; //將新的臨界值賦值賦值給threshold @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //新的陣列賦值給table //擴容後,重新計算元素新的位置 if (oldTab != null) { //原陣列 for (int j = 0; j < oldCap; ++j) { //通過原容量遍歷原陣列 Node<K,V> e; if ((e = oldTab[j]) != null) { //判斷node是否為空,將j位置上的節點 //儲存到e,然後將oldTab置為空,這裡為什麼要把他置為空呢,置為空有什麼好處嗎?? //難道是吧oldTab變為一個空陣列,便於垃圾回收?? 這裡不是很清楚 oldTab[j] = null; if (e.next == null) //判斷node上是否有連結串列 newTab[e.hash & (newCap - 1)] = e; //無連結串列,確定元素存放位置, //擴容前的元素地址為 (oldCap - 1) & e.hash ,所以這裡的新的地址只有兩種可能,一是地址不變, //二是變為 老位置+oldCap else if (e instanceof TreeNode) ((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; /* 這裡如果判斷成立,那麼該元素的地址在新的陣列中就不會改變。因為oldCap的最高位的1,在e.hash對應的位上為0,所以擴容後得到的地址是一樣的,位置不會改變 ,在後面的程式碼的執行中會放到loHead中去,最後賦值給newTab[j]; 如果判斷不成立,那麼該元素的地址變為 原下標位置+oldCap,也就是lodCap最高位的1,在e.hash對應的位置上也為1,所以擴容後的地址改變了,在後面的程式碼中會放到hiHead中,最後賦值給newTab[j + oldCap] 舉個栗子來說一下上面的兩種情況: 設:oldCap=16 二進位制為:0001 0000 oldCap-1=15 二進位制為:0000 1111 e1.hash=10 二進位制為:0000 1010 e2.hash=26 二進位制為:0101 1010 e1在擴容前的位置為:e1.hash & oldCap-1 結果為:0000 1010 e2在擴容前的位置為:e2.hash & oldCap-1 結果為:0000 1010 結果相同,所以e1和e2在擴容前在同一個連結串列上,這是擴容之前的狀態。 現在擴容後,需要重新計算元素的位置,在擴容前的連結串列中計算地址的方式為e.hash & oldCap-1 那麼在擴容後應該也這麼計算呀,擴容後的容量為oldCap*2=32 0010 0000 newCap=32,新的計算 方式應該為 e1.hash & newCap-1 即:0000 1010 & 0001 1111 結果為0000 1010與擴容前的位置完全一樣。 e2.hash & newCap-1 即:0101 1010 & 0001 1111 結果為0001 1010,為擴容前位置+oldCap。 而這裡卻沒有e.hash & newCap-1 而是 e.hash & oldCap,其實這兩個是等效的,都是判斷倒數第五位 是0,還是1。如果是0,則位置不變,是1則位置改變為擴容前位置+oldCap。 再來分析下loTail loHead這兩個的執行過程(假設(e.hash & oldCap) == 0成立): 第一次執行: e指向oldTab[j]所指向的node物件,即e指向該位置上鍊表的第一個元素 loTail為空,所以loHead指向與e相同的node物件,然後loTail也指向了同一個node物件。 最後,在判斷條件e指向next,就是指向oldTab連結串列中的第二個元素 第二次執行: lotail不為null,所以lotail.next指向e,這裡其實是lotail指向的node物件的next指向e, 也可以說是,loHead的next指向了e,就是指向了oldTab連結串列中第二個元素。此時loHead指向 的node變成了一個長度為2的連結串列。然後lotail=e也就是指向了連結串列中第二個元素的地址。 第三次執行: 與第二次執行類似,loHead上的連結串列長度變為3,又增加了一個node,loTail指向新增的node ...... hiTail與hiHead的執行過程與以上相同,這裡就不再做解釋了。 由此可以看出,loHead是用來儲存新連結串列上的頭元素的,loTail是用來儲存尾元素的,直到遍 歷完連結串列。 這是(e.hash & oldCap) == 0成立的時候。 (e.hash & oldCap) == 0不成立的情況也相同,其實就是把oldCap遍歷成兩個新的連結串列, 通過loHead和hiHead來儲存連結串列的頭結點,然後將兩個頭結點放到newTab[j]與 newTab[j+oldCap]上面去 */ do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; //尾節點的next設定為空 newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; //尾節點的next設定為空 newTab[j + oldCap] = hiHead; } } } } } return newTab; }
有關JDK1.7擴容出現的死迴圈的問題:
/** * Transfers all entries from current table to newTable. */ 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) { src[j] = null; do { // B執行緒執行到這裡之後就暫停了 Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } }
併發下的Rehash
1)假設我們有兩個執行緒。我用紅色和淺藍色標註了一下。我們再回頭看一下我們的 transfer程式碼中的這個細節:
do { Entry<K,V> next = e.next; // <--假設執行緒一執行到這裡就被排程掛起了 int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null);
而我們的執行緒二執行完成了。於是我們有下面的這個樣子。
注意,因為Thread1的 e 指向了key(3),而next指向了key(7),其線上程二rehash後,指向了執行緒二重組後的連結串列。我們可以看到連結串列的順序被反轉後。
2)執行緒一被排程回來執行。
- 先是執行 newTalbe[i] = e;
- 然後是e = next,導致了e指向了key(7),
- 而下一次迴圈的next = e.next導致了next指向了key(3)
3)一切安好。
執行緒一接著工作。把key(7)摘下來,放到newTable[i]的第一個,然後把e和next往下移。
4)環形連結出現。
e.next = newTable[i] 導致 key(3).next 指向了 key(7)
注意:此時的key(7).next 已經指向了key(3), 環形連結串列就這樣出現了。
於是,當我們的執行緒一呼叫到,HashTable.get(11)時,悲劇就出現了——Infinite Loop。
因為HashMap本來就不支援併發。要併發就用ConcurrentHashmap
HashMap的get()方法
public V get(Object key) { Node<K,V> e; //直接呼叫了getNode() 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; //先判斷陣列是否為空,長度是否大於0,那個node節點是否存在 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //如果找到,直接返回 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null) { //如果是紅黑樹,去紅黑樹找 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); //連結串列找 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
這裡關於first = tab[(n - 1) & hash]
這裡通過(n - 1)& hash
即可算出桶的在桶陣列中的位置,可能有的朋友不太明白這裡為什麼這麼做,這裡簡單解釋一下。HashMap 中桶陣列的大小 length 總是2的冪,此時,(n - 1) & hash
等價於對 length 取餘。但取餘的計算效率沒有位運算高,所以(n - 1) & hash
也是一個小的優化。舉個例子說明一下吧,假設 hash = 185,n = 16。計算過程示意圖如下
在上面原始碼中,除了查詢相關邏輯,還有一個計算 hash 的方法。這個方法原始碼如下:
/** * 計算鍵的 hash 值 */ static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
看這個方法的邏輯好像是通過位運算重新計算 hash,那麼這裡為什麼要這樣做呢?為什麼不直接用鍵的 hashCode 方法產生的 hash 呢?大家先可以思考一下,我把答案寫在下面。
這樣做有兩個好處,我來簡單解釋一下。我們再看一下上面求餘的計算圖,圖中的 hash 是由鍵的 hashCode 產生。計算餘數時,由於 n 比較小,hash 只有低4位參與了計算,高位的計算可以認為是無效的。這樣導致了計算結果只與低位資訊有關,高位資料沒發揮作用。為了處理這個缺陷,我們可以上圖中的 hash 高4位資料與低4位資料進行異或運算,即 hash ^ (hash >>> 4)
。通過這種方式,讓高位資料與低位資料進行異或,以此加大低位資訊的隨機性,變相的讓高位資料參與到計算中。此時的計算過程如下:
在 Java 中,hashCode 方法產生的 hash 是 int 型別,32 位寬。前16位為高位,後16位為低位,所以要右移16位。
上面所說的是重新計算 hash 的一個好處,除此之外,重新計算 hash 的另一個好處是可以增加 hash 的複雜度。當我們覆寫 hashCode 方法時,可能會寫出分佈性不佳的 hashCode 方法,進而導致 hash 的衝突率比較高。通過移位和異或運算,可以讓 hash 變得更復雜,進而影響 hash 的分佈性。這也就是為什麼 HashMap 不直接使用鍵物件原始 hash 的原因了。
由於個人能力問題,先學習這些,資料結構這個大山,我一定要刨平它。
基於jdk1.7版本的HashMap
https://www.jianshu.com/p/dde9b12343c1
參考部落格:
https://www.cnblogs.com/wenbochang/archive/2018/02/22/8458756.html
https://segmentfault.com/a/1190000012926722
https://blog.csdn.net/pange1991/article/details/82377980
&n