HashMap 源碼和底層原理在如今面試中是必問的。所以,咱們很是有必要搞清楚它的底層實現和思想,才能在面試中對答如流,跟面試官大戰三百回合。文章較長,介紹了不少原理性的問題,但願對你有所幫助~node
本篇文章主要包括如下內容:面試
說明:本篇主要以JDK1.8的源碼來分析,順帶講下和JDK1.7的一些區別。數組
這裏須要區分一下,JDK1.7和 JDK1.8以後的 HashMap 存儲結構。在JDK1.7及以前,是用數組加鏈表的方式存儲的。安全
可是,衆所周知,當鏈表的長度特別長的時候,查詢效率將直線降低,查詢的時間複雜度爲 O(n)。所以,JDK1.8 把它設計爲達到一個特定的閾值以後,就將鏈表轉化爲紅黑樹。多線程
這裏簡單說下紅黑樹的特色:app
因爲紅黑樹,是一個自平衡的二叉搜索樹,所以可使查詢的時間複雜度降爲O(logn)。(紅黑樹不是本文重點,不瞭解的童鞋可自行查閱相關資料哈)函數
HashMap 結構示意圖:post
在 HashMap源碼中,比較重要的經常使用變量,主要有如下這些。還有兩個內部類來表示普通鏈表的節點和紅黑樹節點。優化
//默認的初始化容量爲16,必須是2的n次冪 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //最大容量爲 2^30 static final int MAXIMUM_CAPACITY = 1 << 30; //默認的加載因子0.75,乘以數組容量獲得的值,用來表示元素個數達到多少時,須要擴容。 //爲何設置 0.75 這個值呢,簡單來講就是時間和空間的權衡。 //若小於0.75如0.5,則數組長度達到一半大小就須要擴容,空間使用率大大下降, //若大於0.75如0.8,則會增大hash衝突的機率,影響查詢效率。 static final float DEFAULT_LOAD_FACTOR = 0.75f; //剛纔提到了當鏈表長度過長時,會有一個閾值,超過這個閾值8就會轉化爲紅黑樹 static final int TREEIFY_THRESHOLD = 8; //當紅黑樹上的元素個數,減小到6個時,就退化爲鏈表 static final int UNTREEIFY_THRESHOLD = 6; //鏈表轉化爲紅黑樹,除了有閾值的限制,還有另一個限制,須要數組容量至少達到64,纔會樹化。 //這是爲了不,數組擴容和樹化閾值之間的衝突。 static final int MIN_TREEIFY_CAPACITY = 64; //存放全部Node節點的數組 transient Node<K,V>[] table; //存放全部的鍵值對 transient Set<Map.Entry<K,V>> entrySet; //map中的實際鍵值對個數,即數組中元素個數 transient int size; //每次結構改變時,都會自增,fail-fast機制,這是一種錯誤檢測機制。 //當迭代集合的時候,若是結構發生改變,則會發生 fail-fast,拋出異常。 transient int modCount; //數組擴容閾值 int threshold; //加載因子 final float loadFactor; //普通單向鏈表節點類 static class Node<K,V> implements Map.Entry<K,V> { //key的hash值,put和get的時候都須要用到它來肯定元素在數組中的位置 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; } } //轉化爲紅黑樹的節點類 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { //當前節點的父節點 TreeNode<K,V> parent; //左孩子節點 TreeNode<K,V> left; //右孩子節點 TreeNode<K,V> right; //指向前一個節點 TreeNode<K,V> prev; // needed to unlink next upon deletion //當前節點是紅色或者黑色的標識 boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } }
HashMap有四個構造函數可供咱們使用,一塊兒來看下:this
//默認無參構造,指定一個默認的加載因子 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; } //可指定容量的有參構造,可是須要注意當前咱們指定的容量並不必定就是實際的容量,下面會說 public HashMap(int initialCapacity) { //一樣使用默認加載因子 this(initialCapacity, DEFAULT_LOAD_FACTOR); } //可指定容量和加載因子,可是筆者不建議本身手動指定非0.75的加載因子 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; //這裏就是把咱們指定的容量改成一個大於它的的最小的2次冪值,如傳過來的容量是14,則返回16 //注意這裏,按理說返回的值應該賦值給 capacity,即保證數組容量老是2的n次冪,爲何這裏賦值給了 threshold 呢? //先賣個關子,等到 resize 的時候再說 this.threshold = tableSizeFor(initialCapacity); } //可傳入一個已有的map public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); } //把傳入的map裏邊的元素都加載到當前map final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); if (s > 0) { if (table == null) { // pre-size float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); if (t > threshold) threshold = tableSizeFor(t); } else if (s > threshold) resize(); for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); //put方法的具體實現,後邊講 putVal(hash(key), key, value, false, evict); } } }
上邊的第三個構造函數中,調用了 tableSizeFor 方法,這個方法是怎麼實現的呢?
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; }
咱們以傳入參數爲14 來舉例,計算這個過程。
首先,14傳進去以後先減1,n此時爲13。而後是一系列的無符號右移運算。
//13的二進制 0000 0000 0000 0000 0000 0000 0000 1101 //無右移1位,高位補0 0000 0000 0000 0000 0000 0000 0000 0110 //而後把它和原來的13作或運算獲得,此時的n值 0000 0000 0000 0000 0000 0000 0000 1111 //再以上邊的值,右移2位 0000 0000 0000 0000 0000 0000 0000 0011 //而後和第一次或運算以後的 n 值再作或運算,此時獲得的n值 0000 0000 0000 0000 0000 0000 0000 1111 ... //咱們會發現,再執行右移 4,8,16位,一樣n的值不變 //當n小於0時,返回1,不然判斷是否大於最大容量,是的話返回最大容量,不然返回 n+1 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; //很明顯咱們這裏返回的是 n+1 的值, 0000 0000 0000 0000 0000 0000 0000 1111 + 1 0000 0000 0000 0000 0000 0000 0001 0000
將它轉爲十進制,就是 2^4 = 16 。咱們會發現一個規律,以上的右移運算,最終會把最低位的值都轉化爲 1111 這樣的結構,而後再加1,就是1 0000 這樣的結構,它必定是 2的n次冪。所以,這個方法返回的就是大於當前傳入值的最小(最接近當前值)的一個2的n次冪的值。
//put方法,會先調用一個hash()方法,獲得當前key的一個hash值, //用於肯定當前key應該存放在數組的哪一個下標位置 //這裏的 hash方法,咱們姑且先認爲是key.hashCode(),其實不是的,一下子細講 public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } //把hash值和當前的key,value傳入進來 //這裏onlyIfAbsent若是爲true,代表不能修改已經存在的值,所以咱們傳入false //evict只有在方法 afterNodeInsertion(boolean evict) { }用到,能夠看到它是一個空實現,所以不用關注這個參數 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是否爲空,若是空的話,會先調用resize擴容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //根據當前key的hash值找到它在數組中的下標,判斷當前下標位置是否已經存在元素, //若沒有,則把key、value包裝成Node節點,直接添加到此位置。 // i = (n - 1) & hash 是計算下標位置的,爲何這樣算,後邊講 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { //若是當前位置已經有元素了,分爲三種狀況。 Node<K,V> e; K k; //1.當前位置元素的hash值等於傳過來的hash,而且他們的key值也相等, //則把p賦值給e,跳轉到①處,後續須要作值的覆蓋處理 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //2.若是當前是紅黑樹結構,則把它加入到紅黑樹 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //3.說明此位置已存在元素,而且是普通鏈表結構,則採用尾插法,把新節點加入到鏈表尾部 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { //若是頭結點的下一個節點爲空,則插入新節點 p.next = newNode(hash, key, value, null); //若是在插入的過程當中,鏈表長度超過了8,則轉化爲紅黑樹 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); //插入成功以後,跳出循環,跳轉到①處 break; } //若在鏈表中找到了相同key的話,直接退出循環,跳轉到①處 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //① 此時e有兩種狀況 //1.說明發生了碰撞,e表明的是舊值,所以節點位置不變,可是須要替換爲新值 //2.說明e是插入鏈表或者紅黑樹,成功後的新節點 if (e != null) { // existing mapping for key V oldValue = e.value; //用新值替換舊值,並返回舊值。 //oldValue爲空,說明e是新增的節點或者也有可能舊值原本就是空的,由於hashmap可存空值 if (!onlyIfAbsent || oldValue == null) e.value = value; //看方法名字便可知,這是在node被訪問以後須要作的操做。其實此處是一個空實現, //只有在 LinkedHashMap纔會實現,用於實現根據訪問前後順序對元素進行排序,hashmap不提供排序功能 // Callbacks to allow LinkedHashMap post-actions //void afterNodeAccess(Node<K,V> p) { } afterNodeAccess(e); return oldValue; } } //fail-fast機制 ++modCount; //若是當前數組中的元素個數超過閾值,則擴容 if (++size > threshold) resize(); //一樣的空實現 afterNodeInsertion(evict); return null; }
前面 put 方法中說到,須要先把當前key進行哈希處理,咱們看下這個方法是怎麼實現的。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
這裏,會先判斷key是否爲空,若爲空則返回0。這也說明了hashMap是支持key傳 null 的。若非空,則先計算key的hashCode值,賦值給h,而後把h右移16位,並與原來的h進行異或處理。爲何要這樣作,這樣作有什麼好處呢?
咱們知道,hashCode()方法繼承自父類Object,它返回的是一個 int 類型的數值,能夠保證同一個應用單次執行的每次調用,返回結果都是相同的(這個說明能夠在hashCode源碼上找到),這就保證了hash的肯定性。在此基礎上,再進行某些固定的運算,確定結果也是能夠肯定的。
我隨便運行一段程序,把它的 hashCode的二進制打印出來,以下。
public static void main(String[] args) { Object o = new Object(); int hash = o.hashCode(); System.out.println(hash); System.out.println(Integer.toBinaryString(hash)); } //1836019240 //1101101011011110110111000101000
而後,進行 (h = key.hashCode()) ^ (h >>> 16) 這一段運算。
//h原來的值 0110 1101 0110 1111 0110 1110 0010 1000 //無符號右移16位,其實至關於把低位16位捨去,只保留高16位 0000 0000 0000 0000 0110 1101 0110 1111 //而後高16位和原 h進行異或運算 0110 1101 0110 1111 0110 1110 0010 1000 ^ 0000 0000 0000 0000 0110 1101 0110 1111 = 0110 1101 0110 1111 0000 0011 0100 0111
能夠看到,其實至關於,咱們把高16位值和當前h的低16位進行了混合,這樣能夠儘可能保留高16位的特徵,從而下降哈希碰撞的機率。
思考一下,爲何這樣作,就能夠下降哈希碰撞的機率呢?先彆着急,咱們須要結合 i = (n - 1) & hash 這一段運算來理解。
** (n-1) & hash 做用**
//② //這是 put 方法中用來根據hash()值尋找在數組中的下標的邏輯, //n爲數組長度, hash爲調用 hash()方法混合處理以後的hash值。 i = (n - 1) & hash
咱們知道,若是給定某個數值,去找它在某個數組中的下標位置時,直接用模運算就能夠了(假設數組值從0開始遞增)。如,我找 14 在數組長度爲16的數組中的下標,即爲 14 % 16,等於14 。 18的位置即爲 18%16,等於2。
而②中,就是取模運算的位運算形式。以18%16爲例
//18的二進制 0001 0010 //16 -1 即 15的二進制 0000 1111 //與運算以後的結果爲 0000 0010 // 能夠看到,上邊的結果轉化爲十進制就是 2 。 //其實咱們會發現一個規律,由於n是2的n次冪,所以它的二進制表現形式確定是相似於 0001 0000 //這樣的形式,只有一個位是1,其餘位都是0。而它減 1 以後的形式就是相似於 0000 1111 //這樣的形式,高位都是0,低位都是1,所以它和任意值進行與運算,結果值確定在這個區間內 0000 0000 ~ 0000 1111 //也就是0到15之間,(以n爲16爲例) //所以,這個運算就能夠實現取模運算,並且位運算還有個好處,就是速度比較快。
爲何高低位異或運算能夠減小哈希碰撞
咱們想象一下,假如用 key 原來的hashCode值,直接和 (n-1) 進行與運算來求數組下標,而不進行高低位混合運算,會產生什麼樣的結果。
//例如我有另一個h2,和原來的 h相比較,高16位有很大的不一樣,可是低16位類似度很高,甚至相同的話。 //原h值 0110 1101 0110 1111 0110 1110 0010 1000 //另一個h2值 0100 0101 1110 1011 0110 0110 0010 1000 // n -1 ,即 15 的二進制 0000 0000 0000 0000 0000 0000 0000 1111 //能夠發現 h2 和 h 的高位不相同,可是低位類似度很是高。 //他們分別和 n -1 進行與運算時,獲得的結果倒是相同的。(此處n假設爲16) //由於 n-1 的高16位都是0,無論 h 的高 16 位是什麼,與運算以後,都不影響最終結果,高位必定全是 0 //所以,哈希碰撞的機率就大大增長了,而且 h 的高16 位特徵全都丟失了。
愛思考的同窗可能就會有疑問了,我進行高低16位混合運算,是能夠的,這樣能夠保證儘可能減小高區位的特徵。那麼,爲何選擇用異或運算呢,我用與、或、非運算不行嗎?
這是有必定的道理的。咱們看一個表格,就能明白了。
能夠看到兩個值進行與運算,結果會趨向於0;或運算,結果會趨向於1;而只有異或運算,0和1的比例能夠達到1:1的平衡狀態。(非呢?別扯犢子了,兩個值怎麼作非運算。。。)
因此,異或運算以後,可讓結果的隨機性更大,而隨機性大了以後,哈希碰撞的機率固然就更小了。
以上,就是爲何要對一個hash值進行高低位混合,而且選擇異或運算來混合的緣由。
在上邊 put 方法中,咱們會發現,當數組爲空的時候,會調用 resize 方法,當數組的 size 大於閾值的時候,也會調用 resize方法。 那麼看下 resize 方法都作了哪些事情吧。
final Node<K,V>[] resize() { //舊數組 Node<K,V>[] oldTab = table; //舊數組的容量 int oldCap = (oldTab == null) ? 0 : oldTab.length; //舊數組的擴容閾值,注意看,這裏取的是當前對象的 threshold 值,下邊的第2種狀況會用到。 int oldThr = threshold; //初始化新數組的容量和閾值,分三種狀況討論。 int newCap, newThr = 0; //1.當舊數組的容量大於0時,說明在這以前確定調用過 resize擴容過一次,纔會致使舊容量不爲0。 //爲何這樣說呢,以前我在 tableSizeFor 賣了個關子,須要注意的是,它返回的值是賦給了 threshold 而不是 capacity。 //咱們在這以前,壓根就沒有在任何地方看到過,它給 capacity 賦初始值。 if (oldCap > 0) { //容量達到了最大值 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //新數組的容量和閾值都擴大原來的2倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } //2.到這裏,說明 oldCap <= 0,而且 oldThr(threshold) > 0,這就是 map 初始化的時候,第一次調用 resize的狀況 //而 oldThr的值等於 threshold,此時的 threshold 是經過 tableSizeFor 方法獲得的一個2的n次冪的值(咱們以16爲例)。 //所以,須要把 oldThr 的值,也就是 threshold ,賦值給新數組的容量 newCap,以保證數組的容量是2的n次冪。 //因此咱們能夠得出結論,當map第一次 put 元素的時候,就會走到這個分支,把數組的容量設置爲正確的值(2的n次冪) //可是,此時 threshold 的值也是2的n次冪,這不對啊,它應該是數組的容量乘以加載因子纔對。彆着急,這個會在③處理。 else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; //3.到這裏,說明 oldCap 和 oldThr 都是小於等於0的。也說明咱們的map是經過默認無參構造來建立的, //因而,數組的容量和閾值都取默認值就能夠了,即 16 和 12。 else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //③ 這裏就是處理第2種狀況,由於只有這種狀況 newThr 才爲0, //所以計算 newThr(用 newCap即16 乘以加載因子 0.75,獲得 12) ,並把它賦值給 threshold if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } //賦予 threshold 正確的值,表示數組下次須要擴容的閾值(此時就把原來的 16 修正爲了 12)。 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //若是原來的數組不爲空,那麼咱們就須要把原來數組中的元素從新分配到新的數組中 //若是是第2種狀況,因爲是第一次調用resize,此時數組確定是空的,所以也就不須要從新分配元素。 if (oldTab != null) { //遍歷舊數組 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; //取到當前下標的第一個元素,若是存在,則分三種狀況從新分配位置 if ((e = oldTab[j]) != null) { oldTab[j] = null; //1.若是當前元素的下一個元素爲空,則說明此處只有一個元素 //則直接用它的hash()值和新數組的容量取模就能夠了,獲得新的下標位置。 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; //2.若是是紅黑樹結構,則拆分成黑樹,必要時有可能退化爲鏈表 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //3.到這裏說明,這是一個長度大於 1 的普通鏈表,則須要計算並 //判斷當前位置的鏈表是否須要移動到新的位置 else { // preserve order // loHead 和 loTail 分別表明鏈表舊位置的頭尾節點 Node<K,V> loHead = null, loTail = null; // hiHead 和 hiTail 分別表明鏈表移動到新位置的頭尾節點 Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; //若是當前元素的hash值和oldCap作與運算爲0,則原位置不變 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; newTab[j] = loHead; } //移動到新位置的一條鏈表,數組下標爲原下標加上舊數組的容量 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
上邊還有一個很是重要的運算,咱們沒有講解。就是下邊這個判斷,它用於把原來的普通鏈表拆分爲兩條鏈表,位置不變或者放在新的位置。
if ((e.hash & oldCap) == 0) {} else {}
咱們以原數組容量16爲例,擴容以後容量爲32。說明下爲何這樣計算。
仍是用以前的hash值舉例。
//e.hash值 0110 1101 0110 1111 0110 1110 0010 1000 //oldCap值,即16 0000 0000 0000 0000 0000 0000 0001 0000 //作與運算,咱們會發現結果不是0就是非0, //並且它取決於 e.hash 二進制位的倒數第五位是 0 仍是 1, //若倒數第五位爲0,則結果爲0,若倒數第五位爲1,則結果爲非0。 //那這個和新數組有什麼關係呢? //彆着急,咱們看下新數組的容量是32,若是求當前hash值在新數組中的下標,則爲 // e.hash &( 32 - 1) 這樣的運算 ,即 hash 與 31 進行與運算, 0110 1101 0110 1111 0110 1110 0010 1000 & 0000 0000 0000 0000 0000 0000 0001 1111 = 0000 0000 0000 0000 0000 0000 0000 1000 //接下來,咱們對比原來的下標計算結果和新的下標結果,看圖
看下面的圖,咱們觀察,hash值和舊數組進行與運算的結果 ,跟新數組的與運算結果有什麼不一樣。
會發現一個規律:
若hash值的倒數第五位是0,則新下標與舊下標結果相同,都爲 0000 1000
若hash值的倒數第五位是1,則新下標(0001 1000)與舊下標(0000 1000)結果值相差了 16 。
所以,咱們就能夠根據 (e.hash & oldCap == 0) 這個判斷的真假來決定,當前元素應該在原來的位置不變,仍是在新的位置(原位置 + 16)。
若是,上邊的推理仍是不明白的話,我再舉個簡單的例子。
18%16=2 18%32=18 34%16=2 34%32=2 50%16=2 50%32=18
怎麼樣,發現規律沒,有沒有那個感受了?
計算中的18,34 ,50 其實就至關於 e.hash 值,和新舊數組作取模運算,獲得的結果,要麼就是原來的位置不變,要麼就是原來的位置加上舊數組的長度。
有了前面的基礎,get方法就比較簡單了。
public V get(Object key) { Node<K,V> e; //若是節點爲空,則返回null,不然返回節點的value。這也說明,hashMap是支持value爲null的。 //所以,咱們就明白了,爲何hashMap支持Key和value都爲null 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; //首先要確保數組不能爲空,而後取到當前hash值計算出來的下標位置的第一個元素 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //若hash值和key都相等,則說明咱們要找的就是第一個元素,直接返回 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; //若是不是的話,就遍歷當前鏈表(或紅黑樹) if ((e = first.next) != null) { //若是是紅黑樹結構,則找到當前key所在的節點位置 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); } } //不然,說明沒有找到,返回null return null; }
準確的講應該是 JDK1.7 的 HashMap 鏈表會有死循環的可能,由於JDK1.7是採用的頭插法,在多線程環境下有可能會使鏈表造成環狀,從而致使死循環。JDK1.8作了改進,用的是尾插法,不會產生死循環。
那麼,鏈表是怎麼造成環狀的呢?
關於這一點的解釋,我發現網上文章抄來抄去的,並且都來自左耳朵耗子,更驚奇的是,連配圖都是如出一轍的。(別問我爲何知道,由於我也看過耗子叔的文章,哈哈。然而,菜雞的我,那篇文章,並無看懂。。。)
我實在看不下去了,因而一怒之下,就有了這篇文章。我會照着源碼一步一步的分析變量之間的關係怎麼變化的,並有配圖哦。
咱們從 put()方法開始,最終找到線程不安全的那個方法。這裏省略中間不重要的過程,我只把方法的跳轉流程貼出來:
//添加元素方法 -> 添加新節點方法 -> 擴容方法 -> 把原數組元素從新分配到新數組中 put() --> addEntry() --> resize() --> transfer()
問題就發生在 transfer 這個方法中。
咱們假設,原數組容量只有2,其中一條鏈表上有兩個元素 A,B,以下圖
如今,有兩個線程都執行 transfer 方法。每一個線程都會在它們本身的工做內存生成一個newTable 的數組,用於存儲變化後的鏈表,它們互不影響(這裏互不影響,指的是兩個新數組自己互不影響)。可是,須要注意的是,它們操做的數據倒是同一份。
由於,真正的數組中的內容在堆中存儲,它們指向的是同一份數據內容。就至關於,有兩個不一樣的引用 X,Y,可是它們都指向同一個對象 Z。這裏 X、Y就是兩個線程不一樣的新數組,Z就是堆中的A,B 等元素對象。
假設線程一執行到了上圖1中所指的代碼①處,剛好 CPU 時間片到了,線程被掛起,不能繼續執行了。 記住此時,線程一中記錄的 e = A , e.next = B。
而後線程二正常執行,擴容後的數組長度爲 4, 假設 A,B兩個元素又碰撞到了同一個桶中。而後,經過幾回 while 循環後,採用頭插法,最終呈現的結構以下:
此時,線程一解掛,繼續往下執行。注意,此時線程一,記錄的仍是 e = A,e.next = B,由於它還未感知到最新的變化。
咱們主要關注圖1中標註的①②③④處的變量變化:
/** * next = e.next * e.next = newTable[i] * newTable[i] = e; * e = next; */ //第一次循環,(僞代碼) e=A;next=B; e.next=null //此時線程一的新數組剛初始化完成,尚未元素 newTab[i] = A->null //把A節點頭插到新數組中 e=B; //下次循環的e值
第一次循環結束後,線程一新數組的結構以下圖:
而後,因爲 e=B,不爲空,進入第二次循環。
//第二次循環 e=B;next=A; //此時A,B的內容已經被線程二修改成 B->A->null,而後被線程一讀到,因此B的下一個節點指向A e.next=A->null // A->null 爲第一次循環後線程一新數組的結構 newTab[i] = B->A->null //新節點B插入以後,線程一新數組的結構 e=A; //下次循環的 e 值
第二次循環結束後,線程一新數組的結構以下圖:
此時,因爲 e=A,不爲空,繼續循環。
//第三次循環 e=A;next=null; // A節點後邊已經沒有節點了 e.next= B->A->null // B->A->null 爲第二次循環後線程一新數組的結構 //咱們把A插入後,抽象的表達爲 A->B->A->null,可是,A只能是一個,不能分身啊 //所以其實是 e(A).next指向發生了變化,A的 next 由指向 null 改成指向了 B, //而 B 自己又指向A,所以A和B互相指向,成環 newTab[i] = A->B 且 B->A e=next=null; //e此時爲空,結束循環
第三次循環結束後,看下圖,A的指向由 null ,改成指向爲 B,所以 A 和 B 之間成環。
這時,有的同窗可能就會問了,就算他們成環了,又怎樣,跟死循環有什麼關係?
咱們看下 get() 方法(最終調用 getEntry 方法),
能夠看到查找元素時,只要 e 不爲空,就會一直循環查找下去。如有某個元素 C 的 hash 值也落在了和 A,B元素同一個桶中,則會因爲, A,B互相指向,e.next 永遠不爲空,就會造成死循環。
若是本文對你有用,歡迎關注我哦~