HashMap集合詳解----源碼分析--之--高級篇


 

一、HashMap集合簡介

  • HashMap基於哈希表的Map接口實現,是以key-value存鍺形式存在,即主要用來存放鍵值對。HashMap的實現不是同步的,這意味着它不是線程安全的。它的key、value都可以為null,此外,HashMap中的映射不是有序的。
  • jdk1.8之前HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突(兩個對象調用的hashCode方法計算的哈希值一致導致計算的教組索引值相同)而存在的(“拉鏈法”解決沖突)jdk1.8以后在解決哈希沖突時有了較大的變化,當鏈表長度大於閾值(或者紅黑樹的邊界值,默認為8)並且當前數組的長度大於64時,此時此索引位置上的所有數據改為使用紅黑樹存儲。
  • 補充將鏈表轉換成紅黑樹前會判斷,即便閾值大於8,但是數組長度小於64,此時並不會將鏈表變為紅黑樹,而是選擇逬行數組擴容
  • 這樣做的目的是因為數組比較小,盡量避開紅黑樹結構,這種情況下變為紅黑樹結構,反而會降低效率,因為紅黑樹需要逬行左旋,右旋,變色這些操作來保持平衡。同時數組長度小於64時,搜索時間相對要快些。所以結上所述為了提高性能和減少搜索時間,底層閾值大於8並且數組長度大於64時,鏈表才轉換為紅黑樹,具體可以參考 treeifyBin() 方法。
  • 當然雖然增了紅黑樹作為底層數據結構,結構變得復雜了,但是閾值大於8並且數組長度大於64時,鏈表轉換為紅黑樹時,效率也變的更高效。

小結:

特點:
1.存儲無序的

2.鍵和值位置都可以是null,但是鍵位置只能是一個null

3.鍵位置是唯一的,底層的數據結構控制的

4.jdk1.8前數據結構是鏈表+數組,jdk1.8之后是鏈表+數組+紅黑樹

5.閾值(邊界值)>8並且數組長度大於64,才將鏈表轉換為紅黑樹,變為紅黑樹的目的是為了高效的查詢。

二、HashMap集合底層的數據結構

2.1 數據結構概念

數據結構是計算機存儲、組織數據的方式。數據結構是指互相之間存在一種或多種特定關系的數據元素的集合。通常情況下,精心選擇的數據結構可以帶來更高效的運行或者存儲效率。數據結構往往同高效的檢索算法和索引技術有關。

2.2 HashMap底層的數據結構存儲數據的過程

存儲過程如下所示:

HashMap<String, Integer> map = new HashMap<>();
map.put("柳岩", 18);
map.put("楊冪", 28);
map.put("劉德華", 40);
map.put("柳岩", 20);

輸出結果:

{楊冪=28, 柳岩=20, 劉德華=40}

如圖所示:

  

 

 

 

1.HashMap<String, Integer> map = new HashMap<>();
當創建HashMap集合對象的時候,在jdk8前,構造方法中創建一個長度是16的 Entry[] table 用來存儲鍵值對數據的在jdk8以后不是在HashMap的構造方法底層創建數組了,是在第一次調用put方法時創建的數組 Node[] table 用來存儲鍵值對數據。

2.假設向哈希表中存箱<柳岩,18>數據,根據柳岩調用String類中重寫之后的 hashCode() 方法計算出值,然后結合數組長度采用某種算法(無符號右移等等)計算出向Node數組中存儲數據的空間的索引值。如果計算出的索引空間沒有數據,則直接將<柳岩,18>存儲到數組中。(舉例:計算出的索引是3)

3.向哈希表中存儲數據<劉德華,40>,假設算出的 hashCode() 方法結合數祖長度計算出的索引值也是3,那么此時數組空間不是null,此時底層會比較柳岩和劉德華的hash值是否一致,如果不一致,則在空間上划出一個結點來存儲鍵值對數據對<劉德華,40>,這種方式稱為拉鏈法。

4.假設向哈希表中存儲數據<柳岩,20>,那么首先根據柳岩調用 hashCode() 方法結合數組長度計算出索引肯定3,此時比較后存儲的數據柳岩和已經存在的數據的hash值是否相等,如果hash值相等,此時發生哈希碰撞。那么底層會調用柳岩所屬類 String 中的 equals() 方法比較兩個內容是否相等
相等:將后添加的數據的value覆蓋之前的value
不相等:繼續向下和其他的數據的key進行比較,如果都不相等,則划出一個結點存儲數據,如果結點長度即鏈表長度大於閾值8並且數組長度大於64則將鏈表變為紅黑樹。

5.在不斷的添加數據的過程中,會涉及到擴容問題,當超出閾值(且要存放的位置非空)時,擴容。默認的擴容方式:擴容為原來容量的2倍,並將原有的數據復制到新的數組中來

6.綜上描述,當位於一個表中的元素較多,即hash值相等但是內容不相等的元素較多時,通過key值依次查找的效率較低。而jdk1.8中,哈希表存儲采用數組+鏈表+紅黑樹實現,當鏈表長度(閾值)超過8且當前數組的長度大於64時,將鏈表轉換為紅黑樹,這樣大大減少了查找時間。jdk8在哈希表中引入紅黑樹的原因只是為了查找效率更高。

簡單的來說,哈希表是由數組+鏈表+紅黑樹(JDK1.8增加了紅黑樹部分)實現的。如下圖所示:

    

 

 

 

7.傳統hashMap的缺點,1.8為什么引入紅黑樹?這樣結構的話不是更麻煩了嗎,為何閾值大於8換成紅黑樹?

jdk1.8以前HashMap的實現是數組+鏈表,即使哈希函數取得再好,也很難達到元素百分百均勻分布。當HashMap中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的鏈表,這個時候HashMap就相當於一個單鏈表,假如單鏈表有n個元素,遍歷的時間復雜度就是O(n),完全失去了它的優勢。

針對這種情況,jdk1.8中引入了紅黑樹(查找時間復雜度為O(logn))來優化這個問題。當鏈表長度很小的時候,即使遍歷,速度也非常快,但是當鏈表長度不斷變長,肯定會對查詢性能有一定的影響,所以才需要轉成樹。

總結:

    

說明:

  • size表示HashMap中鍵值對的實時數量,注意這個不等於數組的長度。
  • threshold(臨界值)= capacity(容量)* loadFactor(負載因子)。這個值是當前已占用數組長度的最大值。size超過這個值就重新resize(擴容),擴容后的HashMap容量是之前容量的兩倍。

2.3 面試題

  • HashMap中hash函數是怎么實現的?還有哪些hash函數的實現方式

答:對於key的hashCode做hash操作,無符號右移16位然后做異或運算。還有平方取中法偽隨機數法和取余數法。這三種效率都比較低。而無符號右移16位異或運算效率是最高的。

  • 當兩個對象的hashcode相等時會怎么樣

答:會產生哈希碰撞。若key值內容相同則替換舊的value,不然連接到鏈表后面,鏈表長度超過閾值8就轉換為紅黑樹存儲。

  • 何時發生哈希碰撞和什么是哈希碰撞,如何解決哈希碰撞?

答:只要兩個元素的key計算的哈希碼值相同就會發生哈希碰撞。jdk8之前使用鏈表解決哈希碰撞。jdk8之后使用鏈表+紅黑樹解決哈希碰撞。(通過equals)

  • 如果兩個鍵的hashcode相同,如何存儲鍵值對?

答:通過equals比較內容是否相同相同:則新的value覆蓋之前的value。不相同:則將新的鍵值對添加到哈希表中

三、HashMap繼承關系

HashMap繼承關系如下圖所示:

    

說明:

  • Cloneable空接口,表示可以克隆。創建並返回HashMap對象的一個副本。
  • Serializable序列化接口。屬於標記性接口。HashMap對象可以被序列化和反序列化。
  • AbstractMap父類提供了 Map實現接口。以最大限度地減少實現此接口所需的工作。

補充:

  • 通過上述繼承關系我們發現一個很奇怪的現象,就是HashMap已經繼承了AbstractMap而AbstractMap類實現了Map接口,那為什么HashMap還要在實現Map接口呢?同樣在ArrayList中LinkedLis沖都是這種結構。
  • 據java集合框架的創始人 Josh Bloch 描述,這樣的寫法是一個失誤。在java集合框架中,類似這樣的寫法很多,最幵始寫java集合框架的時候,他認為這樣寫,在某些地方可能是有價值的,直到他意識到錯了。顯然的,jdk的維護者,后來不認為這個小小的失誤值得去修改,所以就這樣存在下來了。

 

四、HashMap集合類的成員

4.1 成員變量

  1. 序列化版本號
private static final long serialVersionUID = 362498820763181265L;
2.集合的初始化容量(必須是2的n次冪)
// 默認的初始容量是16    1 << 4 相當於 1*2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

問題:為什么必須是2的n次冪?如果輸入值不是2的冪比如10會怎么樣?

HashMap構造方法還可以指定集合的初始化容量大小:

HashMap(int initialCapacity) // 構造一個帶指定初始容量和默認負載因子(0.75)的空HashMap。
  • 當向HashMap中添加一個元素的時候,需要根據key的hash值,去確定其在數組中的具體位置。HashMap為了存取高效,減少碰撞,就是要盡量把數據分配均勻,每個鏈表長度大致相同,這個實現就在把數據存到哪個鏈表中的算法。
  • 這個算法實際就是取模,hash%length,計算機中直接求余效率不如位移運算。所以源碼中做了優化,使用 hash&(length-1),而實際上 hash%length 等於 hash&(length-1) 的前提是length是2的n次冪。

例如長度為8的時候,3 & (8 - 1) = 3,2 & (8-1) = 2,不同位置上,不碰撞。

/**
 * Returns a power of two size for the given target capacity.
 */
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;
}

說明:

  • 當在實例化HashMap實例時,如果給定了initialCapacity,由於HashMap的capacity必須都是2的冪,因此這個方法用於找到大於等於initialCapacity的最小的2的冪。

分析:

int n = cap - 1;
防止cap已經是2的冪。如果cap已經是2的冪,又沒有這個減1操作,則執行完后面的幾條無符號操作之后,返回的capacity將是這個cap的2倍。

如果n這時為0了(經過了cap - 1后),則經過后面的幾次無符號右移依然是0,最后返回的capacity是1(最后有個n + 1的操作)。

注意:容量最大也就是32bit的正數,因此最后 n |= n >>> 16; 最多也就32個1(但是這已經是負數了,在執行tableSizeFor之前,對initialCapacity做了判斷,如果大於MAXIMUM_CAPACITY(2 ^ 30),則取MAXIMUM_CAPACITY。如果等於MAXIMUM_CAPACITY,會執行位移操作。所以這里面的位移操作之后,最大30個1,不會大於等於MAXIMUM_CAPACITY。30個1,加1后得2 ^ 30)。

完整例子:
  

 

 注意:得到的這個capacity卻被賦值給了threshold

1.   this.threshold = tableSizeFor(initialCapacity);
2. 默認的負載因子,默認值是0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
3. 集合最大容量
 static final int MAXIMUM_CAPACITY = 1 << 30; // 2的30次冪
4. 當鏈表的值超過8則會轉為紅黑樹(jdk1.8新增)
// 當桶(bucket)上的結點數大於這個值時會轉為紅黑樹
static final int TREEIFY_THRESHOLD = 8;

問題:為什么Map桶中結點個數超過8才轉為紅黑樹?

  • 8這個閾值定義在HashMap中,針對這個成員變量,在源碼的注釋中只說明了8是bin(bin就是bucket桶)從鏈表轉成樹的閾值,但是並沒有說明為什么是8;

在HashMap中有一段注釋說明;

Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins.  In usages with well-distributed user hashCodes, tree bins are rarely used.  Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution (http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:
 翻譯:因為樹結點的大小大約是普通結點的兩倍,所以我們只在箱子包含足夠的結點時才使用樹結點(參見 TREEIFY_THRESHOLD)。當它們變得太小(由於刪除或調整大小)時,就會被轉換回普通的捅。在使用分布良好的用戶 hashCode 時,很少使用樹箱。理想情況下,在隨機哈希碼下,箱子中結點的頻率服從泊松分布 (http://en.wikipedia.org/wiki/Poisson_distribution) ,默認調整閾值為0.75,平均參數約為0.5,盡管由 於調整粒度的差異很大。忽略方差,列表大小k的預朗出現次數是(exp(-0.5) * pow(0.5, k) / factorial(k))。 第一個值是: 0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million
  • TreeNodes占用空間是普通Nodes的兩倍,所以只有當bin包含足夠多的結點時才會轉成TreeNodes,而是否足夠多就是由TREEIFY_THRESH〇LD的值決定的。當bin中結點數變少時,又會轉成普通的bin。並且我們查看源碼的時候發現,鏈表長度達到8就轉成紅黑樹,當長度降到6就轉成普通bin
  • 這樣就解釋了為什么不是一開始就將其轉換為TreeNodes,而是需要一定結點數才轉為TreeNodes,說白了就是權衡空間和時間。
  • 這段內容還說到:當hashCode離散性很好的時候,樹型bin用到的概率非常小,因為數據均勻分布在每個bin中,幾乎不會有bin中鏈表長度會達到閾值。但是在隨機hashCode下,離散性可能會變差,然而jdk又不能阻止用戶實現這種不好的hash算法,因此就可能導致不均勻的數據分布。不理想情況下隨機hashCode算法下所有bin中結點的分布頻率會遵循泊松分布,我們可以看到,一個bin中鏈表長度達到8個元素的槪率為0.00000006,幾乎是不可能事件。所以,之所以選擇8,不是隨便決定的,而是裉據概率統計決定的。甶此可見,發展將近30年的java每一項改動和優化都是非常嚴謹和科學的。
  • 也就是說:選擇8因為符合泊松分布,超過8的時候,概率已經非常小了,所以我們選擇8這個數宇。

補充:

  • Poisson分布(泊松分布),是一種統計與概率學里常見到的離散[概率分布]。泊松分布的概率函數為:
  • 泊松分布的參數A是單位時間(或單位面積)內隨機事件的平均發生次數。泊松分布適合於描述單位時間內隨機事件發生的次數。
  • 紅黑樹的平均查找長度是log(n),如果長度為8,平均查找長度為log(8) = 3,鏈表的平均查找長度為n/2,當長度為8時,平均查找長虔為8/2 = 4,這才有轉換成樹的必要;鏈表長度如果是小於等於6, 6/2 = 3,而log(6) = 2.6,雖然速度也很快的,但是轉化為樹結構和生成樹的時間並不會太短。
 5.當鏈表的值小於6則會從紅黑樹轉回鏈表
// 當捅(bucket)上的結點數小於這個值,樹轉為鏈表 
static final int UNTREEIFY_THRESHOLD = 6;
6. MIN_TREEIFY_CAPACITY 

 

當Map里面的數量超過這個值時,表中的桶才能進行樹形化,否則桶內元素太多時會擴容,而不是樹形化為了避免進行擴容、樹形化選擇的沖突,這個值不能小於4*TREEIFY_THRESHOLD(8)

// 捅中結構轉化為紅黑樹對應的數組長度最小的值 
static final int MIN_TREEIFY_CAPACITY = 64;
7.table用來初始化(必須是二的n次冪)(重點)
// 存儲元素的數組 
transient Node<K,V>[] table;

table在jdk1.8中我們了解到HashMap是由數組加鏈表加紅黑樹來組成的結構其中table就是HashMap中的數組,jdk8之前數組類型是Entry<K,V>類型。從jdk1.8之后是Node<K,V>類型。只是換了個名字,都實現了一樣的接口:Map.Entry<K,V>。負責存儲鍵值對數據的

 8.用來存放緩存 
// 存放具體元素的集合
transient Set<Map.Entry<K,V>> entrySet;
9.HashMap中存放元素的個數(重點)
// 存放元素的個數,注意這個不等於數組的長度
 transient int size;

size為HashMap中K-V的實時數量,不是數組table的長度

10.用來記錄HashMap的修改次數
// 每次擴容和更改map結構的計數器
 transient int modCount;  
11.用來調整大小下一個容量的值計算方式為(容量*負載因子)
// 臨界值 當實際大小(容量*負載因子)超過臨界值時,會進行擴容
int threshold;
12.哈希表的負載因子(重點)
// 負載因子
final float loadFactor;

說明:

  • loadFactor 負載因子,是用來衡量HashMap滿的程度,表示HashMap的疏密程度,影響hash操作到同一個數組位置的概率,計算HashMap的實時負載因子的方法為:size/capacity,而不是占用桶的數量去除以capacity。capacity 是桶的數量,也就是 table 的長度 length。
  • loadFactor太大導致查找元素效率低,太小導致數組的利用率低,存放的數據會很分散。loadFactor的默認值為0.75f是官方給出的一個比較好的臨界值。
  • 當HashMap里面容納的元素已經達到HashMap數組長度的75%時,表示HashMap太擠了,需要擴容,而擴容這個過程涉及到 rehash、復制數據等操作,非常消耗性能。所以開發中盡量減少擴容的次數,可以通過創建HashMap集合對象時指定初始容量來盡量避免

         同時在HashMap的構造器中可以定制loadFactor。

// 構造方法,構造一個帶指定初始容量和負載因子的空HashMap
HashMap(int initialCapacity, float loadFactor);
  • 為什么負載因子設置為0.75,初始化臨界值是12?

    loadFactor越趨近於1,那么 數組中存放的數據(entry)也就越多,也就越密,也就是會讓鏈表的長度增加,loadFactor越小,也就是趨近於0,數組中存放的數據(entry)也就越少,也就越稀疏。

     

 

 

 

如果希望鏈表盡可能少些,要提前擴容。有的數組空間有可能一直沒有存儲數據,負載因子盡可能小一些。

舉例:

例如:負載因子是0.4。 那么16*0.4--->6 如果數組中滿6個空間就擴容會造成數組利用率太低了。
     負載因子是0.9。 那么16*0.9---->14 那么這樣就會導致鏈表有點多了。導致查找元素效率低。

所以既兼顧數組利用率又考慮鏈表不要太多,經過大量測試0.75是最佳方案。

  • threshold計算公式:capacity(數組長度默認16) * loadFactor(負載因子默認0.75)。這個值是當前已占用數組長度的最大值。當Size >= threshold的時候,那么就要考慮對數組的resize(擴容),也就是說,這個的意思就是 衡量數組是否需要擴增的一個標准。 擴容后的 HashMap 容量是之前容量的兩倍。

4.2 構造方法

HashMap 中重要的構造方法,它們分別如下

  1. 構造一個空的HashMap,默認初始容量(16)和默認負載因子(0.75)
public HashMap() {
   this.loadFactor = DEFAULT_LOAD_FACTOR; // 將默認的負載因子0.75賦值給loadFactor,並沒有創建數組
}
      2.構造一個具有指定的初始容量和默認負載因子(0.75)HashMap
 // 指定“容量大小”的構造函數
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
     3.構造一個具有指定的初始容量和負載因子的 HashMap
/*
     指定“容量大小”和“負載因子”的構造函數
     initialCapacity:指定的容量
     loadFactor:指定的負載因子
*/
public HashMap(int initialCapacity, float loadFactor) {
        // 判斷初始化容量initialCapacity是否小於0
        if (initialCapacity < 0)
            // 如果小於0,則拋出非法的參數異常IllegalArgumentException
            throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
        // 判斷初始化容量initialCapacity是否大於集合的最大容量MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            // 如果超過MAXIMUM_CAPACITY,會將MAXIMUM_CAPACITY賦值給initialCapacity
            initialCapacity = MAXIMUM_CAPACITY;
        // 判斷負載因子loadFactor是否小於等於0或者是否是一個非數值
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            // 如果滿足上述其中之一,則拋出非法的參數異常IllegalArgumentException
            throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
         // 將指定的負載因子賦值給HashMap成員變量的負載因子loadFactor
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
// 最后調用了tableSizeFor,來看一下方法實現:
     /*
         返回比指定初始化容量大的最小的2的n次冪
     */
    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;
    }

說明:

對於 javathis.threshold = tableSizeFor(initialCapacity); 疑問解答:

tableSizeFor(initialCapacity)判斷指定的初始化容量是否是2的n次冪,如果不是那么會變為比指定初始化容量大的最小的2的n次冪。 但是注意,在tableSizeFor方法體內部將計算后的數據返回給調用這里了,並且直接賦值給threshold邊界值了。有些人會覺得這里是一個bug,應該這樣書寫: this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
這樣才符合threshold的意思(當HashMap的size到達threshold這個閾值時會擴容)。 但是請注意,在jdk8以后的構造方法中,並沒有對table這個成員變量進行初始化,table的初始化被推遲到了put方法中,在put方法中會對threshold重新計算。
   4.包含另一個“Map”的構造函數
// 構造一個映射關系與指定 Map 相同的新 HashMap。
public HashMap(Map<? extends K, ? extends V> m) {
        // 負載因子loadFactor變為默認的負載因子0.75
         this.loadFactor = DEFAULT_LOAD_FACTOR;
         putMapEntries(m, false);
 }

最后調用了 putMapEntries(),來看一下方法實現:

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    //獲取參數集合的長度
    int s = m.size();
    if (s > 0) {
        //判斷參數集合的長度是否大於0,說明大於0
        if (table == null) { // 判斷table是否已經初始化
                // 未初始化,s為m的實際元素個數
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
                // 計算得到的t大於閾值,則初始化閾值
                if (t > threshold)
                    threshold = tableSizeFor(t);
        }
        // 已初始化,並且m元素個數大於閾值,進行擴容處理
        else if (s > threshold)
            resize();
        // 將m中的所有元素添加至HashMap中
        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);
        }
    }
}

注意:

float ft = ((float)s / loadFactor) + 1.0F; 這一行代碼中為什么要加1.0F

s/loadFactor的結果是小數,加1.0F與(int)ft相當於是對小數做一個向上取整以盡可能的保證更大容量,更大的容量能夠減少resize的調用次數。所以 + 1.0F是為了獲取更大的容量。

例如:原來集合的元素個數是6個,那么6/0.75是8,是2的n次冪,那么新的數組大小就是8了。然后原來數組的數據就會存儲到長度是8的新的數組中了,這樣會導致在存儲元素的時候,容量不夠,還得繼續擴容,那么性能降低了,而如果+1呢,數組長度直接變為16了,這樣可以減少數組的擴容。

4.3成員方法

4.3.1 增加方法

put方法是比較復雜的,實現步驟大致如下

  • 先通過hash值計算出key映射到哪個桶;
  • 如果桶上沒有碰撞沖突,則直接插入
  • 如果出現碰撞沖突了,則需要處理沖突:
  • a 如果該桶使用紅黑樹處理沖突,則調用紅黑樹的方法插入數據;
  • b 否則采用傳統的鏈式方法插入。如果鏈的長度達到臨界值,則把鏈轉變為紅黑樹;
  • 如果桶中存在重復的鍵,則為該鍵替換新值value;
  • 如果size大於閾值threshold,則進行擴容;

具體的方法如下

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

說明:

  1. HashMap只提供了put用於添加元素,putVal方法只是給put方法調用的一個方法,並沒有提供給用戶使用。 所以我們重點看putVal方法。
  2. 我們可以看到在putVal()方法中key在這里執行了一下hash()方法,來看一下Hash方法是如何實現的。
static final int hash(Object key) {
    int h;
    /*
    1)如果key等於null:返回的是0.
    2)如果key不等於null:首先計算出key的hashCode賦值給h,然后與h無符號右移16位后的二進制進行按位異或得到最后的hash值
    */
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

從上面可以得知HashMap是支持Key為空的,而HashTable是直接用Key來獲取HashCode所以key為空會拋異常。

解讀上述hash方法:

我們先研究下key的哈希值是如何計算出來的。key的哈希值是通過上述方法計算出來的。

這個哈希方法首先計算出key的hashCode賦值給h然后與h無符號右移16位后的二進制進行按位異或得到最后的 hash值。計算過程如下所示:

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

在putVal函數中使用到了上述hash函數計算的哈希值:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    ...
    if ((p = tab[i = (n - 1) & hash]) == null) // 這里的n表示數組長度16
    ...
}

計算過程如下所示:

說明:

  1. key.hashCode();返回散列值也就是hashcode,假設隨便生成的一個值。
  2. n表示數組初始化的長度是16。
  3. &(按位與運算):運算規則:相同的二進制數位上,都是1的時候,結果為1,否則為零。
  4. ^(按位異或運算):運算規則:相同的二進制數位上,數字相同,結果為0,不同為1。

簡單來說就是:

高16bit不變,低16bit和高16bit做了一個異或(得到的hashCode轉化為32位二進制,前16位和后16位低16bit和高16bit做了一個異或)

問題:為什么要這樣操作呢?

如果當n即數組長度很小,假設是16的話,那么n - 1即為1111 ,這樣的值和hashCode直接做按位與操作,實際上只使用了哈希值的后4位。如果當哈希值的高位變化很大,低位變化很小,這樣就很容易造成哈希沖突了,所以這里把高低位都利用起來,從而解決了這個問題。

現在看putVal()方法,看看它到底做了什么。

主要參數:

  • hash key的hash值
  • key 原始Key
  • value 要存放的值
  • onlyIfAbsent 如果true代表不更改現有的值
  • evict 如果為false表示table為創建狀態

putVal()方法源代碼如下所示:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    /*
        1)transient Node<K,V>[] table; 表示存儲Map集合中元素的數組。
        2)(tab = table) == null 表示將空的table賦值給tab,然后判斷tab是否等於null,第一次肯定是null。
        3)(n = tab.length) == 0 表示將數組的長度0賦值給n,然后判斷n是否等於0,n等於0,由於if判斷使用雙或,滿足一個即可,則執行代碼 n = (tab = resize()).length; 進行數組初始化,並將初始化好的數組長度賦值給n。
        4)執行完n = (tab = resize()).length,數組tab每個空間都是null。
    */
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    /*
        1)i = (n - 1) & hash 表示計算數組的索引賦值給i,即確定元素存放在哪個桶中。
        2)p = tab[i = (n - 1) & hash]表示獲取計算出的位置的數據賦值給結點p。
        3) (p = tab[i = (n - 1) & hash]) == null 判斷結點位置是否等於null,如果為null,則執行代碼:tab[i] = newNode(hash, key, value, null);根據鍵值對創建新的結點放入該位置的桶中。
        小結:如果當前桶沒有哈希碰撞沖突,則直接把鍵值對插入空間位置。
    */ 
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 創建一個新的結點存入到桶中
        tab[i] = newNode(hash, key, value, null);
    else {
         // 執行else說明tab[i]不等於null,表示這個位置已經有值了
        Node<K,V> e; K k;
        /*
            比較桶中第一個元素(數組中的結點)的hash值和key是否相等
            1)p.hash == hash :p.hash表示原來存在數據的hash值  hash表示后添加數據的hash值 比較兩個hash值是否相等。
                 說明:p表示tab[i],即 newNode(hash, key, value, null)方法返回的Node對象。
                    Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
                        return new Node<>(hash, key, value, next);
                    }
                    而在Node類中具有成員變量hash用來記錄着之前數據的hash值的。
             2)(k = p.key) == key :p.key獲取原來數據的key賦值給k  key 表示后添加數據的key比較兩個key的地址值是否相等。
             3)key != null && key.equals(k):能夠執行到這里說明兩個key的地址值不相等,那么先判斷后添加的key是否等於null,如果不等於null再調用equals方法判斷兩個key的內容是否相等。
        */
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                /*
                    說明:兩個元素哈希值相等,並且key的值也相等,將舊的元素整體對象賦值給e,用e來記錄
                */ 
                e = p;
        // hash值不相等或者key不相等;判斷p是否為紅黑樹結點
        else if (p instanceof TreeNode)
            // 放入樹中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 說明是鏈表結點
        else {
            /*
                1)如果是鏈表的話需要遍歷到最后結點然后插入
                2)采用循環遍歷的方式,判斷鏈表中是否有重復的key
            */
            for (int binCount = 0; ; ++binCount) {
                /*
                    1)e = p.next 獲取p的下一個元素賦值給e。
                    2)(e = p.next) == null 判斷p.next是否等於null,等於null,說明p沒有下一個元素,那么此時到達了鏈表的尾部,還沒有找到重復的key,則說明HashMap沒有包含該鍵,將該鍵值對插入鏈表中。
                */
                if ((e = p.next) == null) {
                    /*
                        1)創建一個新的結點插入到尾部
                         p.next = newNode(hash, key, value, null);
                         Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
                                return new Node<>(hash, key, value, next);
                         }
                         注意第四個參數next是null,因為當前元素插入到鏈表末尾了,那么下一個結點肯定是null。
                         2)這種添加方式也滿足鏈表數據結構的特點,每次向后添加新的元素。
                    */
                    p.next = newNode(hash, key, value, null);
                    /*
                        1)結點添加完成之后判斷此時結點個數是否大於TREEIFY_THRESHOLD臨界值8,如果大於則將鏈表轉換為紅黑樹。
                        2)int binCount = 0 :表示for循環的初始化值。從0開始計數。記錄着遍歷結點的個數。值是0表示第一個結點,1表示第二個結點。。。。7表示第八個結點,加上數組中的的一個元素,元素個數是9。
                        TREEIFY_THRESHOLD - 1 --》8 - 1 ---》7
                        如果binCount的值是7(加上數組中的的一個元素,元素個數是9)
                        TREEIFY_THRESHOLD - 1也是7,此時轉換紅黑樹。
                    */
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 轉換為紅黑樹
                        treeifyBin(tab, hash);
                    // 跳出循環
                    break;
                }
                 
                /*
                    執行到這里說明e = p.next 不是null,不是最后一個元素。繼續判斷鏈表中結點的key值與插入的元素的key值是否相等。
                */
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循環
                    /*
                        要添加的元素和鏈表中的存在的元素的key相等了,則跳出for循環。不用再繼續比較了
                        直接執行下面的if語句去替換去 if (e != null) 
                    */
                    break;
                /*
                    說明新添加的元素和當前結點不相等,繼續查找下一個結點。
                    用於遍歷桶中的鏈表,與前面的e = p.next組合,可以遍歷鏈表
                */
                p = e;
            }
        }
        /*
            表示在桶中找到key值、hash值與插入元素相等的結點
            也就是說通過上面的操作找到了重復的鍵,所以這里就是把該鍵的值變為新的值,並返回舊值
            這里完成了put方法的修改功能
        */
        if (e != null) { 
            // 記錄e的value
            V oldValue = e.value;
            // onlyIfAbsent為false或者舊值為null
            if (!onlyIfAbsent || oldValue == null)
                // 用新值替換舊值
                // e.value 表示舊值  value表示新值 
                e.value = value;
            // 訪問后回調
            afterNodeAccess(e);
            // 返回舊值
            return oldValue;
        }
    }
    // 修改記錄次數
    ++modCount;
    // 判斷實際大小是否大於threshold閾值,如果超過則擴容
    if (++size > threshold)
        resize();
    // 插入后回調
    afterNodeInsertion(evict);
    return null;
}
4.3.2 將鏈表轉換為紅黑樹的treeifyBin方法

結點添加完成之后判斷此時結點個數是否大於TREEIFY_THRESHOLD臨界值8,如果大於則將鏈表轉換為紅黑樹,轉換紅黑樹的方法 treeifyBin,整體代碼如下:

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
   //轉換為紅黑樹 tab表示數組名  hash表示哈希值
   treeifyBin(tab, hash);

treeifyBin方法如下所示:

/*
    替換指定哈希表的索引處桶中的所有鏈接結點,除非表太小,否則將修改大小。
    Node<K,V>[] tab = tab 數組名
    int hash = hash表示哈希值
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    /*
        如果當前數組為空或者數組的長度小於進行樹形化的閾值(MIN_TREEIFY_CAPACITY = 64),就去擴容。而不是將結點變為紅黑樹。
        目的:如果數組很小,那么轉換紅黑樹,然后遍歷效率要低一些。這時進行擴容,那么重新計算哈希值,鏈表長度有可能就變短了,數據會放到數組中,這樣相對來說效率高一些。
    */
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        //擴容方法
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        /*
            1)執行到這里說明哈希表中的數組長度大於閾值64,開始進行樹形化
            2)e = tab[index = (n - 1) & hash]表示將數組中的元素取出賦值給e,e是哈希表中指定位置桶里的鏈表結點,從第一個開始
        */
        // hd:紅黑樹的頭結點   tl:紅黑樹的尾結點
        TreeNode<K,V> hd = null, tl = null;
        do {
            // 新創建一個樹的結點,內容和當前鏈表結點e一致
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p; // 將新創鍵的p結點賦值給紅黑樹的頭結點
            else {
                p.prev = tl; // 將上一個結點p賦值給現在的p的前一個結點
                tl.next = p; // 將現在結點p作為樹的尾結點的下一個結點
            }
            tl = p;
            /*
                e = e.next 將當前結點的下一個結點賦值給e,如果下一個結點不等於null
                則回到上面繼續取出鏈表中結點轉換為紅黑樹
            */
        } while ((e = e.next) != null);
        /*
            讓桶中的第一個元素即數組中的元素指向新建的紅黑樹的結點,以后這個桶里的元素就是紅黑樹
            而不是鏈表數據結構了
        */
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

小結:上述操作一共做了如下幾件事:

  • 根據哈希表中元素個數確定是擴容還是樹形化。
  • 如果是樹形化遍歷桶中的元素,創建相同個數的樹形結點,復制內容,建立起聯系。
  • 然后讓桶中的第一個元素指向新創建的樹根結點,替換桶的鏈表內容為樹形化內容。
4.3.3 擴容方法 resize()

擴容機制

1.什么時候才需要擴容
  • 當HashMap中的元素個數超過數組大小(數組長度)*loadFactor(負載因子)時,就會進行數組擴容,loadFactor的默認值(DEFAULT_LOAD_FACTOR)是0.75,這是一個折中的取值。也就是說,默認情況下,數組大小為16,那么當HashMap中的元素個數超過16×0.75=12(這個值就是閾值或者邊界值threshold值)的時候,就把數組的大小擴展為2×16=32,即擴大一倍,然后重新計算每個元素在數組中的位置,而這是一個非常耗性能的操作,所以如果我們已經預知HashMap中元素的個數,那么預知元素的個數能夠有效的提高HashMap的性能。

補充:

  • 當HashMap中的其中一個鏈表的對象個數如果達到了8個,此時如果數組長度沒有達到64,那么HashMap會先擴容解決,如果已經達到了64,那么這個鏈表會變成紅黑樹,結點類型由Node變成TreeNode類型。當然,如果映射關系被移除后,下次執行resize方法時判斷樹的結點個數低於6,也會再把樹轉換為鏈表。
2.HashMap的擴容是什么
  • 進行擴容,會伴隨着一次重新hash分配,並且會遍歷hash表中所有的元素,是非常耗時的。在編寫程序中,要盡量避免resize。

HashMap在進行擴容時,使用的rehash方式非常巧妙,因為每次擴容都是翻倍,與原來計算的 (n-1)&hash的結果相比,只是多了一個bit位,所以結點要么就在原來的位置,要么就被分配到"原位置+舊容量"這個位置。

例如我們從16擴展為32時,具體的變化如下所示:

 

 

 因此元素在重新計算hash之后,因為n變為2倍,那么n-1的標記范圍在高位多1bit(紅色),因此新的index就會發生這樣的變化

說明:

5是假設計算出來的原來的索引。這樣就驗證了上述所描述的:擴容之后所以結點要么就在原來的位置,要么就被分配到"原位置+舊容量"這個位置。

因此,我們在擴充HashMap的時候,不需要重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就可以了,是0的話索引沒變,是1的話索引變成“原索引+oldCap(原位置+舊容量)”。可以看看下圖為16擴充為32的resize示意圖:   
                           

正是因為這樣巧妙的rehash方式,既省去了重新計算hash值的時間,而且同時,由於新增的1bit是0還是1可以認為是隨機的,在resize的過程中保證了rehash之后每個桶上的結點數一定小於等於原來桶上的結點數,保證了rehash之后不會出現更嚴重的hash沖突,均勻的把之前的沖突的結點分散到新的桶中了。

源碼resize方法的解讀

下面是代碼的具體實現:

final Node<K,V>[] resize() {
    // 得到當前數組
    Node<K,V>[] oldTab = table;
    // 如果當前數組等於null長度返回0,否則返回當前數組的長度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //當前閥值點 默認是12(16*0.75)
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 如果老的數組長度大於0
    // 開始計算擴容后的大小
    if (oldCap > 0) {
        // 超過最大值就不再擴充了,就只好隨你碰撞去吧
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 修改閾值為int的最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        /*
            沒超過最大值,就擴充為原來的2倍
            1) (newCap = oldCap << 1) < MAXIMUM_CAPACITY 擴大到2倍之后容量要小於最大容量
            2)oldCap >= DEFAULT_INITIAL_CAPACITY 原數組長度大於等於數組初始化長度16
        */
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 閾值擴大一倍
            newThr = oldThr << 1; // double threshold
    }
    // 老閾值點大於0 直接賦值
    else if (oldThr > 0) // 老閾值賦值給新的數組長度
        newCap = oldThr;
    else { // 直接使用默認值
        newCap = DEFAULT_INITIAL_CAPACITY;//16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 計算新的resize最大上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 新的閥值 默認原來是12 乘以2之后變為24
    threshold = newThr;
    // 創建新的哈希表
    @SuppressWarnings({"rawtypes","unchecked"})
    //newCap是新的數組長度--》32
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 判斷舊數組是否等於空
    if (oldTab != null) {
        // 把每個bucket都移動到新的buckets中
        // 遍歷舊的哈希表的每個桶,重新計算桶里元素的新位置
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 原來的數據賦值為null 便於GC回收
                oldTab[j] = null;
                // 判斷數組是否有下一個引用
                if (e.next == null)
                    // 沒有下一個引用,說明不是鏈表,當前桶上只有一個鍵值對,直接插入
                    newTab[e.hash & (newCap - 1)] = e;
                //判斷是否是紅黑樹
                else if (e instanceof TreeNode)
                    // 說明是紅黑樹來處理沖突的,則調用相關方法把樹分開
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 采用鏈表處理沖突
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 通過上述講解的原理來計算結點的新位置
                    do {
                        // 原索引
                        next = e.next;
                         // 這里來判斷如果等於true e這個結點在resize之后不需要移動位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
4.3.4 刪除方法remove()

刪除的話就是首先先找到元素的位置,如果是鏈表就遍歷鏈表找到元素之后刪除。如果是用紅黑樹就遍歷樹然后找到之后做刪除,樹小於6的時候要轉鏈表。

刪除remove()方法:

// remove方法的具體實現在removeNode方法中,所以我們重點看下removeNode方法
public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

removeNode()方法:

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 根據hash找到位置 
    // 如果當前key映射到的桶不為空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 如果桶上的結點就是要找的key,則將node指向該結點
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            // 說明結點存在下一個結點
            if (p instanceof TreeNode)
                // 說明是以紅黑樹來處理的沖突,則獲取紅黑樹要刪除的結點
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 判斷是否以鏈表方式處理hash沖突,是的話則通過遍歷鏈表來尋找要刪除的結點
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        // 比較找到的key的value和要刪除的是否匹配
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            // 通過調用紅黑樹的方法來刪除結點
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                // 鏈表刪除
                tab[index] = node.next;
            else
                p.next = node.next;
            // 記錄修改次數
            ++modCount;
            // 變動的數量
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}
4.3.5 查找元素方法get()

查找方法,通過元素的Key找到Value。

代碼如下:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

get方法主要調用的是getNode方法,代碼如下:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 如果哈希表不為空並且key對應的桶上不為空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        /* 
            判斷數組元素是否相等
            根據索引的位置檢查第一個元素
            注意:總是檢查第一個元素
        */
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 如果不是第一個元素,判斷是否有后續結點
        if ((e = first.next) != null) {
            // 判斷是否是紅黑樹,是的話調用紅黑樹中的getTreeNode方法獲取結點
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                // 不是紅黑樹的話,那就是鏈表結構了,通過循環的方法判斷鏈表中是否存在該key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

小結:

  1. get方法實現的步驟:
    a. 通過hash值獲取該key映射到的桶
    b. 桶上的key就是要查找的key,則直接找到並返回
    c. 桶上的key不是要找的key,則查看后續的結點:
    如果后續結點是紅黑樹結點,通過調用紅黑樹的方法根據key獲取value
    如果后續結點是鏈表結點,則通過循環遍歷鏈表根據key獲取value

        2 . 上述紅黑樹結點調用的是getTreeNode方法通過樹形結點的find方法進行查找:

 final TreeNode<K,V> getTreeNode(int h, Object k) {
            return ((parent != null) ? root() : this).find(h, k, null);
 }
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
    TreeNode<K,V> p = this;
    do {
        int ph, dir; K pk;
        TreeNode<K,V> pl = p.left, pr = p.right, q;
        if ((ph = p.hash) > h)
            p = pl;
        else if (ph < h)
            p = pr;
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p; // 找到之后直接返回
        else if (pl == null)
            p = pr;
        else if (pr == null)
            p = pl;
        else if ((kc != null ||
                  (kc = comparableClassFor(k)) != null) &&
                 (dir = compareComparables(kc, k, pk)) != 0)
            p = (dir < 0) ? pl : pr;
        // 遞歸查找
        else if ((q = pr.find(h, k, kc)) != null)
            return q;
        else
            p = pl;
    } while (p != null);
    return null;
}

  3. 查找紅黑樹,由於之前添加時已經保證這個樹是有序的了,因此查找時基本就是折半查找,效率更高。
  4. 這里和插入時一樣,如果對比結點的哈希值和要查找的哈希值相等,就會判斷key是否相等,相等就直接返回。不相等就從子樹中遞歸查找。
  4.1 若為樹,則在樹中通過key.equals(k)查找,O(logn)
  4.2 若為鏈表,則在鏈表中通過key.equals(k)查找,O(n)。

4.3.6 遍歷HashMap集合幾種方式

1.分別遍歷Key和Values

for (String key : map.keySet()) {
    System.out.println(key);
}
for (Object vlaue : map.values() {
    System.out.println(value);
}

2.使用Iterator迭代器迭代

Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, Object> mapEntry = iterator.next();
    System.out.println(mapEntry.getKey() + "---" + mapEntry.getValue());
}

3.通過get方式(不建議使用)

Set<String> keySet = map.keySet();
for (String str : keySet) {
    System.out.println(str + "---" + map.get(str));
}

說明:根據阿里開發手冊,不建議使用這種方式,因為迭代兩次。keySet獲取Iterator一次,還有通過get又迭代一次,降低性能。

4.jdk8以后使用Map接口中的默認方法

default void forEach(BiConsumer<? super K,? super V> action) 
// BiConsumer接口中的方法:
    void accept​(T t, U u) 對給定的參數執行此操作。  
        參數 
            t - 第一個輸入參數 
            u - 第二個輸入參數 

遍歷代碼:

HashMap<String,String> map = new HashMap();
map.put("001", "zhangsan");
map.put("002", "lisi");
map.forEach((key, value) -> {
    System.out.println(key + "---" + value);
});

五、如何設計多個非重復的鍵值對要存儲HashMap的初始化?


5.1 HashMap的初始化問題描述

  • 如果我們確切的知道我們有多少鍵值對需要存儲,那么我們在初始化HashMap的時候就應該指定它的容量,以防止HashMap自動擴容,影響使用效率。
  • 默認情況下HashMap的容量是16,但是,如果用戶通過構造函數指定了一個數字作為容量,那么Hash會選擇大於該數字的第一個2的冪作為容量(3->4、7->8、9->16)。這點我們在上述已經進行過講解。

《阿里巴巴Java開發手冊》中建議我們設置HashMap的初始化容量。

 

 

那么,為什么要這么建議?你有想過沒有。

  • 當然,以上建議也是有理論支撐的。我們上面介紹過,HashMap的擴容機制,就是當達到擴容條件時會進行擴容。HashMap的擴容條件就是當HashMap中的元素個數(size)超過臨界值(threshold)時就會自動擴容。在HashMap中,threshold = loadFactor * capacity。
  • 所以,如果我們沒有設置初始容量大小,隨着元素的不斷增加,HashMap會有可能發生多次擴容,而HashMap中的擴容機制決定了每次擴容都需要重建hash表,是非常影響性能的。
  • 但是設置初始化容量,設置的數值不同也會影響性能,那么當我們已知HashMap中即將存放的KV個數的時候,容量設置成多少為好呢?

5.2 HashMap中容量的初始化

  • 當我們使用HashMap(int initialCapacity)來初始化容量的時候,jdk會默認幫我們計算一個相對合理的值當做初始容量。那么,是不是我們只需要把已知的HashMap中即將存放的元素個數直接傳給initialCapacity就可以了呢?

  • 也就是說,如果我們設置的默認值是7,經過Jdk處理之后,會被設置成8,但是,這個HashMap在元素個數達到 8*0.75 = 6的時候就會進行一次擴容,這明顯是我們不希望見到的。我們應該盡量減少擴容。原因也已經分析過。
  • 如果我們通過initialCapacity/ 0.75F + 1.0F計算,7/0.75 + 1 = 10 ,10經過Jdk處理之后,會被設置成16,這就大大的減少了擴容的幾率。
  • 當HashMap內部維護的哈希表的容量達到75%時(默認情況下),會觸發rehash,而rehash的過程是比較耗費時間的。所以初始化容量要設置成initialCapacity/0.75 + 1的話,可以有效的減少沖突也可以減小誤差。
  • 所以,我可以認為,當我們明確知道HashMap中元素的個數的時候,把默認容量設置成initialCapacity/ 0.75F + 1.0F是一個在性能上相對好的選擇,但是,同時也會犧牲些內存。
  • 我們想要在代碼中創建一個HashMap的時候,如果我們已知這個Map中即將存放的元素個數,給HashMap設置初始容量可以在一定程度上提升效率。
  • 但是,JDK並不會直接拿用戶傳進來的數字當做默認容量,而是會進行一番運算,最終得到一個2的冪。原因也已經分析過。
  • 但是,為了最大程度的避免擴容帶來的性能消耗,我們建議可以把默認容量的數字設置成initialCapacity/ 0.75F + 1.0F。

 

本文參考來源於:掘金

 


免責聲明!

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



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