HashMap分析html
這篇文章,分析一下面試中常常會被問到的數據結構——HashMap。node
HashMap是啥git
你們都知道HashMap是基於key-value機制存儲數據的,那麼是否有思考過底層是怎樣的數據結構從而能夠支持這種存儲機制呢?github
上圖,以便看清楚HashMap的數據結構:面試
咱們把這張圖分紅兩部分來看:數組
1.首先是左邊豎着的一個個矩形框(也被稱爲桶,專業術語叫Bucket),其實這是一個數組(Table),每個矩形框對應的就是數組中的一個索引位置,而每一個索引位置處存放的就是該索引位置處對應鏈表的頭節點(Entry,亦即Node);數據結構
2.每一個Bucket都有本身所對應的單向鏈表,鏈表的每一個節點其實就是一個 key-value鍵值對。多線程
咱們再看一張圖:函數
這張圖和上面那張有什麼區別呢?能夠看到在圖中倒數第2個Bucket中,節點並非以鏈表形式存儲,而是以紅黑樹的形式進行了存儲!源碼分析
上面圖片是JDK1.7的HashMap存儲結構,而下面這張圖是JDK1.8的HashMap存儲結構。
1.8中最大的變化就是在一個Bucket中,若是存儲節點的數量超過了8個,就會將該Bucket中原來以鏈表形式存儲節點轉換爲以樹的形式存儲節點;而若是少於6個,就會還原成鏈表形式存儲。
爲何要這樣作?前面已經說過LinkedList的遍歷操做不太友好,若是在節點個數比較多的狀況下性能會比較差,而樹的遍歷效率是比較好的,主要是優化遍歷,提高性能。
接着咱們看下源碼中和數據結構相關的部分:
1 //初始容量(桶的數量),默認爲16 2 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 3 4 //最大支持容量,2^30 5 static final int MAXIMUM_CAPACITY = 1 << 30; 6 7 //負載因子,默認0.75 8 static final float DEFAULT_LOAD_FACTOR = 0.75f; 9 10 //當一個桶中的entry數量大於8時,就知足了鏈表轉化爲樹結構存儲的其中一個條件 11 static final int TREEIFY_THRESHOLD = 8; 12 13 //當一個桶中的entry數量小於6時,將這個桶中的鍵值對轉化爲桶+鏈表的結構存儲 14 static final int UNTREEIFY_THRESHOLD = 6; 15 16 //桶的數量大於64個,鏈表轉化爲樹結構存儲的另外一個條件 17 static final int MIN_TREEIFY_CAPACITY = 64; 18 19 //存放桶的數組 20 transient HashMap.Node<K,V>[] table; 21 22 //map中實際存儲鍵值對的數量 23 transient int size; 24 25 //rehash操做的臨界值 26 int threshold; 27 28 //標準單向鏈表的節點構成 29 static class Node<K,V> implements Map.Entry<K,V> { 30 final int hash; //結點哈希值,和Bucket對應的index相同 31 final K key; 32 V value; 33 HashMap.Node<K,V> next; //指向下一個結點 34 35 Node(int hash, K key, V value, HashMap.Node<K,V> next) { 36 this.hash = hash; 37 this.key = key; 38 this.value = value; 39 this.next = next; 40 } 41 42 public final K getKey() { return key; } 43 public final V getValue() { return value; } 44 public final String toString() { return key + "=" + value; } 45 46 //計算節點hashCode的方法 47 public final int hashCode() { 48 //直接調用object類的hashcode()方法 49 //key和value的hashcode按位異或,不一樣爲真 50 //注意:一個數a兩次對同一個值b進行異或操做獲得的仍是它自己 51 return Objects.hashCode(key) ^ Objects.hashCode(value); 52 } 53 54 public final V setValue(V newValue) { 55 V oldValue = value; 56 value = newValue; 57 return oldValue; 58 } 59 60 //判斷兩個節點對象是否相同 61 public final boolean equals(Object o) { 62 if (o == this) //內存地址相同,直接返回true 63 return true; 64 if (o instanceof Map.Entry) { //key和value都相同才返回true 65 Map.Entry<?,?> e = (Map.Entry<?,?>)o; 66 if (Objects.equals(key, e.getKey()) && 67 Objects.equals(value, e.getValue())) 68 return true; 69 } 70 return false; 71 } 72 }
對其中較重要的屬性(常量)解釋一下:
1.capacity:指的是桶的數量,代碼第二行中能夠看到定義了 DEFAULT_INITIAL_CAPACITY = 1 << 4,這個意思是若是在初始化HashMap時沒有指定桶的數量,則默認桶的數量爲16。
2.loadFactor:負載因子,默認爲0.75, 影響hashMap最多能夠裝多少個節點的條件之一。
3.threshold: 這個變量的值是經過 capacity * loadFactor 計算得出的,它指的是當前map中可以存放節點的最大值,若是超過這個值,就須要作rehash操做。
而且咱們能夠看到這裏節點的定義和LinkedList的節點定義不同,這裏的節點由4部分組成:
1.hash:指的是這個節點應該存放在哪一個Bucket中,它的值和對應存放的Bucket的index是相同的。
2.key:節點對應的鍵。
3.value:節點對應的值。
4.next:指向後一個節點的引用,能夠看出這裏的鏈表是單向鏈表,遍歷時就只能從頭節點開始日後遍歷,這點和LinekdList不一樣。
HashMap核心源碼分析(JDK Version 1.8)
1.構造函數
1 /** 2 * 無參構造方法,只設置了默認的負載因子 3 */ 4 public HashMap() { 5 this.loadFactor = DEFAULT_LOAD_FACTOR; 6 } 7 8 /** 9 * 帶初始容量參數的構造方法 10 * @param initialCapacity 自定義table初始容量 11 */ 12 public HashMap(int initialCapacity) { 13 this(initialCapacity, DEFAULT_LOAD_FACTOR); 14 } 15 16 /** 17 *能夠自定義初始容量和負載因子的構造方法 18 * 19 * @param initialCapacity 初始容量 20 * @param loadFactor 負載因子 21 * @throws IllegalArgumentException 傳入值邊界檢查異常 22 */ 23 public HashMap(int initialCapacity, float loadFactor) { 24 if (initialCapacity < 0) 25 throw new IllegalArgumentException("Illegal initial capacity: " + 26 initialCapacity); 27 if (initialCapacity > MAXIMUM_CAPACITY) 28 initialCapacity = MAXIMUM_CAPACITY; 29 if (loadFactor <= 0 || Float.isNaN(loadFactor)) 30 throw new IllegalArgumentException("Illegal load factor: " + 31 loadFactor); 32 this.loadFactor = loadFactor; 33 this.threshold = tableSizeFor(initialCapacity); 34 } 35 36 //返回大於所傳入的自定義容量的最小2的整數冪(好比傳入13,則返回16) 37 static final int tableSizeFor(int cap) { 38 int n = cap - 1; 39 n |= n >>> 1; 40 n |= n >>> 2; 41 n |= n >>> 4; 42 n |= n >>> 8; 43 n |= n >>> 16; 44 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 45 }
一些重載的構造函數,主要保證capacity和loadFactor都有初始值以免後續rehash出錯。
這裏主要看下第37行的 tableSizeFor(int cap)這個方法,該方法會保證hashMap的初始桶數量一定爲2的整數冪(好比1六、3二、6四、128........), 爲啥要這麼作呢?
初始桶數量默認設置16,是爲了不桶數量太少而頻繁引起rehash操做形成沒必要要的性能開銷(rehash操做默認會將當前桶的數量擴大一倍)。
而對於計算機來講它底層的計算是基於二進制的(好比16就是 00010000),此時rehash操做會將桶數量變動爲32(對應二進制爲 00100000)
能夠看到這種狀況下每次rehash操做對於計算機來講就是將1向左邊移動一位,效率很是高。
可是試想一下若初始化時桶的數量不是2的整數冪,好比22,對應的二進制爲 00010110,
此時rehash操做會將桶數量變動爲44,對應的二進制爲 00101100,改變了四個數字,從效率上來講確定是不如上面2的整數冪的狀況。
2.經過key查找對應節點的方法
1 /** 2 * 對外封裝的get方法,實際調用的是getNode()這個方法 3 */ 4 public V get(Object key) { 5 Node<K,V> e; 6 return (e = getNode(hash(key), key)) == null ? null : e.value; 7 } 8 9 /** 10 * 根據key對象獲取結點 11 * 12 * @param hash key對象的哈希值 13 * @param key key對象 14 * @return 若是存在該key對應節點,則返回節點;不然返回null 15 */ 16 final HashMap.Node<K,V> getNode(int hash, Object key) { 17 HashMap.Node<K,V>[] tab; //Bucket數組 18 HashMap.Node<K,V> first, e; //first是鏈表頭節點,e爲遍歷鏈表時每次循環到的當前節點 19 int n; //Bucket數組大小 20 K k; //遍歷鏈表時每次循環到的當前節點對應的key 21 if ((tab = table) != null && (n = tab.length) > 0 && 22 (first = tab[(n - 1) & hash]) != null) { //若是桶數組已經初始化且初始大小>0,則根據傳入key的hash尋找其應該存放的桶裏存儲鏈表的頭節點,並賦值給first變量 23 if (first.hash == hash && // 頭節點存在,老是先檢查第一個結點 24 ((k = first.key) == key || (key != null && key.equals(k)))) 25 return first; //若是頭節點和所需查找節點的hash值相同而且key也相同,則直接返回頭節點(要找的結點就是第一個結點,一次命中) 26 if ((e = first.next) != null) { //若是該桶中第一個結點還有後續結點,則繼續遍歷查找,不然返回null 27 if (first instanceof TreeNode) //若是該桶中節點已是紅黑樹形式存儲,則直接調用樹結點的get方法 28 return ((TreeNode<K,V>)first).getTreeNode(hash, key); 29 do { //若是仍是鏈表方式存儲,則遍歷鏈表,如有找到hash相同而且key也相同的結點,那麼就是須要找的結點,返回該結點;沒找到則返回null 30 if (e.hash == hash && 31 ((k = e.key) == key || (key != null && key.equals(k)))) //key有多是基本數據類型,也有多是對象 32 return e; 33 } while ((e = e.next) != null); 34 } 35 } 36 return null; 37 }
查找節點的過程就再也不多說了,註釋已經寫的很詳細了,只須要注意一下JDK1.8新增了對樹存儲結構的相關操做(第2七、28行)。
可是這裏須要注意一點,看下第22行代碼,在計算當前查找的節點應該存放在哪一個bucket時調用了這行代碼 (n - 1) & hash,而這裏的hash是經過以下方法計算獲得的:
1 //每一個Node計算hash的方法,返回的是最終放的桶的index 2 static final int hash(Object key) { //注意key爲null時,默認是放在0桶位的 3 int h; 4 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); 5 }
能夠看到這裏計算key對應的hash值時,先調用了當前key的hashCode()方法,而後再和該值向右移動16位獲得的值進行異或操做,爲啥要這麼作呢?爲何不直接使用key的hashcode去和 (n-1)作&操做呢?
在n-1很小的狀況下,其二進制串只有末幾位是有效的,若是直接使用key的hashCode對應的32bit二進制串去和n-1對應的二進制串去作&操做,其實最終只有末幾位參與了運算。
這樣致使的結果就是會形成大量的碰撞(即大量的節點可能存放於同一個Bucket中,形成分佈不均),這樣不只會形成內存空間的浪費,並且遍歷的效率不好。
而 (h = key.hashCode()) ^ (h >>> 16) 這行代碼的做用就是先將key的hashCode對應的32bit的二進制串,對其高16bit和低16bit進行一次異或操做,而後再和 n-1 對應的二進制串去作&操做。
這麼作的好處就是不管你key的hashCode是怎樣的,都會先對hashCode的二進制串作一次異或操做,至少保證了高16bit的二進制數字參與了運算,下降了最終的碰撞機率。
固然,上面也提到過,JDK1.8中若是一個Bucket中存放的節點超過8個就會轉換爲樹的存儲結構,經過這兩種方法的結合下降了碰撞機率,同時保證了碰撞較多時結點的遍歷效率。
放一張圖片,便於理解(假設此時Bucket數組容量爲16):
仍是以爲比較難理解的童鞋,能夠參考下方連接中做者寫的文章:
http://yikun.github.io/2015/04/01/Java-HashMap工做原理及實現/
3.更新/插入節點的方法
1 /** 2 * 對外封裝的put方法,實際調用putVal方法 3 */ 4 public V put(K key, V value) { 5 return putVal(hash(key), key, value, false, true); 6 } 7 8 /** 9 * 根據傳入的key和value添加/設置值 10 * 若原先table中已存在該key對象對應的鍵值對,則將其值更改成新的value 11 * 若原先table中不存在該key對象對應鍵值對,則計算其所需放入的bucket位置,將其放入 12 * 13 * @param hash key的hash值 14 * @param key key對象 15 * @param value 新的value 16 * @param onlyIfAbsent 若是爲true,則不改變已存在鍵值對的值(默認爲false) 17 * @param evict 若是爲false,則table處於creation模式 18 * @return 以前有鍵值對存在,則返回以前該key對應的value;不然返回null 19 */ 20 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 21 boolean evict) { 22 HashMap.Node<K,V>[] tab; //Bucket數組 23 HashMap.Node<K,V> p; //某個Bucket的首結點 24 int n, i; 25 if ((tab = table) == null || (n = tab.length) == 0) //若是bucket數組尚未初始化,先調用resize()方法初始化table 26 n = (tab = resize()).length; //記錄下初始化完成後此時table的大小(桶的數量) 27 if ((p = tab[i = (n - 1) & hash]) == null) //根據hash值計算出該鍵值對應該放的桶位置,若此時該位置尚未結點存在 28 tab[i] = newNode(hash, key, value, null); //直接將該鍵值對設置爲該桶的第一個結點 29 else { //執行到這裏,說明發生碰撞,即tab[i]不爲空,須要組成單鏈表或紅黑樹 30 HashMap.Node<K,V> e; 31 K k; 32 if (p.hash == hash && 33 ((k = p.key) == key || (key != null && key.equals(k)))) 34 e = p; //發現該位置處第一個結點的key就是新傳入鍵值對的key,則將該結點記錄下來 35 else if (p instanceof TreeNode) //若是桶中首結點已是以紅黑樹結構存儲 36 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //調用樹的put()方法 37 else { //桶中結點依舊仍是鏈表存儲 38 for (int binCount = 0; ; ++binCount) { //遍歷鏈表,binCount記錄已遍歷結點個數 39 if ((e = p.next) == null) { //若是當前遍歷到的結點爲空,說明以前遍歷過的結點沒有和當前傳入鍵值對key相同的結點,須要新增該結點 40 p.next = newNode(hash, key, value, null); //新增結點 41 if (binCount >= TREEIFY_THRESHOLD - 1) //若是新增該結點後,當前桶中的結點個數大於樹化的界定值(8) 42 treeifyBin(tab, hash); //轉爲紅黑樹結構存儲 43 break; 44 } 45 if (e.hash == hash && //若是遍歷過程當中在桶中找到了和當前傳入鍵值對key相同的結點,則將該結點記錄下來 46 ((k = e.key) == key || (key != null && key.equals(k)))) 47 break; 48 p = e; 49 } 50 } 51 if (e != null) { //若是以前在鏈表或樹中找到了和當前傳入鍵值對key相同的結點,則將值進行替換 52 V oldValue = e.value; 53 if (!onlyIfAbsent || oldValue == null) 54 e.value = value; 55 afterNodeAccess(e); //回調LinkedHashMap本身的方法,用於新增LinkedHashMap的雙向鏈表中節點之間的先後關係 56 return oldValue; //返回原先該結點的value 57 } 58 } 59 ++modCount; 60 if (++size > threshold) //總結點數+1,若是插入該結點後map總結點個數大於闕值,則須要擴容和rehash 61 resize(); 62 afterNodeInsertion(evict); 63 return null; 64 }
沒啥好講的,跟着源碼走一遍應該就清楚流程了。
4.刪除節點的方法
1 /** 2 * 對外封裝的刪除方法,實際調用removeNode方法 3 */ 4 public V remove(Object key) { 5 Node<K,V> e; 6 return (e = removeNode(hash(key), key, null, false, true)) == null ? 7 null : e.value; 8 } 9 10 /** 11 * 根據傳入的key值(必要時可增長value值判斷)刪除結點 12 * 13 * @param hash key的hash值 14 * @param key key對象 15 * @param value 須要判斷value時才傳入(默認爲null) 16 * @param matchValue 若是爲true,則必須key和value同時和傳入結點相同纔會被刪除(默認爲false) 17 * @param movable 若是爲false,刪除時不移動其餘結點(默認爲true) 18 * @return 被刪除結點,或沒找到要刪除結點則返回null 19 */ 20 final HashMap.Node<K,V> removeNode(int hash, Object key, Object value, 21 boolean matchValue, boolean movable) { 22 HashMap.Node<K,V>[] tab; 23 HashMap.Node<K,V> p; 24 int n, index; 25 if ((tab = table) != null && (n = tab.length) > 0 && 26 (p = tab[index = (n - 1) & hash]) != null) { //若是table已經初始化,大小不爲0,且根據key的hash計算桶的位置處已有結點存在 27 HashMap.Node<K,V> node = null, e; 28 K k; 29 V v; 30 if (p.hash == hash && 31 ((k = p.key) == key || (key != null && key.equals(k)))) //若是要刪除的結點就是該桶中鏈表的第一個結點 32 node = p; //將該結點保存下來 33 else if ((e = p.next) != null) { //若是第一個結點不是要刪除的結點,則須要先遍歷查找到該結點,再刪除 34 if (p instanceof TreeNode) //若是該桶中頭結點是樹結點,說明已經是樹存儲結構 35 node = ((TreeNode<K,V>)p).getTreeNode(hash, key); //需調用樹的get()方法查找和當前需刪除結點key相同的結點 36 else { //不然依舊仍是鏈表存儲,遍歷鏈表 37 do { 38 if (e.hash == hash && 39 ((k = e.key) == key || 40 (key != null && key.equals(k)))) { //若是找到和要刪除結點的key相同的結點 41 node = e; //保存下來 42 break; 43 } 44 p = e; 45 } while ((e = e.next) != null); 46 } 47 } 48 if (node != null && (!matchValue || (v = node.value) == value || 49 (value != null && value.equals(v)))) { //若是以前在桶中找到了和當前傳入鍵值對key相同的結點 50 if (node instanceof TreeNode) //以前找到的結點是樹結點 51 ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); //調用樹的remove()方法 52 else if (node == p) //以前找到的結點是桶中鏈表的頭結點 53 tab[index] = node.next; //將桶的頭結點刪除,並將下一個結點設置爲頭結點 54 else //以前找到的結點是鏈表中其中一個結點(非頭節點) 55 p.next = node.next; //刪除該結點,並將前一個結點後驅指向被刪除結點的下一個結點 56 ++modCount; 57 --size; //map總結點個數減1 58 afterNodeRemoval(node); //回調LinkedHashMap的方法,用於刪除LinkedHashMap雙向鏈表中節點之間的先後關係 59 return node; //返回被刪除的結點 60 } 61 } 62 return null; 63 }
一樣,跟着源碼走一遍就清楚流程了,主要分爲查找節點和刪除節點兩步,每步又分爲頭節點、非頭節點(樹/鏈表)幾種狀況的實現。
5.HashMap核心方法——擴容及rehash方法
1 /** 2 * 核心方法:擴容以及rehash操做. 3 * 初值爲空時,則根據初始容量開闢空間來建立數組. 4 * 不然, 由於咱們使用2的冪定義數組大小,數據要麼待在原來的下標, 要麼移動到新數組的高位下標 5 * 好比初始容量爲16,原來有兩個數據在index爲1的桶中;resize後容量爲32,原來那兩個數據既能夠在index爲1的桶,也能夠在index爲17的桶中 6 * 7 * @return 擴容後的新數組 8 */ 9 final HashMap.Node<K,V>[] resize() { 10 HashMap.Node<K,V>[] oldTab = table; //記錄原數組 11 int oldCap = (oldTab == null) ? 0 : oldTab.length; //記錄原數組Capacity 12 int oldThr = threshold; //記錄原數組的rehash闕值 13 int newCap, newThr = 0; //新數組capacity,rehash闕值 14 if (oldCap > 0) { //若是原數組capacity>0 15 if (oldCap >= MAXIMUM_CAPACITY) { //原數組capacity已經達到容量最大值,沒法再擴容 16 threshold = Integer.MAX_VALUE; //闕值設置爲最大值 17 return oldTab; //直接返回原數組 18 } 19 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 20 oldCap >= DEFAULT_INITIAL_CAPACITY) //新數組capacity爲原數組兩倍 21 newThr = oldThr << 1; //若是此時數組大小在16至最大值之間,新闕值也變爲原先的兩倍 22 } 23 else if (oldThr > 0) // 若是原闕值>0,說明調用HashMap構造方法(帶闕值參數的那個)時只設置了闕值大小但沒有設置capacity 24 newCap = oldThr; //將闕值大小直接賦值給新數組的capacity 25 else { //若是直接調用HashMap無參構造方法,則初始capacity和闕值都沒有被設置,此處給它設置上 26 newCap = DEFAULT_INITIAL_CAPACITY; //初始capacity默認爲16 27 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //闕值默認爲16*0.75=12 28 } 29 if (newThr == 0) { //若是新闕值爲0,從新設置闕值,防止意外狀況 30 float ft = (float)newCap * loadFactor; //用新capacity * 當前負載因子獲得計算結果 31 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? 32 (int)ft : Integer.MAX_VALUE);//若新容量和該計算結果都未達到最大值,則新闕值就是該計算結果;不然新闕值爲int最大值 33 } 34 threshold = newThr; 35 36 HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap]; 37 table = newTab; //用新容量初始化新數組 38 if (oldTab != null) { //若舊數組已經被初始化,須要對原先存放的結點進行rehash操做 39 for (int j = 0; j < oldCap; ++j) { //遍歷舊數組每一個桶,對桶中每個結點判斷應放入高位仍是低位,並放入新數組對應的桶中 40 HashMap.Node<K,V> e; 41 if ((e = oldTab[j]) != null) { //若是當前遍歷到的桶中第一個結點不爲空,才繼續往下走,不然直接進行下一個桶的遍歷 42 oldTab[j] = null; //將該桶的首結點置空 43 if (e.next == null) //若是該結點沒有後續結點,即該桶中只有這一個結點 44 newTab[e.hash & (newCap - 1)] = e; //將該結點從新計算index後,放入新數組的桶中 45 else if (e instanceof TreeNode) //若是該結點有後續結點,且該結點已是樹存儲 46 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //則直接調用樹的split方法 47 else { //若是該結點有後續結點,且該桶中結點存儲方式仍是鏈表存儲 48 HashMap.Node<K,V> loHead = null, loTail = null; 49 HashMap.Node<K,V> hiHead = null, hiTail = null; 50 HashMap.Node<K,V> next; 51 do { 52 next = e.next; 53 //e.hash & oldCap 將該鏈表的全部結點均勻分散爲新數組低位和高位兩個位置 54 if ((e.hash & oldCap) == 0) { // 若是被分到低位,則在新數組中的桶位置和原先的桶是同樣的 55 if (loTail == null) //若是新數組中低位桶尾結點爲空,說明該桶當前尚未結點 56 loHead = e; //則將當前鏈表中遍歷到的結點保存至loHead變量中,待遍歷完一整條鏈表後,新數組低位桶設置頭節點使用 57 else //若是新數組中低位桶尾結點不爲空 58 loTail.next = e; //則將當前鏈表中遍歷到的結點添加到新數組中該低位桶的鏈表尾部 59 loTail = e; //新數組中該低位桶的鏈表尾結點設置爲該結點 60 } 61 else { //若是被分到高位,則在新數組中的桶位置爲原先所在桶位置+原先桶的capacity 62 if (hiTail == null) //若是新數組中高位桶尾結點爲空,說明該桶當前尚未結點 63 hiHead = e; //則將當前鏈表中遍歷到的結點保存至hiHead變量中,待遍歷完一整條鏈表後,新數組高位桶設置頭節點使用 64 else //若是新數組中高位桶尾結點不爲空 65 hiTail.next = e; //則將當前鏈表中遍歷到的結點添加到新數組中該高位桶的鏈表尾部 66 hiTail = e; //新數組中該高位桶的鏈表尾結點設置爲該結點 67 } 68 } while ((e = next) != null); 69 if (loTail != null) { //若是新數組低位桶的尾結點非空 70 loTail.next = null; //則將其下一個結點設置爲null,方便後續結點插入 71 newTab[j] = loHead; //並將新數組中低位桶頭結點設置爲loHead中保存的結點 72 } 73 if (hiTail != null) { //若是新數組高位桶的尾結點非空 74 hiTail.next = null; //則將其下一個結點設置爲null,方便後續結點插入 75 newTab[j + oldCap] = hiHead; //並將新數組中高位桶頭結點設置爲hiHead中保存的結點 76 } 77 } 78 } 79 } 80 } 81 return newTab; 82 }
這個方法是HashMap核心的方法了,以35行爲分界線分紅上下兩部分看。
35行以前的代碼主要就是將Bucket數組容量擴大一倍,並設置新的闕值,沒啥好講的。
重點是35行以後的代碼,這部分代碼是在依次遍歷原Bucket數組的每一條鏈表,並根據必定的規則從新計算鏈表中每一個節點新的hash值,以便定位應該放在新Bucket數組中的哪一個位置。
在JDK1.8以前,每條鏈表的每一個節點都會從新再計算一遍hash值,以確認在新Bucket數組中存放位置;
而在JDK1.8及以後,再也不從新對每一個結點計算新hash值,如54行代碼所示,直接用該節點原先的hash值的二進制串與原Bucket數組容量的二進制串進行&操做以確認節點rehash後應該放在新數組的位置,爲啥要這麼作?
首先要明確兩點:
1.HashMap的Bucket數組默認初始容量爲16,而且每次擴容後的大小都爲2的冪,而且對於計算機來講每次擴容其實就是將二進制串中的1往左移動一位。
2.&操做的特性是 兩個二進制串相同bit位 都是1結果才爲1,其餘狀況結果均爲0。
明確以上兩點後,再解釋一下這麼作的緣由:
每次擴容,1會在二進制串中向左移動一位,而對於原先節點來講每一個節點的二進制串在該bit位是0或1的可能性是隨機的
那麼此時只須要對兩個二進制串中該bit位作&操做便可,而&操做的結果可能有兩個:0或1
因此代碼中定義了當&操做結果爲0時,表示當前節點在新Bucket數組中的位置和原Bucket數組相同(也就是註釋中說的低位桶);
而&操做結果爲1時,表示當前節點在新Bucket數組中的位置是 原bucket數組索引 + 原Bucket容量處(也就是註釋中說的高位桶)。
由於每一個結點對應的二進制串每一個bit位爲0或1的可能性是隨機的,因此最後被分配到新數組是在低位仍是高位也能夠認爲是隨機的。
這種作法巧妙的利用了HashMap自身以及&操做的特性,避免了JDK1.8之前每次rehash時每一個節點都要從新計算hash值形成的大量性能消耗。
一樣放幾張圖,以便你們結合着理解:
仍是理解不了的童鞋,請打開上方引用的連接,做者在他的文章中也作了詳細的解釋 :)
注: 圖片均來自於連接中做者的文章,非本人原創!
HashMap存在的較隱蔽的一個問題
HashMap在作rehash操做的時候會發生死循環從而形成CPU100%,這個問題只會在多線程環境下使用HashMap進行操做時纔可能會出現。
而致使這個問題的主要緣由是多線程環境下若是不使用鎖,將沒法保證線程的執行順序,那麼就可能出現一個線程執行期間被另外一個線程搶佔到執行資源從而讓出執行權。
而後就可能出現這樣的狀況 線程A中將節點之間的引用變爲 E1 -> E2, 而線程B中將節點之間的引用變爲 E2 -> E1, 此時其實就造成了一個閉環,兩個節點相互引用。
若是此時再執行了 get/put 操做,由於都須要先遍歷鏈表,若是遍歷到E1,E2這兩個節點,那麼就會無限循環,最終致使的結果就是死循環。
若想深刻了解該問題,可參考下方連接中的文章:
https://www.cnblogs.com/dongguacai/p/5599100.html
固然這個問題的解決方案也很簡單,在多線程場景下請使用 ConCurrentHashMap 進行操做(這個類會在後續多線程相關的文章中進行分析)。
好了,到這裏HashMap的分析就差很少了,礙於篇幅緣由這裏並無介紹轉換爲樹存儲後的一系列相關方法,感興趣的童鞋能夠本身跟一下源碼 : )
下篇文章會分析LinkedHashMap的相關源碼。