1. 程式人生 > >Jdk1.8下的HashMap原始碼分析

Jdk1.8下的HashMap原始碼分析

**目錄結構** 一、面試常見問題 二、基本常量屬性 三、構造方法 四、節點結構        4.1 Node類        4.2.TreeNode 五、put方法        5.1 key的hash方法        5.2 resize() 擴容方法 六、get方法 ##### 一、面試常見問題 Q1: HashMap底層資料結構是什麼? ``` jdk1.7是陣列+連結串列; jdk1.8 採用陣列+ 連結串列+紅黑樹 ``` Q2:為什麼要引入紅黑樹,有什麼優勢,解決什麼問題? ``` 紅黑樹的引入是為了提高查詢效率,雜湊衝突可以減少(元素key均勻雜湊程度和通過擴容),不可避免, 如果衝突元素較多,會導致連結串列過長,而連結串列查詢效率是O(n), 當連結串列長度超過8後,且陣列長度(length)超過64才轉化為紅黑樹, 這點易忽略(後原始碼證明), 不是連結串列長度一超高8個就將其樹化為紅黑樹,此時擴容解決衝突效果更好。(注:紅黑樹的特性需要了解掌握) ``` Q3: 在一條連結串列上新增節點,什麼方式插入? ``` 1.8 是尾插法,1.7是採用頭插法,為什麼1.7需要頭插法?頭插法效率高? ``` Q4:放入HashMap中元素需要什麼特性? ``` 需要重寫hashCode和 equals()方法 ,不重寫會有什麼問題? ``` Q5: HashMap是執行緒安全的嗎?你列舉其他執行緒安全的,特性? ``` 不是, 因為它的方法沒有同步鎖,HashTable 是執行緒安全的,每個方法有加synhronized關鍵字,執行緒安全,但也會導致效率變低; 一般多執行緒環境使用 ConcurrentHashMap,其原理是採用了分段鎖機制,每一段(Segment)中是一個HashMap,每個Segment中操作是加鎖的, 即保證執行緒操作某一個Segment是排他的,但不同執行緒在不同Segment是可以同時操作的,即保證了執行緒安全,又提高了併發效率。 (此處不詳細展開ConcurrentHashMap,面試問題是環環相套的,好的面試官會步步引導,目的是為了全面考察面試者的技術水平)。 ``` Q6: 1.7中 HashMap存在什麼問題? ``` 在多執行緒環境下,1.7中Map可能會導致cpu使用率過高,是因為存在環形連結串列了,HashMap中 連結串列是單鏈表結構,怎麼會有環? 是連結串列中元素指向下一個元素的指標next,指向了前面的元素,導致了環,所以在遍歷連結串列時, 程式一直死迴圈無法結束。在多個執行緒放置元素時,resize()方法中導致(後原始碼證明)。 ``` Q7: 1.8是先插入新值再判斷是否擴容,還是先擴容在插入新值? ``` 1.8是先插入元素,在判斷容量是否超過閾值,擴容,1.7是先擴容再插入新值 ``` Q8: HashMap是延遲初始化? ``` 是的,建立的Map物件,如果有指定容量大小,會記錄下來;沒有指定會使用預設16,在第一次put元素時才初始化陣列,1.7,1.8都是如此,可以節省記憶體空間。 ``` Q9: HashMap允許放置空鍵(null),空值(null)嗎? ``` 允許放置,null 鍵是放置在陣列第一個位置的,因此在判斷某個key是否存在時,不能通過該get() 方法獲取value為null判斷, 這時鍵值對可能是 null:null,此時是存在Node物件的,可以通過containsKey(key)判斷 ,key為null,Node物件不為null,如下圖1所示 ``` ![](https://img2020.cnblogs.com/blog/1458219/202008/1458219-20200810185528307-151182280.jpg) 對比HashTable ,**HashTable 不允許 null鍵,null值**. Q10 :簡要講述一下HashMap放置元素過程,即put()方法。 put方法流程如下圖2所示 ![](https://img2020.cnblogs.com/blog/1458219/202008/1458219-20200810185642826-1224960365.png) ##### 二、基本常量屬性 ```java //預設初始容量 16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /** *最大容量,如果在建構函式中指定大於該值,會使用該值作為容量大小,2^30 */ static final int MAXIMUM_CAPACITY = 1 << 30; /** * 預設載入因子75%,好比地鐵滿載率,受疫情影響地鐵滿載率不超過30% * 1,載入因子設定較大,隨著陣列中元素裝的越多,發生的衝突的概率越大,即對應的連結串列 * 越長,影響查詢效率。 * 2,載入因子設定較小,元素很容易達到設定的閾值,發生擴容操作,陣列空間還有很大部分 * 沒有利用上,造成空間浪費。 * 因此在時間與空間上的權衡考慮 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; /** * 樹化閾值:在連結串列元素超過8個了,將連結串列轉化為紅黑數結果,提高查詢效率 */ static final int TREEIFY_THRESHOLD = 8; /** * 取消樹化閾值:在紅黑樹中節點數量小於6個,將其轉化為連結串列結構 */ static final int UNTREEIFY_THRESHOLD = 6; /** * 最小樹化容量,容易忽視 * 連結串列樹化紅黑樹條件: * 1,連結串列中元素數量超過(>)8個; * 2, 滿足map中元素個數(size)大於等於64否則會先擴容,擴容對解決hash衝突更有效。 */ static final int MIN_TREEIFY_CAPACITY = 64; ``` ##### 三、構造方法 ```java //傳有初始容量和載入因子 1.HashMap(int initialCapacity, float loadFactor) //有初始容量,載入因子預設 2.HashMap(int initialCapacity) //初始容量, 載入因子預設 3.HashMap() //傳遞的一個Map子集 4.HashMap(Map m) ``` ##### 四、節點結構 - 1.陣列和連結串列節點物件為HashMap內部類 Node. - 2.紅黑樹節點 TreeNode,繼承LinkedHashMap.Entry , 而 Entry繼承Node,因此 TreeNode 實際是 Node孫子. - 3.Node類,重寫了hashCode和 equals方法,記錄了當前key, value, key的hash值,以及指向後一個元素指標. ###### 4.1 Node類 ```java //實現Map集合的Entry類 static class Node implements Map.Entry { //記錄當前節點key的hash final int hash; //鍵 final K key; //值 V value; //用於連結串列時,指向後一個元素 Node next; Node(int hash, K key, V value, Node 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 public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } //重寫了equals 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; } } ``` ###### 4.2.TreeNode 什麼是紅黑樹?特點? 性質1. 節點是紅色或黑色. 性質2. 根節點是黑色. 性質3.所有葉子都是黑色.(葉子是NUIL節點) 性質4. 每個紅色節點的兩個子節點都是黑色.(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點) 性質5. 從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點. ```java // 繼承LinkedHashMap.Entry 繼承>> HashMap.Node static final class TreeNode extends LinkedHashMap.Entry { TreeNode parent; // red-black tree links TreeNode left; TreeNode right; TreeNode prev; // needed to unlink next upon deletion //顏色是否為紅色 boolean red; ``` ##### 五、put方法 ```java public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } ``` ###### 5.1 key的hash方法 ```java static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ``` hash(key)方法: 1.hashMap支援 key 為null,放在陣列索引為0的位置 2.為了保證key的hash值分佈均分、雜湊,減少衝突 ```java (h = key.hashCode()) ^ (h >>> 16) ``` 等於 key的hash值 異或於 其hash值的低 16位 例:"水果"的 hashCode() h = 11011000000111101000 h>>>16=00000000000000001101 兩者異或,不同為1,相同為0 ```java 1101 10000001 11101000 ^ 0000 00000000 00001101 ---------------------- 1101 10000001 11100101 ``` **put核心方法** ```java final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node[] tab; Node p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) //1,陣列為空,初始化操作,此處resize() 待解析1 n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) /**2,通過key的計算出的hash值與陣列長度-1與運算,算出在陣列下標位置 * 若該位置為空則建立新節點封裝元素值,放在該位置 */ tab[i] = newNode(hash, key, value, null); else { // 若陣列下標位置已經有值 Node e; K k; //3,首先判斷陣列位置兩個key是否相同,e用來指向存在相同key的節點。 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) //4,key不相同的情況下,判斷陣列該下標位置連線的是連結串列還是樹節點 //若是樹結構,就將該值放入樹中(放入樹中操作,也會遍歷樹判斷是否存在相同的key的節點,若無相同會以新節點插入樹中) e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value); else { //陣列該下標位置只有1個節點或者連結串列:這裡統一為連結串列形式 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { //5,沒有找到相同key的元素,在連結串列後插入新節點 p.next = newNode(hash, key, value, null); /**此處為什麼要>=7,因為bincount從0開始遞增,當bincount=7時, *此時for迴圈執行了7次,加上新增加的節點,以及陣列下標位置節點 *共7+1+1= 9了,即該陣列下標位置連結串列長度大於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; //有上面 e= p.next,這裡 p有重新被賦值為e,即繼續遍歷連結串列下一個元素 p = e; } } // 存在相同的key的元素,可能在陣列、連結串列、樹上 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) //6,覆蓋舊值,返回舊值,也可設定僅僅當舊value存在,不為null情況才覆蓋原值 e.value = value; afterNodeAccess(e); return oldValue; } } //修改次數+1 ++modCount; //7,判斷整個map中元素個數是否大於閾值,大於則擴容,由此可見是先插入元素再判斷容量大小是否擴容,區別1.7先擴容再插入新值 if (++size > threshold) resize(); //該方法為空,不用理會 afterNodeInsertion(evict); return null; } ``` ###### 5.2 resize() 擴容方法 該方法有個比較有意思的是將舊陣列的元素移到擴大為原來容量2倍的新陣列中時,原來陣列中連結串列需要進行拆鏈,非常巧妙,下見。 ```java final Node[] resize() { Node[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; //1,原來陣列容量>0情況 if (oldCap > 0) { //如果陣列容量已經為最大值了 2^30,那就僅僅是將擴容閾值修改 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //1.1 如果原來陣列容量>=16,且其2倍小於最大容量(oldCap<<1 等價於 oldCap*2^1) // 閾值也擴容為原來2倍 newThr = oldThr << 1; // double threshold } else if (oldThr >
0) // initial capacity was placed in threshold //1.2 什麼情況陣列容量=0,閾值>0呢? //建立HashMap,指定了陣列初始容量cap,會將其轉換為大於等於cap的最小的2的冪次方的數賦值給threshold,這裡即將陣列容量賦值 newCap = oldThr; else { // zero initial threshold signifies using defaults //1.3原來陣列容量和閾值都為0,即常用的無參構造方式,使用預設容量大小 //閾值根據容量和負載因子算出 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //2,newThr = 0情況出現在1.1中,沒滿足條件擴大為原來2倍 if (newThr == 0) { float ft = (float)newCap * loadFactor; //小於最大容量,則用容量和載入因子乘積,否則為 Integer最大值 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; //3,建立新陣列物件 @SuppressWarnings({"rawtypes","unchecked"}) Node[] newTab = (Node[])new Node[newCap]; table = newTab; //4,原陣列物件不為空,需要將原陣列元素移到新陣列中 if (oldTab != null) { //遍歷舊陣列 for (int j = 0; j < oldCap; ++j) { Node e; //陣列下標位置有元素 if ((e = oldTab[j]) != null) { //直接把該下標位置連結串列(或紅黑樹)取出來,1個元素也當做連結串列看待,原陣列該位置置空,只要我們拿著連結串列頭節點/樹根節點(e)就行 oldTab[j] = null; if (e.next == null) //該位置只有1個元素,直接與新陣列長度hash,確定位置放入新陣列 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) //該位置是一顆紅黑樹,遷移到新陣列 ((TreeNode)e).split(this, newTab, j, oldCap); else { // preserve order //5,重點解析:這裡將原陣列連結串列拆分為2條連結串列放入新陣列,那它是怎麼拆的呢? // loHead,loTail(lo 理解為low縮寫)分別記錄放在新陣列下標小的那一條連結串列的頭節點和尾結點, // 同理hiHead,hiTail(hi理解為 hight縮寫)放在新陣列下標大的位置 Node loHead = null, loTail = null; Node hiHead = null, hiTail = null; Node next; do { next = e.next; // e.hash & oldCap 是什麼目的呢? // e.hash & (oldCap-1)求得是陣列下標,e.hash & oldCap // 是為了獲取比oldCap-1更高的那位一是0還是1,是0的就留在原位,是1的話需要增加oldCap。這裡不易理解,下面詳解 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; } ``` 擴容拆鏈過程分析: ```java #1,擴容前位置 23&15= 7 0001 0111 & 0000 1111 --------- 0000 0111 #2,擴容後位置 23&31= 23 0001 0111 & 0001 1111 --------- 0001 0111 /**因為每次擴容都是old陣列長度的2倍,那麼在要計算在擴容後新陣列位置, 那麼只需要關心key的hash值左邊新增計算的那位是0還是1,如上未擴容前23與15與運算,只需關心低4位值,高位無論其是否有1,與運算後都會為0; 而擴容後,15變為31,那麼key的hash值影響計算結果位由4位變為5位, 因此 e.hash & oldCap的結果就是獲取新增的位置,000X 0000,即X的值,0或者1; 0運算結果不變,放在原位,1與運算結果會增加一個原陣列長度。 */ ``` 如下圖3所示: ![](https://img2020.cnblogs.com/blog/1458219/202008/1458219-20200810185604449-1822251834.png) ```java 7 & 7=7 0000 0111 0000 0111 ---------- 23 & 7=7 0001 0111 0000 0111 ---------- 31 & 7=7 0001 1111 0000 0111 ---------- 15 & 7=7 0000 1111 0000 0111 #可見第4位(從右向左)為1的有15,31,擴容後位置:原有下標+原陣列長度 ``` 拆鏈位運算實現的巧妙: 1,擴容遷移原有陣列中的元素,不用再重複計算原有元素key的hash值,提高效率. 2,擴容後拆鏈,會將連結串列長度縮短,減少hash衝突,提高查詢效率. ##### 六、get方法 根據鍵值對中的鍵(key)獲取值 ```java public V get(Object key) { Node e; //獲取節點賦值給e,e!=null, 返回該鍵值對的value值 return (e = getNode(hash(key), key)) == null ? null : e.value; } ``` 下見方法:getNode(int hash, Object key) ```java final Node getNode(int hash, Object key) { //傳入的hash值與put方法一樣的方法,相同規則計算key的hash值 Node[] tab; Node first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //1,獲取陣列下標位置的第一個節點,first if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) //2,檢查是否為相同的key,是直接返回該節點物件;不是則遍歷下一個節點 return first; if ((e = first.next) != null) { //3,下一個節點存在,需判斷是紅黑樹,還是連結串列 if (first instanceof TreeNode) //3.1紅黑樹則遍歷樹獲取是否存在與該key相同的節點 return ((TreeNode)first).getTreeNode(hash, key); do { //3.2 連結串列則依次遍歷,查詢相同的key,找到則返回 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } //陣列為空,或者沒有找到返回空 return null