HashMap集合(高級)


1.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,才將鏈表轉換為紅黑樹,變為紅黑樹的目的是為了高效的查詢。

2.HashMap集合底層的數據結構

2.1數據結構概念

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

數據結構:就是存儲數據的一種方式。ArrayList LinkedList

在JDK1.8 之前 HashMap 由 數組+鏈表 數據結構組成的。

在JDK1.8 之后 HashMap 由 數組+鏈表 +紅黑樹數據結構組成的。

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

存儲過程如下所示:

使用的代碼:

public class Demo01 {
    public static void main(String[] args) {
        HashMap<String, Integer> map = new HashMap<>();
        map.put("劉德華", 53);
        map.put("柳岩", 35);
        map.put("張學友", 55);
        map.put("郭富城", 52);
        map.put("黎明", 51);
        map.put("林青霞", 55);
        map.put("劉德華", 50);
    }
}

2.面試題:當兩個對象的hashCode相等時會怎么樣?

會產生哈希碰撞,此時會調用對象的equals方法比較key值,若key值內容相同則替換舊的value.不然連接到鏈表后面,鏈表長度超過閾值8就轉換為紅黑樹存儲。

3.面試題:何時發生哈希碰撞和什么是哈希碰撞,如何解決哈希碰撞?

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

4.面試題:如果兩個鍵的hashcode相同,如何存儲鍵值對?

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

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

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

JDK 1.8 以前 HashMap 的實現是 數組+鏈表,即使哈希函數取得再好,也很難達到元素百分百均勻分布。當 HashMap 中有大量的元素都存放到同一個桶中時,這個桶下有一條長長的鏈表,這個時候 HashMap 就相當於一個單鏈表,假如單鏈表有 n 個元素,遍歷的時間復雜度就是 O(n),完全失去了它的優勢。針對這種情況,JDK 1.8 中引入了 紅黑樹(查找時間復雜度為 O(logn))來優化這個問題。 當鏈表長度很小的時候,即使遍歷,速度也非常快,但是當鏈表長度不斷變長,肯定會對查詢性能有一定的影響,所以才需要轉成樹。

至於為什么閾值是8,我想,去源碼中找尋答案應該是最可靠的途徑。 下面我們在分析源碼的時候會介紹。

7.總結:

上述我們大概闡述了HashMap底層存儲數據的方式。為了方便大家更好的理解,我們結合一個存儲流程圖來進一步說明一下:(jdk8存儲過程)

 

說明:

1.size表示 HashMap中K-V的實時數量 , 注意這個不等於數組的長度 。

2.threshold( 臨界值) =capacity(容量) * loadFactor( 加載因子 )。這個值是當前已占用數組長度的最大值。size超過這個臨界值就重新resize(擴容),擴容后的 HashMap 容量是之前容量的兩倍 。

3.HashMap繼承關系

HashMap繼承關系如下圖所示:

說明:

  • Cloneable 空接口,表示可以克隆。 創建並返回HashMap對象的一個副本。

  • Serializable 序列化接口。屬於標記性接口。HashMap對象可以被序列化和反序列化。

  • AbstractMap 父類提供了Map實現接口。以最大限度地減少實現此接口所需的工作。

補充:通過上述繼承關系我們發現一個很奇怪的現象, 就是HashMap已經繼承了AbstractMap而AbstractMap類實現了Map接口,那為什么HashMap還要在實現Map接口呢?同樣在ArrayList中LinkedList中都是這種結構。

據 java 集合框架的創始人Josh Bloch描述,這樣的寫法是一個失誤。在java集合框架中,類似這樣的寫法很多,最開始寫java集合框架的時候,他認為這樣寫,在某些地方可能是有價值
的,直到他意識到錯了。顯然的,JDK的維護者,后來不認為這個小小的失誤值得去修改,所以就這樣存在下來了。

4.HashMap集合類的成員

1.序列化版本號

private static final long serialVersionUID = 362498820763181265L;

2.集合的初始化容量( 必須是二的n次冪 )

//默認的初始容量是16 -- 1<<4相當於1*2的4次方---1*16
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次冪。

為什么這樣能均勻分布減少碰撞呢?2的n次方實際就是1后面n個0,2的n次方-1 實際就是n個1;

舉例:說明:按位與運算:相同的二進制數位上,都是1的時候,結果為1,否則為零。

例如長度為8時候,3&(8-1)=3  2&(8-1)=2 ,不同位置上,不碰撞;
例如長度length為8時候,8是2的3次冪。二進制是:1000
length-1 二進制運算:
    1000
-       1
---------------------
     111
如下所示:
hash&(length-1)
3   &(8    - 1)=3  
    00000011  3 hash
&   00000111  7 length-1
---------------------
    00000011-----》3 數組下標
    
hash&(length-1)
2 &  (8 -    1) = 2  
    00000010  2 hash
&   00000111  7 length-1
---------------------
    00000010-----》2  數組下標
說明:上述計算結果是不同位置上,不碰撞;
2的n次冪不碰撞
例如長度為9時候,3&(9-1)=0  2&(9-1)=0 ,都在0上,碰撞了;
例如長度length為9時候,9不是2的n次冪。二進制是:00001001
length-1 二進制運算:
    1001
-       1
---------------------
    1000
如下所示:
hash&(length-1)
3   &(9    - 1)=0  
    00000011  3 hash
&   00001000  8 length-1 
---------------------
    00000000-----》0  數組下標
    
hash&(length-1)
2 &  (9 -    1) = 2  
    00000010 2 hash
&   00001000 8 length-1 
---------------------
    00000000-----》0  數組下標
說明:上述計算結果都在0上,碰撞了;
非2的n次冪碰撞

注意: 當然如果不考慮效率直接求余即可(就不需要要求長度必須是2的n次方了)

小結:

1.由上面可以看出,當我們根據key的hash確定其在數組的位置時,如果n為2的冪次方,可以保證數據的均勻插入,如果n不是2的冪次方,可能數組的一些位置永遠不會插入數據,浪費數組的空間,加大hash沖突。

2.另一方面,一般我們可能會想通過 % 求余來確定位置,這樣也可以,只不過性能不如 & 運算。而且當n是2的冪次方時:hash & (length - 1) == hash % length

3.因此,HashMap 容量為2次冪的原因,就是為了數據的的均勻分布,減少hash沖突,畢竟hash沖突越大,代表數組中一個鏈的長度越大,這樣的話會降低hashmap的性能

4.如果創建HashMap對象時,輸入的數組長度是10,不是2的冪,HashMap通過一通位移運算和或運算得到的肯定是2的冪次數,並且是離那個數最近的數字。

源代碼如下:

//創建HashMap集合的對象,指定數組長度是10,不是2的冪
HashMap hashMap = new HashMap(10);
public HashMap(int initialCapacity) {//initialCapacity=10
   this(initialCapacity, DEFAULT_LOAD_FACTOR);
 }
public HashMap(int initialCapacity, float loadFactor) {//initialCapacity=10
     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;
    this.threshold = tableSizeFor(initialCapacity);//initialCapacity=10
}
  /**
   * Returns a power of two size for the given target capacity.
  */
    static final int tableSizeFor(int cap) {//int cap = 10
        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轉換源碼

說明:

由此可以看到,當在實例化HashMap實例時,如果給定了initialCapacity(假設是10),由於HashMap的capacity必須都是2的冪,因此這個方法用於找到大於等於initialCapacity(假設是10)的最小的2的冪(initialCapacity如果就是2的冪,則返回的還是這個數)。 下面分析這個算法: 1)、首先,為什么要對cap做減1操作。int n = cap - 1; 這是為了防止,cap已經是2的冪。如果cap已經是2的冪, 又沒有執行這個減1操作,則執行完后面的幾條無符號右移操作之后,返回的capacity將是這個cap的2倍。如果不懂,要看完后面的幾個無符號右移之后再回來看看。 下面看看這幾個無符號右移操作: 2)、如果n這時為0了(經過了cap-1之后),則經過后面的幾次無符號右移依然是0,最后返回的capacity是 1(最后有個n+1的操作)。 這里只討論n不等於0的情況。

3)、注意:|(按位或運算):運算規則:相同的二進制數位上,都是0的時候,結果為0,否則為1。

第一次右移

int n = cap - 1;//cap=10  n=9
n |= n >>> 1;
    00000000 00000000 00000000 00001001 //9
|    
    00000000 00000000 00000000 00000100 //9右移之后變為4
-------------------------------------------------
    00000000 00000000 00000000 00001101 //按位異或之后是13

由於n不等於0,則n的二進制表示中總會有一bit為1,這時考慮最高位的1。通過無符號右移1位,則將最高位的1右移了1位,再做或操作,使得n的二進制表示中與最高位的1緊鄰的右邊一位也為1,如:

00000000 00000000 00000000 00001101

第二次右移

 n |= n >>> 2;//n通過第一次右移變為了:n=13
    00000000 00000000 00000000 00001101  // 13
|
    00000000 00000000 00000000 00000011  //13右移之后變為3
-------------------------------------------------
    00000000 00000000 00000000 00001111 //按位異或之后是15

注意,這個n已經經過了n |= n >>> 1; 操作。假設此時n為00000000 00000000 00000000 00001101 ,則n無符號右移兩位,會將最高位兩個連續的1右移兩位,然后再與原來的n做或操作,這樣n的二進制表示的高位中會有4個連續的1。如:  

00000000 00000000 00000000 00001111 //按位異或之后是15

第三次右移 :

n |= n >>> 4;//n通過第一、二次右移變為了:n=15
    00000000 00000000 00000000 00001111  // 15
|
    00000000 00000000 00000000 00000000  //15右移之后變為0
-------------------------------------------------
    00000000 00000000 00000000 00001111 //按位異或之后是15

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

 

 

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

this.threshold = tableSizeFor(initialCapacity);//initialCapacity=10

3.默認的負載因子,默認值是0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;

4.集合最大容量

//集合最大容量的上限是:2的30次冪
static final int MAXIMUM_CAPACITY = 1 << 30;

5.當鏈表的值超過8則會轉紅黑樹(1.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_THRESHOLD的值決定的。當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這個數字。

6.當鏈表的值小於6則會從紅黑樹轉回鏈表

//當桶(bucket)上的結點數小於這個值時樹轉鏈表
 static final int UNTREEIFY_THRESHOLD = 6;

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

//桶中結構轉化為紅黑樹對應的數組長度最小的值 
static final int MIN_TREEIFY_CAPACITY = 64;

8、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>。負責存儲鍵值對數據的。

 

9、用來存放緩存

/存放具體元素的集合
transient Set<Map.Entry<K,V>> entrySet;

10、 HashMap中存放元素的個數(重點)

//存放元素的個數,注意這個不等於數組的長度。
 transient int size;

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

11、 用來記錄HashMap的修改次數

// 每次擴容和更改map結構的計數器
transient int modCount;  

12、 用來調整大小下一個容量的值計算方式為(容量*負載因子)

// 臨界值 當實際大小(容量*負載因子)超過臨界值時,會進行擴容
int threshold;

13、 哈希表的加載因子(重點)

// 加載因子
final float loadFactor;

說明:

1.loadFactor加載因子,是用來衡量 HashMap 滿的程度,表示HashMap的疏密程度,影響hash操作到同一個數組位置的概率,計算HashMap的實時加載因子的方法為:size/capacity,而不是占用桶的數量去除以capacity。capacity 是桶的數量,也就是 table 的長度length。

loadFactor太大導致查找元素效率低,太小導致數組的利用率低,存放的數據會很分散。loadFactor的默認值為0.75f是官方給出的一個比較好的臨界值

當HashMap里面容納的元素已經達到HashMap數組長度的75%時,表示HashMap太擠了,需要擴容,而擴容這個過程涉及到 rehash、復制數據等操作,非常消耗性能。,所以開發中盡量減少擴容的次數,可以通過創建HashMap集合對象時指定初始容量來盡量避免。

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

構造方法:
HashMap(int initialCapacity, float loadFactor) 構造一個帶指定初始容量和加載因子的空 HashMap。

 

2.為什么加載因子設置為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-》2的30次冪
       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;
  /*
  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重新計算,put方法的具體實現我們下面會進行講解
  */
       this.threshold = tableSizeFor(initialCapacity);
  }
最后調用了tableSizeFor,來看一下方法實現:
    /**
    * Returns a power of two size for the given target capacity.
      返回比指定初始化容量大的最小的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;
  }
構造方法源碼

 

說明:

對於 this.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重新計算,put方法的具體實現我們下面會進行講解

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是否已經初始化
      { // pre-size
               // 未初始化,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方法是比較復雜的,實現步驟大致如下:

1)先通過hash值計算出key映射到哪個桶;

2)如果桶上沒有碰撞沖突,則直接插入;

3)如果出現碰撞沖突了,則需要處理沖突:

a:如果該桶使用紅黑樹處理沖突,則調用紅黑樹的方法插入數據;

b:否則采用傳統的鏈式方法插入。如果鏈的長度達到臨界值,則把鏈轉變為紅黑樹;

4)如果桶中存在重復的鍵,則為該鍵替換新值value;

5)如果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:
    可以看到當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為空會拋異常。

{其實上面就已經解釋了為什么HashMap的長度為什么要是2的冪因為HashMap 使用的方法很巧妙,它通過 hash & (table.length -1)來得到該對象的保存位,前面說過 HashMap 底層數組的長度總是2的n次方,這是HashMap在速度上的優化。當 length 總是2的n次方時,hash & (length-1)運算等價於對 length 取模,也就是hash%length,但是&比%具有更高的效率。比如 n % 32 = n & (32 -1)。}

 

解讀上述hash方法:

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

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

 static final int hash(Object key) 
{
       int h;
    /*
    1)如果key等於null:
    可以看到當key等於null的時候也是有哈希值的,返回的是0.
    2)如果key不等於null:
    首先計算出key的hashCode賦值給h,然后與h無符號右移16位后的二進制進行按位異或得到最后的hash值
    */
       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。

 

 

 簡單來說就是:

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

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

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

    例如上述:
    hashCode()值:     1111 1111 1111 1111 1111 0000 1110 1010
    &
    n-1即16-1--》15:  。。。。。。。。。。。。。。。。。。。。。。1111
    -------------------------------------------------------------------
     0000 0000 0000 0000 0000 0000 0000 1010 ----》10作為索引
    其實就是將hashCode值作為數組索引,那么如果下個高位hashCode不一致,低位一致的話,就會造成計算的索引還是10,從而造成了哈希沖突了。降低性能。

     

  • (n-1) & hash = -> 得到下標 (n-1) n表示數組長度16,n-1就是15

  • 取余數本質是不斷做除法,把剩余的數減去,運算效率要比位運算低。

 

現在看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方法如下所示:

 

  /**
   * Replaces all linked nodes in bin at index for given hash unless
   * table is too small, in which case resizes instead.
     替換指定哈希表的索引處桶中的所有鏈接節點,除非表太小,否則將修改大小。
     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)
                    //將新創鍵的p節點賦值給紅黑樹的頭結點
                    hd = p;
                else {
                    /*
                         p.prev = tl:將上一個節點p賦值給現在的p的前一個節點
                         tl.next = p;將現在節點p作為樹的尾結點的下一個節點
                    */
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
                /*
                    e = e.next 將當前節點的下一個節點賦值給e,如果下一個節點不等於null
                    則回到上面繼續取出鏈表中節點轉換為紅黑樹
                */
            } while ((e = e.next) != null);
            /*
                讓桶中的第一個元素即數組中的元素指向新建的紅黑樹的節點,以后這個桶里的元素就是紅黑樹
                而不是鏈表數據結構了
            */
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
源碼

 

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

1.根據哈希表中元素個數確定是擴容還是樹形化

2.如果是樹形化遍歷桶中的元素,創建相同個數的樹形節點,復制內容,建立起聯系

3.然后讓桶中的第一個元素指向新創建的樹根節點,替換桶的鏈表內容為樹形化內容

 

4.3.3擴容方法_resize

4.3.3.1擴容機制

想要了解HashMap的擴容機制你要有這兩個問題

  • 1.什么時候才需要擴容

  • 2.HashMap的擴容是什么

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沖突,均勻的把之前的沖突的節點分散到新的桶中了。

4.3.3.2源碼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)

理解了put方法之后,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方法實現的步驟:

1)通過hash值獲取該key映射到的桶

2)桶上的key就是要查找的key,則直接找到並返回

3)桶上的key不是要找的key,則查看后續的節點:

a:如果后續節點是紅黑樹節點,通過調用紅黑樹的方法根據key獲取value

b:如果后續節點是鏈表節點,則通過循環遍歷鏈表根據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是否相等,相等就直接返回。不相等就從子樹中遞歸查找。

5.

若為樹,則在樹中通過key.equals(k)查找,O(logn)

若為鏈表,則在鏈表中通過key.equals(k)查找,O(n)。

1、分別遍歷Key和Values

2、使用Iterator迭代器迭代

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

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

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

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

遍歷代碼:

public class Demo02 {
   public static void main(String[] args) {
       HashMap<String,String> m1 = new HashMap();
       m1.put("001", "zhangsan");
       m1.put("002", "lisi");
       m1.forEach((key,value)->{
           System.out.println(key+"---"+value);
      });
  }
}

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

5.1HashMap的初始化問題描述

如果我們確切的知道我們有多少鍵值對需要存儲,那么我們在初始化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.2HashMap中容量的初始化

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

關於這個值的設置,在《阿里巴巴Java開發手冊》有以下建議:

 

 也就是說,如果我們設置的默認值是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

 

 

 

 

 

 

//桶中結構轉化為紅黑樹對應的數組長度最小的值 static final int MIN_TREEIFY_CAPACITY = 64;


免責聲明!

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



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