源碼部分從HashMap提及是由於筆者看了不少遍這個類的源碼部分,同時感受網上不少都是粗略的介紹,有些可能還不正確,最後只能本身看源碼來驗證理解,寫下這篇文章一方面是爲了促使本身能深刻,另外一方面也是給一些新人一些指導,不求有功,但求無過。有錯誤的地方請在評論中指出,我會及時驗證修改,謝謝。html
接下來就來講下我眼中的HashMap。java
jdk版本:1.8
在深刻源碼以前,瞭解HashMap的總體結構是很是重要的事情,結構也體現出了源碼中一些對HashMap的操做,結構大體以下:node
從上邊的結構圖你們應該也能看出來HashMap的實現結構:數組+鏈表+紅黑樹
。segmentfault
看下類註釋,直接看源碼部分最好,可能大多數都看不明白,這裏能夠看下別人的翻譯:類註釋翻譯。本文中筆者不打算對紅黑樹部分進行講解說明,插入和刪除操做會引起各類狀態,須要作對應的調整,以後會單獨寫一篇紅黑樹基礎,結合TreeNode來作講解。數組
先總結一些名詞概念方便初學者理解:安全
1.桶(bucket):數組中存儲元素的位置,參考結構圖,其實是數組中的某個索引下的元素,這個元素有多是樹的根節點或者鏈表的首節點,固然,理解上仍是一個鏈表或紅黑樹總體當成桶2.bin:桶中的每一個元素,即紅黑樹中的某個元素或者是鏈表中的某個元素。多線程
除了上邊的名詞,最好還能去理解下哈希表,能夠參考下。HashMap也是對哈希表的一種實現,簡單理解,能夠類比數學中的求餘操做,對範圍進行固定,將大量的數據放入一個有界的範圍中,求餘放置,這種操做算是哈希表的一種實現方式。併發
下面進行源碼部分的說明:app
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
繼承AbstractMap
實現Cloneable接口,提供克隆功能
實現Serializable接口,支持序列化,方便序列化傳輸函數
這裏有個有意思的問題:爲何HashMap繼承了AbstractMap還要實現Map接口?有興趣的能夠去看下stackoverflow上的回答:
https://stackoverflow.com/que...
/** * Node數組的默認長度,16 */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** * Node數組的最大長度,最大擴容長度 */ /** * 默認負載因子 * 這個是幹嗎的呢? * 負載因子是哈希表在自動擴容以前能承受容量的一種尺度。 * 當哈希表的數目超出了負載因子與當前容量的乘積時,則要對該哈希表進行rehash操做(擴容操做)。 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 鏈表轉換爲樹的閾值,超過這個長度的鏈表會被轉換爲紅黑樹, * 固然,不止這一個條件,在下面的源碼部分會看到。 */ static final int TREEIFY_THRESHOLD = 8; /** * 當進行resize操做時,小於這個長度的樹會被轉換爲鏈表 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 鏈表被轉換成樹形的最小容量, * 若是沒有達到這個容量只會執行resize進行擴容 * 能夠理解成一種計算規則 */ static final int MIN_TREEIFY_CAPACITY = 64; /** * * 第一次使用的時候進行初始化,put操做纔會初始化對象 * 調用構造函數時不會初始化,後面源碼可參考 */ transient Node<K,V>[] table; /** * * entrySet保存key和value 用於迭代 */ transient Set<Map.Entry<K,V>> entrySet; /** * * 存放元素的個數,但不等於數組的長度 */ transient int size; /** * * 計數器,fail-fast機制相關,不詳細介紹,有興趣的本身google下 * 你能夠當成一個在高併發讀寫操做時的判斷,舉個例子: * 一個線程A迭代遍歷a,modCount=expectedModCount值爲1,執行過程當中,一個線程B修改了a,modCount=2,線程A在遍歷時判斷了modCount<>expectedModCount,拋錯 * 固然,這個只是簡單的檢查,並不能獲得保證 */ transient int modCount; /** * * 閾值,當實際大小超過閾值(容量*負載因子)的時候,會進行擴容 */ int threshold; /** * * 負載因子 */ final float loadFactor;
在看方法以前先看下Node實現:
/** * Node的實現 * 看出來是最多實現單向鏈表 僅有一個next引用 * 比較簡單明瞭,應該都能看明白 */ 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; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } /** * Map.Entry 判斷類型 * 鍵值對進行比較 判斷是否相等 */ public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
在註釋中我會添加一些標記幫助理清流程,同時方便我後邊總結對照和參考(例如A1,A2是同一級)。
/** * 負載因子設置成默認值 0.75f * A1 */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } /** * 初始數組長度設置,負載因子默認值 * A2 */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** * 初始長度和負載因子設置 * A2 */ 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; // 根據初始容量設置閾值 // 二進制操做,比較繞,須要本身好好理解下 // 這值在resize有用,resize代碼能夠注意下,主要是爲了區分是不是有參構造函數仍是無參構造函數以便以後的操做 // 能夠參考文章:https://www.cnblogs.com/liujinhong/p/6576543.html // 是否有更深層次的考慮筆者還未想到,有大神能夠在評論區告知我 this.threshold = tableSizeFor(initialCapacity); } /** * 將m存入當前map中 */ public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); } /** * evict參數至關於佔位符,是爲了擴展性,能夠追溯到afterNodeInsertion(evict),方法是空的 * 在LinkedHashMap中有實現,有興趣能夠去看看 */ final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); if (s > 0) { /** * 判斷table是否已經被初始化 */ if (table == null) { // pre-size // 未被初始化,判斷m中元素的個數放入當前map中是否會超出最大容量的閾值 float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); // 計算獲得的t大於閾值 閾值設置 if (t > threshold) threshold = tableSizeFor(t); } else if (s > threshold) /** * 當前map已經初始化,而且添加的元素長度大於閾值,須要進行擴容操做 */ resize(); /** * 上邊已經初始化並處理好閾值設置,下面使用entrySet循環putVal保存m中的Node對象的key和value * 這裏有個重要的地方, * putVal的第一個參數,hash(key),map的put操做也是一樣的調用方式 * 能夠參考文章:https://www.cnblogs.com/liujinhong/p/6576543.html * 順便看下源碼上的註釋,主要是減小衝突和性能上的考慮 */ 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); } } } /** * 擴容操做,重點部分 * * 若是第一次帶容量參數時,建立時閾值設置爲對應容量的最小的2的N次方(大於等於傳入容量參數),去看下上邊HashMap(int initialCapacity), * 若是添加一個元素,會執行resize將閾值設置爲了閾值 * 負載因子, * 好比設置1000 建立時閾值threshold=1024,負載因子默認,其餘值都未進行操做, * 添加一個元素 閾值變爲1024 * 0.75 = 768,建立的Node數組長度爲1024,size=1, * 添加第769個元素時,進行resize操做,threshold=1536,Node數組長度爲2048,數組拷貝到新數組中, * 若是有確認的數據長度,不想讓HashMap進行擴容操做,那麼則須要在構造時填上計算好的數組容量 * 強烈建議本身寫代碼debug試試 */ final Node<K,V>[] resize() { //oldTab 保存擴容前的Node數組 Node<K,V>[] oldTab = table; // oldCap null的話即爲0,不然就是擴容前的Node數組的容量大小 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 擴容前的閾值 int oldThr = threshold; // 擴容後的數組容量(長度),擴容後的閾值 int newCap, newThr = 0; // 1.擴容前的數組不爲空 // B1 if (oldCap > 0) { // 擴容前的Node數組容量大於等於設置的最大容量,不會進行擴容,閾值設置爲Integer.MAX_VALUE if (oldCap >= MAXIMUM_CAPACITY) { // C1 threshold = Integer.MAX_VALUE; return oldTab; } // 若是擴容前的數組容量擴大爲2倍依然沒有超過最大容量, // 而且擴容前的Node數組容量大於等於數組的默認容量, // 擴容後的數組容量值爲擴容前的map的容量的2倍,而且擴容後的閾值一樣設置爲擴容前的兩倍, // 反之,則只設置擴容後的容量值爲擴容前的map的容量的2倍 // 這裏newCap已經在條件裏賦值了 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // C2 newThr = oldThr << 1; // double threshold } // 2.擴容前的數組未初始化而且使用了有參構造函數構造 // 這裏在oldCap = 0時執行,這裏oldThr > 0說明初始化時是有參初始化構造的map // 本身能夠試下無參構造函數,threshold的值爲0 // B2 else if (oldThr > 0) // initial capacity was placed in threshold // 使用有參初始化構造函數而且在第一次put操做時會進入執行(去看下put源碼) // 擴容後的容量大小設置爲原有閾值 // 例如我上邊的註釋中的例子,這裏第一次添加鍵值對時容量設置爲了1024 newCap = oldThr; // 3.擴容前的數組未初始化而且使用了無參構造函數構造 // B3 else { // zero initial threshold signifies using defaults // 擴容後的容量 = 默認容量,擴容後的閾值 = 默認容量 * 負載因子 // 擴容後的容量爲16,閾值爲12 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } /** * 上邊設置了新容量和新的閾值,執行到這裏,你應該發現只有newThr可能沒被賦值,因此這裏要繼續進行一個操做,來對newThr進行賦值 * 新閾值等於0,照上邊邏輯: * 兩種狀況: * 1.擴容前的node數組容量有值且擴容後容量超過最大值或者原node數組容量小於默認初始容量16 * 2.使用有參構造函數,第一次put操做時上邊代碼裏沒有設置newThr * D1 */ if (newThr == 0) { // 應該獲得的新閾值ft = 新容量 * 負載因子 float ft = (float)newCap * loadFactor; // 假如新容量小於最大容量而且ft小於最大容量則新的閾值設置爲ft,不然設置成int最大值 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 執行到這,擴容後的容量和閾值都計算完畢 // 閾值設置爲新閾值 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) // 建立擴容後的Node數組 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 切換爲擴容後的Node數組,此時還未進行將舊數組拷貝到新數組 table = newTab; // E1 if (oldTab != null) { // 原有數組不爲空,將原有數組數據拷貝到新數組中 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; // 非空元素才進行賦值 if ((e = oldTab[j]) != null) { // 原有數值引用置空,方便GC oldTab[j] = null; if (e.next == null) // 桶對應的Node只有當前一個節點,鏈表長度爲1 // 中括號中計算原有數組元素在新數組中存放的位置, // 爲何這麼計算? // 正常的想,添加了一個鍵值對,鍵的hash值(固然,這裏在HashMap的hash(key)進行了統一處理) // 那麼長度是有限的,在這個有限長度下如何放置,類比整數取餘操做, // &操做代表只取e.hash的低n位,n是newCap - 1轉換成二進制的有效位數 // 這裏記得初始不設長度時默認16,二進制爲10000,減一爲1111,低4位 // 設置長度時tableSizeFor從新設置了長度和16處理相似 // 經過&操做全部添加的鍵值對都分配到了數組中,固然,分配到數組中同一個位置時會擴展成鏈表或紅黑樹 // 添加詳細操做看後邊putVal源碼,這裏先不用糾結 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) // 到此說明e.next不爲空,那麼須要判斷了, // 由於有兩種結構,一種是鏈表,一種爲紅黑樹 // 這裏先進行紅黑樹處理,樹的具體處理後邊有時間單獨作一章進行說明講解, // 這裏先簡單瞭解,擴容以後,須要對原有的樹進行處理,使得數據分散比較均勻。 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order /** * 到這裏結合HashMap的結構, * 排除上邊兩個條件,這裏就進行鏈表結構的處理 * 進行鏈表複製操做 * 複製的時候就有個問題了,舉個例子,原來我是16,如今擴容成了32(原數組兩倍,我上邊分析裏有說明) * 那麼我複製時怎麼辦? * 不移動原來的鏈表? * 這裏就要想到了我擴容以後訪問的時候不能影響 * 那麼就須要看下put操做時是怎樣存的,這裏先說下,putVal裏也能夠看到 * (n - 1) & hash 和上邊newTab[e.hash & (newCap - 1)] 分析是同樣的 * 這裏不知道你想到了嗎?擴容以後有什麼不一樣? * 若是還沒什麼想法,請繼續往下看,我等下會說明 * 新擴容部分頭尾節點(hi能夠理解成高位)設置爲hiHead,hiTail * 原有部分頭尾節點(lo能夠理解成低位)設置爲loHead,loTail * 這裏什麼意思呢? * 往下看就好,我下面的註釋詳細說明了爲何定義了兩個鏈表頭尾節點 */ Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; // 這裏循環獲取鏈表元素進行處理 do { next = e.next; /** * e.hash & oldCap = 0 * 位與操做,這裏初學者要本身寫下多理解下 * 舉個例子: * oldCap=32=100000(二進制),newCap=64=1000000(二進制) * 在未擴容以前計算元素所處位置是(oldCap-1) & hash * 全1位與操做,取值範圍落在0~oldCap-1 * e.hash & oldCap 只判斷了最高位的那個1位置是否相同 * 相同則非0,不一樣則爲0 * 爲何要判斷這一位呢? * 咱們想想,擴容以後,計算bucket(桶)位置(即元素落在數組那個索引位置)時 * (newCap-1) & hash和(oldCap-1) & hash二者對比,只有一位不一樣 * 好比32和64,最高位是1不一樣,其餘位相同 * 若是擴容以後最高位爲0,則擴容先後獲得的bucket位置相同,不須要調整位置 * 若是非0,則是1,則須要將桶位置調整到更高的索引位置 * 並且這裏也應該明白,同一個bucket下的鏈表(非單一元素)在擴容後 * 由於只有一位二進制不一樣,不是1就是0 * 最多分到兩個bucket中,一個是擴容前的bucket(當前所在的bucket), * 一個是擴容後的bucket(新的bucket), * 這裏也說明了上邊爲何設置了兩組頭尾節點,一組低位鏈表,一組高位鏈表 * 擴容先後兩個bucket位置之間差值爲原數組容量值 * 上邊32和64,差值爲63-31=32=oldCap=10000(二進制) * 因此這下面使用的是oldCap */ if ((e.hash & oldCap) == 0) { // 說明當前Node元素位置 = 原數組中的位置 // 放入loHead,loTail這一組中,低位鏈表 if (loTail == null) // 鏈表還未放元素,鏈表頭賦值 loHead = e; else // 鏈表存在元素,新元素放置在鏈表尾部,next指向新元素 loTail.next = e; // 尾節點指向改變,變成了新添加的節點 loTail = e; } else { // 相似上邊 if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 上面已經處理完了,分紅了高低位兩個鏈表,下面就是將這兩個鏈表放置擴容後的新數組中 if (loTail != null) { // 低位鏈表不爲空,添加到新數組,尾節點next指向置空,由於原有節點可能還存在next指向 loTail.next = null; // 新數組j處就是原有數組j處,這裏直接將低位首節點引用賦值給新數組節點 newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; // 這裏和我上邊註釋分析是一致的,相差的值即爲oldCap,即原數組的容量 newTab[j + oldCap] = hiHead; } } } } } return newTab; } /** * put操做方法主體 * hash,key的hash值,上邊講過,HashMap本身處理過的 * onlyIfAbsent,是否覆蓋原有值,true,不覆蓋原有值 * evict,LinkedHashMap實現afterNodeInsertion方法時調用,這裏至關於佔位符的做用 */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // F1 if ((tab = table) == null || (n = tab.length) == 0) // table爲空或長度爲0時,對table進行初始化,上邊已經分析過了 // 這裏也說明了第一次初始化是在這裏,而不是使用構造方法,排除putMapEntries方式 n = (tab = resize()).length; // 判斷當前須要存儲的鍵值對存放到數組中的位置是否已經存在值(鏈表或者紅黑樹) // 便是否已經有對應key // G1 if ((p = tab[i = (n - 1) & hash]) == null) // 不存在,則建立一個新節點保存 tab[i] = newNode(hash, key, value, null); // G2 else { // 將桶上的值進行匹配,判斷是否存在 Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 鏈表頭節點(或紅黑樹根節點)與當前須要保存的hash值相等 // 而且key值相等,e和p是同一個,說明添加了相同的key // e指向p對應的節點 e = p; else if (p instanceof TreeNode) // 紅黑樹添加節點處理,本文不詳細將紅黑樹部分,後面有空會單獨抽出講解 // 返回值能夠理解成若是有相同key,則返回對應Node,不然返回null(建立了新的Node) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 這裏說明非頭節點(數組中對應的桶的第一個節點),非紅黑樹結構, // 說明須要匹配鏈表,判斷鏈表中對應的key是否已存在 // 設置binCount計算當前桶中bin的數量,即鏈表長度 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { // next 爲空 無下一個元素 再也不繼續查找 直接新建立直接賦值next p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 判斷是否樹化,這裏就是鏈表樹化條件,在treeifyBin還有個數組容量判斷,方法也可能只進行擴容操做 // 總結下,即桶中bin數量大於等於TREEIFY_THRESHOLD=8,數組容量不能小於MIN_TREEIFY_CAPACITY=64時進行樹化轉化 // 怎麼轉成紅黑樹結構這裏也不作深刻,後續會進行說明 treeifyBin(tab, hash); break; } // 不爲空 且節點爲尋找的節點 終止循環 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } // 上邊已經檢查完map中是否存在對應key的Node節點,不存在的新建立節點,這裏處理下存在對應key的節點數據 // H1 if (e != null) { // existing mapping for key // 保存下原來的節點值 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) // onlyIfAbsent 是否須要覆蓋操做,是則覆蓋 e.value = value; // 子類實現方法的話能夠進行對應的後置操做 afterNodeAccess(e); // 返回原值 return oldValue; } } ++modCount; // 實際元素長度,不是容量,是每次添加一個新的鍵值對會加1,覆蓋不增長 // 判斷是否大於閾值,進行擴容操做 // I1 if (++size > threshold) resize(); // 同afterNodeAccess,子類實現方法的話能夠進行對應的後置操做 afterNodeInsertion(evict); return null; }
重點的部分也就是在上面這幾個方法,剩下的源碼部分就不一一貼出來分析了,能看懂我上面說明的部分,基本上除了紅黑樹和jdk1.8的新特性相關部分,其他部分應該基本都能看懂,這裏再補充一個序列化方面的問題:
爲何HashMap中的table變量要設置爲transient?在理解這個問題以前,自行去看下序列化代碼writeObject和readObject,而後參考如下連接來思考:
https://segmentfault.com/q/10...
HashMap中,因爲Entry的存放位置是根據Key的Hash值來計算,而後存放到數組中的,對於同一個Key,在不一樣的JVM實現中計算得出的Hash值多是不一樣的。這裏不一樣意思是說我原來在window機器上A是放在Node數組中0的位置,在Mac上多是放在Node數組中5的位置,可是不修改的話,反序列化以後Mac上也是0的位置,這樣致使後續增長節點會錯亂,不是咱們想要的結果,故在序列化中HashMap對每一個鍵值對的鍵和值序列化,而不是總體,反序列化一個一個取出來,不會形成位置錯亂。
那麼JDK1.8中HashMap在多線程環境下會形成死循環嗎?
從上邊結構以及處理過程的分析來看,應該是不會的,只不過數據丟失仍是會發生,這一塊我就不進行驗證了,自行Google,手寫代碼來驗證。同時想多說句,對於通常開發人員知道HashMap是非線程安全的,多線程狀況下使用ConcurrentHashMap便可,後邊有時間ConcurrentHashMap的分析我也會整理出來。
在重點說明部分我已經詳細解釋了resize和put操做的過程,可能有些新人仍是不能梳理清楚,我在這裏結合下平常使用總結下整個過程,方便各位理解:
1.HashMap建立過程(正常狀態):
2.HashMap resize過程(正常狀態):
3.HashMap put過程(正常狀態):
HashMap首先須要理解清楚其內部的實現結構:數組+鏈表+紅黑樹
,在結構的基礎之上來對源碼進行深刻,resize和put操做是最爲重要的兩部分,理解了這兩塊,基本上對HashMap的總體處理過程有了必定的認知,另外,必定要本身動手debug,理清數據的轉換,對了解HashMap有很大的幫助。
文章先從基礎部分提及,解釋了一些名詞,說起了哈希表,從實現結構開始來幫助各位更好的理解源碼操做部分,對重點的幾個部分作出詳細的說明,resize和put操做難點部分也作了相應的解釋,但願對各位有所幫助,後邊有空我會將紅黑樹部分的理解分享出來,謝謝。