本文的源碼是基於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