java 8 Hashmap深入解析 —— put get 方法源碼


      本文為原創博文,轉載請注明出處,侵權必究!

      每個java程序員都知道,HashMap是java中最重要的集合類之一,也是找工作面試中非常常見的考點,因為HashMap的實現本身確實蘊含了很多精妙的代碼設計。

  對於普通的程序員,可能僅僅能說出HashMap線程不安全,允許key、value為null,以及不要求線程安全時,效率上比HashTable要快一些。稍微好一些的,會對具體實現有過大概了解,能說出HashMap由數組+鏈表+RBT實現,並了解HashMap的擴容機制。但如果你真的有一個刨根問題的熱情,那么你肯定會想知道具體是如何一步步實現的。HashMap的源碼一共2000多行,很難在這里每一句都說明,但這篇文章會讓你透徹的理解到我們平時常用的幾個操作下,HashMap是如何工作的。

   要先提一下的是,我看過很多講解HashMap原理的文章,有一些講的非常好,但這些文章習慣於把源代碼和邏輯分析分開,導致出現了大段的文字講解代碼,閱讀起來有些吃力和枯燥。所以我想嘗試另一種風格,將更多的內容寫進注釋里,可能看起來有些啰嗦,但對於一些新手的理解,應該會有好的效果。


 

  • HashMap結構

   首先是了解HashMap的幾個核心成員變量(以下均為jdk源碼):

 1   transient Node<K,V>[] table;        //HashMap的哈希桶數組,非常重要的存儲結構,用於存放表示鍵值對數據的Node元素。
 2 
 3   transient Set<Map.Entry<K,V>> entrySet;  //HashMap將數據轉換成set的另一種存儲形式,這個變量主要用於迭代功能。
 4 
 5   transient int size;             //HashMap中實際存在的Node數量,注意這個數量不等於table的長度,甚至可能大於它,因為在table的每個節點上是一個鏈表(或RBT)結構,可能不止有一個Node元素存在。
 6 
 7   transient int modCount;           //HashMap的數據被修改的次數,這個變量用於迭代過程中的Fail-Fast機制,其存在的意義在於保證發生了線程安全問題時,能及時的發現(操作前備份的count和當前modCount不相等)並拋出異常終止操作。
 8 
 9   int threshold;                //HashMap的擴容閾值,在HashMap中存儲的Node鍵值對超過這個數量時,自動擴容容量為原來的二倍。
10 
11   final float loadFactor;           //HashMap的負載因子,可計算出當前table長度下的擴容閾值:threshold = loadFactor * table.length。 

 

  顯然,HashMap的底層實現是基於一個Node的數組,那么Node是什么呢?在HashMap的內部可以看見定義了這樣一個內部類:

 1 static class Node<K,V> implements Map.Entry<K,V> {
 2   final int hash;
 3   final K key;
 4     V value;
 5     Node<K,V> next;
 6 
 7     Node(int hash, K key, V value, Node<K,V> next) {
 8         this.hash = hash;
 9         this.key = key;
10         this.value = value;
11         this.next = next;
12     }
13 
14     public final K getKey()        { return key; }
15     public final V getValue()      { return value; }
16     public final String toString() { return key + "=" + value; }
17 
18     public final int hashCode() {
19         return Objects.hashCode(key) ^ Objects.hashCode(value);
20     }
21 
22     public final V setValue(V newValue) {
23         V oldValue = value;
24         value = newValue;
25         return oldValue;
26     }
27 
28     public final boolean equals(Object o) {
29         if (o == this)
30             return true;
31         if (o instanceof Map.Entry) {
32             Map.Entry<?,?> e = (Map.Entry<?,?>)o;
33             if (Objects.equals(key, e.getKey()) &&
34                 Objects.equals(value, e.getValue()))
35                 return true;
36         }
37         return false;
38     }
39 }

 

  我們大體看一下這個內部類就可以知道,它實現了Map.Entry接口。其內部的變量含義也很明確,hash值、key\value對和實現鏈表和紅黑樹所需要的指針索引。

  既然知道了HashMap的基本結構,那么這些變量的默認值都是多少呢?我們再看一下HashMap定義的一些常量:

 1     //默認的初始容量為16,必須是2的冪次
 2        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
3
4 //最大容量即2的30次方 5 static final int MAXIMUM_CAPACITY = 1 << 30; 6 7 //默認加載因子 8 static final float DEFAULT_LOAD_FACTOR = 0.75f; 9 10 //當put一個元素時,其鏈表長度達到8時將鏈表轉換為紅黑樹 11 static final int TREEIFY_THRESHOLD = 8; 12 13 //鏈表長度小於6時,解散紅黑樹 14 static final int UNTREEIFY_THRESHOLD = 6; 15 16 //默認的最小的擴容量64,為避免重新擴容沖突,至少為4 * TREEIFY_THRESHOLD=32,即默認初始容量的2倍 17 static final int MIN_TREEIFY_CAPACITY = 64;

 

  • TIP : 在HashMap內部定義的幾個變量,包括桶數組本身都是transient修飾的,這代表了他們無法被序列化,而HashMap本身是實現了Serializable接口的。這很容易產生疑惑:HashMap是如何序列化的呢?查了一下源碼發現,HashMap內有兩個用於序列化的函數 readObject(ObjectInputStream s) 和 writeObject(ObjectOutputStreams),通過這個函數將table序列化。

  •  HashMap 的 put 方法解析

  以上就是我們對HashMap的初步認識,下面進入正題,看看HashMap是如何添加、查找與刪除數據的。

  首先來看put方法,我盡量在每行都加注釋闡明這一行的含義,讓閱讀起來更容易理解。

 1     public V put(K key, V value) {
 2         return putVal(hash(key), key, value, false, true); 
 3     }
 4 
 5   final V putVal(int hash, K key, V value, boolean onlyIfAbsent,          //這里onlyIfAbsent表示只有在該key對應原來的value為null的時候才插入,也就是說如果value之前存在了,就不會被新put的元素覆蓋。  6                    boolean evict) {                                              //evict參數用於LinkedHashMap中的尾部操作,這里沒有實際意義。  7         Node<K,V>[] tab; Node<K,V> p; int n, i;                    //定義變量tab是將要操作的Node數組引用,p表示tab上的某Node節點,n為tab的長度,i為tab的下標。  8         if ((tab = table) == null || (n = tab.length) == 0)                    //判斷當table為null或者tab的長度為0時,即table尚未初始化,此時通過resize()方法得到初始化的table。                        
 9             n = (tab = resize()).length;                        //這種情況是可能發生的,HashMap的注釋中提到:The table, initialized on first use, and resized as necessary。 10         if ((p = tab[i = (n - 1) & hash]) == null)                               //此處通過(n - 1) & hash 計算出的值作為tab的下標i,並另p表示tab[i],也就是該鏈表第一個節點的位置。並判斷p是否為null。 11             tab[i] = newNode(hash, key, value, null);                 //當p為null時,表明tab[i]上沒有任何元素,那么接下來就new第一個Node節點,調用newNode方法返回新節點賦值給tab[i]。 12         else {                                              //下面進入p不為null的情況,有三種情況:p為鏈表節點;p為紅黑樹節點;p是鏈表節點但長度為臨界長度TREEIFY_THRESHOLD,再插入任何元素就要變成紅黑樹了。 13             Node<K,V> e; K k;                               //定義e引用即將插入的Node節點,並且下文可以看出 k = p.key。 14             if (p.hash == hash &&                             //HashMap中判斷key相同的條件是key的hash相同,並且符合equals方法。這里判斷了p.key是否和插入的key相等,如果相等,則將p的引用賦給e。
15                 ((k = p.key) == key || (key != null && key.equals(k))))           //這一步的判斷其實是屬於一種特殊情況,即HashMap中已經存在了key,於是插入操作就不需要了,只要把原來的value覆蓋就可以了。 16                 e = p;                                    //這里為什么要把p賦值給e,而不是直接覆蓋原值呢?答案很簡單,現在我們只判斷了第一個節點,后面還可能出現key相同,所以需要在最后一並處理。 17             else if (p instanceof TreeNode)                                       //現在開始了第一種情況,p是紅黑樹節點,那么肯定插入后仍然是紅黑樹節點,所以我們直接強制轉型p后調用TreeNode.putTreeVal方法,返回的引用賦給e。 18                 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);   //你可能好奇,這里怎么不遍歷tree看看有沒有key相同的節點呢?其實,putTreeVal內部進行了遍歷,存在相同hash時返回被覆蓋的TreeNode,否則返回null。 19             else {                                                  //接下里就是p為鏈表節點的情形,也就是上述說的另外兩類情況:插入后還是鏈表/插入后轉紅黑樹。另外,上行轉型代碼也說明了TreeNode是Node的一個子類。 20                 for (int binCount = 0; ; ++binCount) {                 //我們需要一個計數器來計算當前鏈表的元素個數,並遍歷鏈表,binCount就是這個計數器。 21                     if ((e = p.next) == null) {                     //遍歷過程中當發現p.next為null時,說明鏈表到頭了,直接在p的后面插入新的鏈表節點,即把新節點的引用賦給p.next,插入操作就完成了。注意此時e賦給p。 22                         p.next = newNode(hash, key, value, null);          //最后一個參數為新節點的next,這里傳入null,保證了新節點繼續為該鏈表的末端。 23                         if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st     //插入成功后,要判斷是否需要轉換為紅黑樹,因為插入后鏈表長度加1,而binCount並不包含新節點,所以判斷時要將臨界閾值減1。
24                             treeifyBin(tab, hash);                     //當新長度滿足轉換條件時,調用treeifyBin方法,將該鏈表轉換為紅黑樹。 25                         break;                                //當然如果不滿足轉換條件,那么插入數據后結構也無需變動,所有插入操作也到此結束了,break退出即可。 26                     }
27                     if (e.hash == hash &&                         //在遍歷鏈表的過程中,我之前提到了,有可能遍歷到與插入的key相同的節點,此時只要將這個節點引用賦值給e,最后通過e去把新的value覆蓋掉就可以了。
28                         ((k = e.key) == key || (key != null && key.equals(k))))   //老樣子判斷當前遍歷的節點的key是否相同。 29                         break;                                //找到了相同key的節點,那么插入操作也不需要了,直接break退出循環進行最后的value覆蓋操作。 30                     p = e;                                  //在第21行我提到過,e是當前遍歷的節點p的下一個節點,p = e 就是依次遍歷鏈表的核心語句。每次循環時p都是下一個node節點。 31                 }
32             }
33             if (e != null) { // existing mapping for key                //左邊注釋為jdk自帶注釋,說的很明白了,針對已經存在key的情況做處理。
34                 V oldValue = e.value;                           //定義oldValue,即原存在的節點e的value值。 35                 if (!onlyIfAbsent || oldValue == null)                 //前面提到,onlyIfAbsent表示存在key相同時不做覆蓋處理,這里作為判斷條件,可以看出當onlyIfAbsent為false或者oldValue為null時,進行覆蓋操作。 36                     e.value = value;                              //覆蓋操作,將原節點e上的value設置為插入的新value。 37                 afterNodeAccess(e);                            //這個函數在hashmap中沒有任何操作,是個空函數,他存在主要是為了linkedHashMap的一些后續處理工作。 38                 return oldValue;                              //這里很有意思,他返回的是被覆蓋的oldValue。我們在使用put方法時很少用他的返回值,甚至忘了它的存在,這里我們知道,他返回的是被覆蓋的oldValue。 39             }
40         }                                            
41         ++modCount;                                      //收尾工作,值得一提的是,對key相同而覆蓋oldValue的情況,在前面已經return,不會執行這里,所以那一類情況不算數據結構變化,並不改變modCount值。 42         if (++size > threshold)                               //同理,覆蓋oldValue時顯然沒有新元素添加,除此之外都新增了一個元素,這里++size並與threshold判斷是否達到了擴容標准。 43             resize();                                     //當HashMap中存在的node節點大於threshold時,hashmap進行擴容。 44         afterNodeInsertion(evict);                             //這里與前面的afterNodeAccess同理,是用於linkedHashMap的尾部操作,HashMap中並無實際意義。1 45         return null;                                        //最終,對於真正進行插入元素的情況,put函數一律返回null。 46     }

    在上述代碼中的第十行,HashMap根據 (n - 1) & hash 求出了元素在node數組的下標。這個操作非常精妙,下面我們仔細分析一下計算下標的過程,主要分三個階段:計算hashcode、高位運算和取模運算。

  首先,傳進來的hash值是由put方法中的hash(key)產生的(上述第2行),我們來看一下hash()方法的源碼:

1     static final int hash(Object key) {
2         int h;
3         return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
4     }

  這里通過key.hashCode()計算出key的哈希值,然后將哈希值h右移16位,再與原來的h做異或^運算——這一步是高位運算。設想一下,如果沒有高位運算,那么hash值將是一個int型的32位數。而從2的-31次冪到2的31次冪之間,有將近幾十億的空間,如果我們的HashMap的table有這么長,內存早就爆了。所以這個散列值不能直接用來最終的取模運算,而需要先加入高位運算,將高16位和低16位的信息"融合"到一起,也稱為"擾動函數"。這樣才能保證hash值所有位的數值特征都保存下來而沒有遺漏,從而使映射結果盡可能的松散。最后,根據 n-1 做與操作的取模運算。這里也能看出為什么HashMap要限制table的長度為2的n次冪,因為這樣,n-1可以保證二進制展示形式是(以16為例)0000 0000 0000 0000 0000 0000 0000 1111。在做"與"操作時,就等同於截取hash二進制值得后四位數據作為下標。這里也可以看出"擾動函數"的重要性了,如果高位不參與運算,那么高16位的hash特征幾乎永遠得不到展現,發生hash碰撞的幾率就會增大,從而影響性能。

  HashMap的put方法的源碼實現就是這樣了,整理思路非常連貫。這里面有幾個函數的源碼(比如resize、putTreeValue、newNode、treeifyBin)限於篇幅原因,就不貼了,后面應該還會更新在其他博客里,有興趣的同學也可以自己挖掘一下。


  • HashMap 的 get 方法解析

   讀完了put的源碼,其實已經可以很清晰的理清HashMap的工作原理了。接下來再看get方法的源碼,就非常的簡單:

 1     public V get(Object key) {
 2         Node<K,V> e;
 3         return (e = getNode(hash(key), key)) == null ? null : e.value;      //根據key及其hash值查詢node節點,如果存在,則返回該節點的value值。  4     }
 5 
 6     final Node<K,V> getNode(int hash, Object key) {                  //根據key搜索節點的方法。記住判斷key相等的條件:hash值相同 並且 符合equals方法。  7         Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
 8         if ((tab = table) != null && (n = tab.length) > 0 &&            //根據輸入的hash值,可以直接計算出對應的下標(n - 1)& hash,縮小查詢范圍,如果存在結果,則必定在table的這個位置上。
 9             (first = tab[(n - 1) & hash]) != null) {
10             if (first.hash == hash && // always check first node
11                 ((k = first.key) == key || (key != null && key.equals(k))))    //判斷第一個存在的節點的key是否和查詢的key相等。如果相等,直接返回該節點。 12                 return first;
13             if ((e = first.next) != null) {                       //遍歷該鏈表/紅黑樹直到next為null。 14                 if (first instanceof TreeNode)                       //當這個table節點上存儲的是紅黑樹結構時,在根節點first上調用getTreeNode方法,在內部遍歷紅黑樹節點,查看是否有匹配的TreeNode。 15                     return ((TreeNode<K,V>)first).getTreeNode(hash, key);
16                 do {
17                     if (e.hash == hash &&                        //當這個table節點上存儲的是鏈表結構時,用跟第11行同樣的方式去判斷key是否相同。
18                         ((k = e.key) == key || (key != null && key.equals(k))))
19                         return e;
20                 } while ((e = e.next) != null);                      //如果key不同,一直遍歷下去直到鏈表盡頭,e.next == null。 21             }
22         }
23         return null;
24     }

  因為查詢過程不涉及到HashMap的結構變動,所以get方法的源碼顯得很簡潔。核心邏輯就是遍歷table某特定位置上的所有節點,分別與key進行比較看是否相等。


  以上便是HashMap最常用API的源碼分析,除此之外,HashMap還有一些知識需要重點學習:擴容機制、並發安全問題、內部紅黑樹的實現。這些內容我也會在之后陸續發文分析,希望可以幫讀者徹底理解HashMap的原理。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM