[Java系列]搞懂HashMap,看這篇足夠!!(萬字長文/詳細深入)

本文主要是從jdk源碼入手, 結合常用操作, 圖文並茂, 探討Java中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時,鏈表轉換爲紅黑樹時,效率也變得更高效.

在這裏插入圖片描述

HashMap特點:

  1. 存取無序
  2. 鍵和值都可以是null,但是鍵位置只能是一個null
  3. 鍵位置是唯一的,底層的數據結構控制鍵的
  4. jdk1.8 之前數據結構是: 鏈表+數組
    jdk1.8之後是: 鏈表+數組+紅黑樹
  5. 閾值 > 8 and 數組長度大於64,纔將鏈表轉換爲紅黑樹,變爲紅黑樹的目的是爲了更高效地查詢

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

2.1 數據結構

  • jdk1.8之前 HashMap由 數組+鏈表 數據結構組成
  • jdk1.8之後 HashMap由 數組+鏈表+紅黑樹 數據結構組成

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

pulic static void main(String[] args){
    //創建HashMap集合對象
    HashMap<String, Integer> hm = new HashMap<>();
    hm.put("柳巖",18);
    hm.put("楊冪",28);
    hm.put("劉德華",40);
    //hm.put("柳巖",18);
    hm.put("柳巖",20);
    System.out.println(hm);
}
{楊冪=28, 柳巖=20, 劉德華=40}

在這裏插入圖片描述

  1. HashMap<String,Integer> hm = new HashMap<>();

    當創建HashMap集合對象的時候.

    • jdk8之前: 構造方法中創建一個長度爲16的Entry[] table 用來存儲鍵值對數據的.
    • jdk8之後: 構造方法中不創建數組了,而是在第一次調用put方法時創建的數組 Node[] table 用來存儲鍵值對數據的
  2. 假設向hm中存儲柳巖-18 數據,根據柳巖調用String類中重寫hashCode()方法計算出值, 然後結合數組長度採用某種算法(散列算法)計算出向Node數組中存儲數據的空間的索引值.

    如果計算出的索引空間沒有數據,則直接將柳巖-18存儲到數組中, 舉例:計算出的索引位3

    面試題: 哈希表底層採用何種算法計算hash值? 還有哪些算法可以計算出hash值?
    
    底層採用的key的hashCode方法的值結合數組長度進行無符號右移(>>>),按位異或^,按位與& 計算出索引號
    還可以採用: 平方取中法,取餘數,僞隨機法
    10%8 ==> 2, 11%8 ==>3
    
    
  3. 向哈希表中存儲數據劉德華-40 ,假設"劉德華"計算出的hashCode方法結合數組長度計算出的索引值爲3,那麼此時數組空間不是null,此時底層會比較"柳巖""劉德華"的hash值是否一致, 若不一致,則在此空間上劃出一個結點來存儲鍵值對數據劉德華-40 (拉鍊法)

  4. 假設向哈希表中存儲數據柳巖-20,那麼首先根據柳巖調用hashCode方法結合數組長度計算出的索引肯定是3. 此時比較後存儲的數據柳巖 和已經存在的數據的hash值是否相等, 如果hash值相等,此時發生哈希碰撞
    那麼底層會調用柳巖所屬類String 的equals方法比較兩個內容是否相等:

    相等: 則將後面添加的數據的value覆蓋之前的value
    不相等: 那麼繼續向下和其他的數據的key進行比較,若都不相等, 則劃出一個結點存儲數據

    哪怕string不同也有可能hashCode方法值相等:

    String a = "重地";
    String b = "通話";
    System.out.println(a.hashCode()+ " " + b.hashCode());	
    System.out.println(a.equals(b));
    /*
    1179395 1179395
    false
    */
    

如果結點個數(鏈表長度)大於閾值8並且數組長度大於64 則將鏈表變爲 紅黑樹.

2.  當兩個對象的hashCode相等會怎麼樣?
產生衝突(哈希碰撞),如key值內容相同則替換就得value值,不然連接到鏈表後面,鏈表長度超過閾值8就轉換爲紅黑樹存儲.

3.  何時發生哈希碰撞和什麼是哈希碰撞?
只要兩個元素的key計算的hashcode相同就會發生衝突.
jdk8前使用鏈表解決哈希碰撞.jdk8後使用鏈表+紅黑樹解決

4.   如果兩個鍵的hashcode相同,如何存儲鍵值對?
	hashCode相等. 通過equals方法比較內容是否相等.
	相同: 則新的value覆蓋老的value值
	不想同: 則將新的鍵值對添加到哈希表中.

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

通過上述描述,當位於一個鏈表中元素衆多,即hash值相等但是內容不等的元素較多時,通過key值依次查找的效率較低. 而jdk1.8中,哈希表存儲在鏈表長度大於8並且數組長度大於64時將鏈表轉換爲紅黑樹.jdk8在hash表中引入紅黑樹主要是爲了 查找效率更更高.

傳統HashMap的缺點,1.8爲什麼引入紅黑樹? 這樣結構不就變得更麻煩了嘛? 爲何閾值大於8才換成紅黑樹?

1.8之前HashMap的實現是數組+鏈表, 即使哈希函數取得再好,也很難達到元素的百分百均勻分佈.當HashMap中有大量的元素都放在同一個桶中時,這個桶下有一條長長的鏈表, 這個時候HashMap就相當於一個單鏈表, 假如單鏈表有n個元素, 遍歷的時間就是O(n).
1.8爲解決這一問題, 使用 `紅黑樹(查找時間複雜度爲O(logn)) 來優化這個問題.當鏈表長度很小的時候,即使遍歷,速度也很快,但是當鏈表長度不斷變長,對查詢也存在影響.

一些說明:

  • size 表示HashMap中K-V的實時數量, 注意這個不等於數組的長度.
  • threshold(臨界值) = capacity(容量) * loaFactor(加載因子). 這個值是當前已佔用數組長度的最大值. size 超過這個臨界值就會重新 reszie . 擴容後的HashMap容量是之前容量的兩倍.

3. HashMap的繼承關係

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

在這裏插入圖片描述

說明

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

補充: 爲甚HashMap基礎AbstractMap而AbstractMap類實現了Map接口, 那爲啥HashMap還要去實現Map接口呢? 同樣ArrayList也是如此.

這是一個失誤. 最開始寫Java框架時, 以爲會有一些1價值, 直到其意識到毫無價值.

4 HashMap 集合類的成員

4.1 成員變量

1. 序列化版本號

private static final long serialVersionUID = 362498820763181265L;

由於實現了序列化接口, 所以需要一個默認的序列化版本號.

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

/**
  * The default initial capacity - MUST be a power of two.
  */
// 1<< 4 相當於 1*(2^4)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

問題: 爲啥是2的次冪?如果輸入的值並非2的n次冪而是比如10 會怎樣?

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

public 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, 否則爲0

例如長度爲8:
    3	&	(8-1) = 3
    0000 0011
    0000 0111
    ----------
    0000 0011

    13	&	(8-1) = 5
    0000 1101
    0000 0111
    ---------
    0000 0101	

例如長度爲9:
    3	&	(9-1)
    0000 0011
    0000 1000
    ---------
    0

    2	&	(9-1)
    0000 0010
    0000 1000
    ---------
    0			碰撞,而當length爲8時不會

    13	&	(9-1)
    0000 1101
    0000 1000
    ---------
    0000 1000

如果不是2的n次冪,計算出的索引特別容易相同, 及其容易發生哈希碰撞,造成其餘數組空間很大程度上並沒有存儲數據,鏈表或者紅黑樹過長,效率較低

小結:

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

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

  3. 因此, HashMap容量爲2的n次方的原因,就是爲了數據的均勻分佈,減少hasn衝突. 畢竟hash衝突也多,代表數組中的一個鏈的長度就會越大,這樣的話會降低hashmap的性能.

  4. 如果創建的HashMap對象輸入的數組長度不是2的n次方時,HashMap會通過移位運算和或運算得到2的n次方數, 並且是距離那個數最近的數字(比如輸入10, 獲得16), 源代碼如下:

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)	//最大2^30
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    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;
    }
    

    說明:

    如果給定了initialCapacity(假設爲10), 由於HashMap的capacity必須都是2的冪,因此這個方法用於找到大於等於initialCapacity的最小的2的次冪(此處爲16),然後返回.下面分析這個算法:

    1. 爲什麼要對cap減1操作呢? int n = cap - 1;

      這是爲了防止,cap本身就是2的n次冪, 若不進行此操作,則執行完該方法則會得到這個cap的二倍,比如輸入8, 不進行-1的話返回16

    2. 現在來看這些個無符號右移. 若果n這時爲0了(經過了cap-1),則經過後面幾次無符號右移依然是0,最後返回capacity的值爲1(最後有個n+1的操作). 這裏討論不爲0的情況.

    3. 注意: | 按位或運算: 相同位置上都是0的時候才爲0, 否則爲1

      cap = 10
      int n =cap-1; == > 9
      n |= n >>> 1
      00000000 00000000 00000000 00001001	9 >>> 1 
      00000000 00000000 00000000 00000100	4
      --------------------------------------
      00000000 00000000 00000000 00001101 13 	 最高位右邊相鄰位爲1
      
      n=13
      n |= n >>> 2
      00000000 00000000 00000000 00001101 13 >>>2
      00000000 00000000 00000000 00000011 3
      ---------------------------------------
      00000000 00000000 00000000 00001111 15  最高兩位右邊相鄰兩位爲1 -- 此時最高4位爲1
      
      n=15
      00000000 00000000 00000000 00001111 15 >>> 4
      00000000 00000000 00000000 00000000 0
      ----------------------------------------
      00000000 00000000 00000000 00001111 15  最高位有8個連續的1, 但是這裏沒有8位,不變...
      

      以此類推, 容量最大也就是32bit的正數, 最後一次 >>> 16 將變爲連續的32個1(但這已經是負數了. 在執行tableSizeFor之前, 對initialCapacity做了判斷, 如果大於MAXIMUM_CAPACITY = 2^30 ,則取MAXIMUM_CAPACITY.

      所以這裏的移位操作之後,最大30個1,不會大於等於MAXIMUM_CAPACITY. 30個1,加1後爲2^30

      綜上, 10 變成 16就是這樣得到的~

4. 默認的負載因子, 默認值爲0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;

5. 集合最大容量

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

當鏈表的值超過8, 則會轉紅黑樹(1.8之後)

//當桶(bucket)上的結點數大於這個值時會轉成紅黑樹
static final int TREEIFY_THRESHOLD = 8

面試題: 爲什麼Map桶中結點個數超過8 才轉爲紅黑樹 ?

8 這個閾值定義在HashMap中, 在源碼註釋中只說明了8是bin(bin就是bucket桶)從鏈轉換成紅黑樹的閾值,但是並沒有說爲什麼是8:

在HashMap中174行有一段說明

* 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). 當他們變得太小(由於刪除或者調整)時,就會被轉換爲普通的桶. 在使用分佈良好的用戶HashCodes時, 很少使用樹箱.理想情況下,箱子中的結點的頻率服從泊松分佈

(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中結點變少時(長度降到6)就又轉爲普通bin.

這樣就解釋了爲什麼不是一開始就轉換爲TreeNodes, 而是需要一定結點數才轉爲TreeNodes,說白了就是權衡,空間和時間

這段內容還說: 當HashCode離散性很好時,樹形bin用到的概率很小,因爲數據均勻分佈在每個bin中,幾乎不會有bin中鏈表長度達到閾值. 但是在隨機hashcode下,離散性可能會變差,然而jdk又不能阻止用戶實現這種不好的hash算法,因此就可能導致不均勻的數據分佈.不過理想情況下隨機hashCode方法下所有bin中結點分佈頻率滿足泊松分佈.可以看到,一個bin中鏈表長度達到8個元素的概率爲0.00000006. 幾乎是不可能事件.所以,之所以選擇8,不是隨便決定的,而是根據概率統計得到.

簡而言之,選擇8是因爲符號泊松分佈,超過8的時候,概率已經非常小了.所以選擇8

另外還有如下說法:

紅黑樹的平均查找長度爲log(n), 如果長度爲8,平均查找長度爲log(8)=3,鏈表平均查找長度爲n/2,當長度爲8時,平均查找長度爲4,這纔有轉換爲樹的必要;鏈表長度若爲小於等於6.6/2=3,而log(6)=2.6,雖然速度也快些,但轉化爲樹和生成樹的時間並不會太短.

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

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

7.

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

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

8. table用來初始化(必須是2的n次冪)

重點

//存儲元素的數組
transient Node<K,V>[] table;

table 在jdk8中我們瞭解到HashMap是由數組加鏈表加紅黑樹來組成的結構. 其中tale就是HashMap中的數組,8之前爲

Entry<K,V>類型. 1.8之後只是換樂觀名字Node<K,V>,都實現一樣的接口: Map.Entry<K,V>負責村村鍵值對數據.

9. 用來存放緩存 (不那麼重要)

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

10. HashMap中存放元素的個數

重點

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

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

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

//每次擴容和更改HashMap的修改次數
transient int modCount;

12 . 用來調整大小下一個容量的閾值

計算方式爲(容量 *負載因子)

//臨界值 當實際大小([容量capatocy=16]*[負載因子0.75])超過臨界值[threshold]時,會進行擴容(翻倍)
int threshold;

13. 哈希表的加載因子

重點

//加載因子
final float loadFactor;

說明:

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

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

    當HashMap裏面容納的元素達到HashMap數組長度的0.75時,表示HashMap太擠,需要擴容,而這個過程涉及到rehash,數據複製等操作,非常消耗性能. 所以開發中儘量減少擴容次數,可以通過創建集合對象時指定初始容量來儘量避免.

    另外在HashMap構造器中也可以指定loadFactor

    面試題:爲啥默認0.75的threshold啊?
    0.4 那麼16*0.4 ---> 6  如果滿6個就進行擴容會造成數組利用率太低
    0.9 那麼16*0.9 ---> 14 那麼這樣導致鏈表有點多了,導查找元素效率低   
    
    • threshold計算公式: capacity(數組默認長度16)*loadFactor(負載因子默認0.75). 這個值是當前佔用數組長度的最大值.當Size >= threshold時,那麼就要考慮對數組進行擴容.也就是說,這個數用來衡量數組是否需要擴容的一個標準.

4.2 構造方法

HashMap中重要的構造方法如下:

1, 構造個空的HashMap,默認初始容量(16) 和默認負載因子(0.75)

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 默認因子0.75賦給loadFactor,並沒有創建數組
    }

2, 構造一個具有指定的初始容量和默認loadFactor的HashMap

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {	//指定容量
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

3, 構件一個具有指定初始容量和loadFactor的hashMap

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)	//大於 2^30
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))		//小於0或者不是一個小數
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;	//檢查完畢,局部變量傳給成員變量
        this.threshold = tableSizeFor(initialCapacity);//放進去10,threshold爲16,照理應該是12,之後put()會修改
    }

說明:對於 this.threshold = tableSizeFor(initialCapacity); 疑問解答:

tableSizeFor(initialCapacity)判斷指定的初始化容量時候爲2的n次冪,如果不是則變爲最小的離它最近的那個2的n次冪.這點前面已經講過.

但是注意,在taleSizeFor內部將計算後的數據直接返回賦給threshold,有人覺得應該這麼寫:

this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;

這樣才符合threshold的意思(當HashMap的Size達到threshold這個閾值時會擴容).

但是注意, 在jdk8以後的構造方法中,並沒有對table這個成員變量進行初始化,table的初始化被推遲到了put方法中**,在put方法會對threshold重新計算**,put方法的具體實現下面會繼續講解

4, 包含另一個Map的構造函數

public HashMap(Map<? extends K, ? extends V> m) { //將原來的集合m內容放在新的集合裏面
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {	//如果有數據,才進行拷貝
        if (table == null) { // pre-size
            float ft = ((float)s / loadFactor) + 1.0F; //思考這裏爲啥要 +1 
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold) //這裏threshold爲0
                threshold = tableSizeFor(t);
        }
        else if (s > threshold)
            resize();
        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; //思考這裏爲啥要 +1f?

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

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

4.3 HashMap的成員方法

4.3.1 增加方法 put

主要步驟:

  1. 通過hash值計算出key映射到哪個桶;
  2. 如果桶上沒衝突,直接插入;
  3. 如果出現衝突,則需要衝突處理:
    1. 如果該桶使用紅黑樹處理衝突,則調用紅黑樹的方法插入數據
    2. 否則如果採用傳統的鏈式方法插入. 如果鏈的長度達到臨界值,則把鏈轉換爲紅黑樹;
  4. 如果桶中存在重複的鍵,則爲該鍵替換新的值value
  5. 如果size大於閾值threshold,則進行擴容;

具體方法如下:

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


/**
1) key=null:可以看出當key爲null時hash值爲0
1) key不等於null:
	首先計算出key的hashCode賦值給h, 然後與h無符號右移16位的二進制進行按位異或^ 得到最後的hash值	
*/
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
	...
     if ((p = tab[i = (n - 1) & hash]) == null)		//這裏就是上面所說的求摸策略,這裏的n表示數組長爲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)

下面是一些位運算解析: 注意:異或運算^: 相同爲0,不同爲1

(key.hashCode()) ^ (h>>>16)
i = (n - 1) & hash

1111 1111 1111 1111 1111 0000 1110 1010	h = key.hashCode()
0000 0000 0000 0000 1111 1111 1111 1111 h >>>16
----------------------------------------------- 異或操作
1111 1111 1111 1111 0000 1111 0001 0101 返回給: hash
0000 0000 0000 0000 0000 0000 0000 1111 n = 16-1
------------------------------------------------ 與操作
0000 0000 0000 0000 0000 0000 0000 0101 i=5 = i = (n - 1) & hash

簡單來看就是:

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

爲啥是這樣操作呢?

如果當n即數組長度很小,假設是16的話,那麼n-1爲1111,這樣的值與hashCode()直接按位與操作,實際上只使用了哈希值的後四位.如果hash值高位變化很大,低位變化很小,這樣就很容易造成hash衝突了,所以這裏吧高低位都利用起來,從而解決了一個問題.如下

1111 1111 1111 1111 1111 0000 1110 1010	h1 = key.hashCode()
1010 1011 0001 1111 1111 0000 1110 1010	h2 = key.hashCode() //高位變化很大
此時如果不進行右移16位再異或操作,而是直接和數字長度進行按位與, 則會h1和h2衝突

現在來詳細看putVal()方法,看它到底做了什麼

主要參數:

  • hash: 即key的hash值, 通過hashCode()方法產生的結果再與右移16位進行異或操作得到
  • key: 原始key
  • value: 要存放的值
  • onlyifAsent: 如果true表示 不更改現有的值
  • evict: 如果false表示table爲創建狀態
/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)//table爲null或者長度爲0, 初始擴容
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)		//這裏就是上面所說的求摸策略,這裏的n表示數組長爲16
        tab[i] = newNode(hash, key, value, null);	//tab[i]爲空,直接創建節點
    else {	//此處已有節點
        Node<K,V> e; K k;
        if (p.hash == hash && //tab[i]處的已存在節點處的hash == 新插入數據的hash
            ((k = p.key) == key || (key != null && key.equals(k)))) //並且 (比較兩者地址是否相等 或者 key內容相等) 
            e = p;		
        else if (p instanceof TreeNode) 
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            for (int binCount = 0; ; ++binCount) { //用for循環找到最後一個節點
                if ((e = p.next) == null) {			//如果p的後繼e爲null
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // 加之前是否比閾值8小1,也就是說現在節點數是否等於8
                        treeifyBin(tab, hash);	//鏈表轉爲紅黑樹
                    break;
                }
                if (e.hash == hash &&  //表名p的後繼不爲null
                    ((k = e.key) == key || (key != null && key.equals(k)))) //比較地址 或者 內容
                    break;
                p = e; //p後移, 遍歷鏈表
            }
        }
        if (e != null) { // existing mapping for key  替換策略
            V oldValue = e.value; //得到舊的值
            if (!onlyIfAbsent || oldValue == null)//如果 可更改 並且舊值不爲null 
                e.value = value;	//將新值賦到該處的value
            afterNodeAccess(e);
            return oldValue;	//返回舊值
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

接下來來看看結點數大於等於8轉換爲紅黑樹的函數(上面30行,真的會轉換嗎?)

4.3.2 增加方法 put

/**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
final void treeifyBin(Node<K,V>[] tab, int hash) { //傳入數組tab,和待插入元素結點的hash
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) //  MIN_TREEIFY_CAPACITY=64
        resize(); //不轉化,而是擴容
    else if ((e = tab[index = (n - 1) & hash]) != null) { //此時n=64, 根據hash獲得桶中元素,賦給e
        TreeNode<K,V> hd = null, tl = null; //頭結點hd,尾節點tl
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null); //將鏈表結點變爲樹節點p
            if (tl == null)	//第一次,p賦給頭結點
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null); //循環至鏈表的最後一個結點
        if ((tab[index] = hd) != null)
            hd.treeify(tab); 
    }
}

下面我結合圖進行說明上述代碼完成了怎樣的一些操作:

  1. 查看9,10,12,13,14 行, 初次操作,第一次循環:
    在這裏插入圖片描述

    此時頭結點hd,尾節點tl 同時指向tab[index]轉換的結點p

  2. 如果e.next != null,繼續循環,此時e指向下一個結點,

    回到12 行,又將其變成p

在這裏插入圖片描述

接下來執行15~17行:

    p.prev = tl; //P的前驅節點指向tl
    tl.next = p;//tl的後繼指向現在的p
}
tl = p;	//尾節點變爲p

經過上述操作變爲如下:

在這裏插入圖片描述

  1. e繼續向後尋找下一個結點, 重複剛剛的動作,得到: (藍色是本次操作)

在這裏插入圖片描述

好了,當e後面沒有結點後將不再繼續,從而執行到最後一行:

        if ((tab[index] = hd) != null)
            hd.treeify(tab); //旋轉等一些操作

在此之前將鏈表上的每一個結點都轉換爲了TreeNode(不過left和right指針都沒用), 同時相鄰兩個結點相互指向, 形式上來看更像是雙向鏈表.

而最後一行, hd.treeify(tab); 就是構造紅黑樹的關鍵了.

/**
 * Forms tree of the nodes linked from this node.
 * @return root of tree
 */
final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    for (TreeNode<K,V> x = this, next; x != null; x = next) { //將調用函數的hd命名爲x,next指向下一個結點, 再依次向下進行遍歷鏈表
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;	//x的left和right置空
        if (root == null) {			//第一次,表名根節點
            x.parent = null;
            x.red = false;		// 當前節點的紅色屬性設爲false(把當前節點設爲黑色)
            root = x;			//現在這個 hd = x = root了, 即根節點指向到當前節點
        }
        else {	//已經存在根節點了
            K k = x.key; 	//當前鏈表節點的key
            int h = x.hash; //取得當前鏈表節點的hash
            Class<?> kc = null;// 定義key所屬的Class
            for (TreeNode<K,V> p = root;;) { // 從根節點開始遍歷,此遍歷沒有設置邊界,只能從內部跳出
                // GOTO1
                int dir, ph; // dir 標識方向(左右)、ph標識當前樹節點的hash值
                K pk = p.key;// 當前樹節點的key
                if ((ph = p.hash) > h)  // 如果當前樹節點hash值 大於 當前鏈表節點的hash值
                    dir = -1;// 標識當前鏈表節點會放到當前樹節點的左側
                else if (ph < h)
                    dir = 1;// 右側
                
                /*
                 * 如果兩個節點的key的hash值相等,那麼還要通過其他方式再進行比較
                 * 如果當前鏈表節點的key實現了comparable接口,並且當前樹節點和鏈表節點是相同Class的實例,那麼通過comparable的方式再比較兩者。
                 * 如果還是相等,最後再通過tieBreakOrder比較一次
                 */
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);
                TreeNode<K,V> xp = p;// 保存當前樹節點
                
                /*
                 * 如果dir 小於等於0 : 
                 *	當前鏈表節點一定放置在當前樹節點的左側,但不一定是該樹節點的左孩子,也可能是左孩子的右孩子 或者 更深層次的節點。
                 * 如果dir 大於0 : 
                 *	當前鏈表節點一定放置在當前樹節點的右側,但不一定是該樹節點的右孩子,也可能是右孩子的左孩子 或者 更深層次的節點。
                 * 如果當前樹節點不是葉子節點,那麼最終會以當前樹節點的左孩子或者右孩子 爲 起始節點  再從GOTO1 處開始 重新尋找自己(當前鏈表節點)的位置
                 * 如果當前樹節點就是葉子節點,那麼根據dir的值,就可以把當前鏈表節點掛載到當前樹節點的左或者右側了。
                 * 掛載之後,還需要重新把樹進行平衡。平衡之後,就可以針對下一個鏈表節點進行處理了。
                 */

                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;  // 當前鏈表節點 作爲 當前樹節點的子節點
                    if (dir <= 0)
                        xp.left = x; // 作爲左孩子
                    else
                        xp.right = x; // 作爲右孩子
                    root = balanceInsertion(root, x); //重新平衡
                    break;
                }
            }
        }
    }
    
    // 把所有的鏈表節點都遍歷完之後,最終構造出來的樹可能經歷多個平衡操作,根節點目前到底是鏈表的哪一個節點是不確定的
    // 因爲我們要基於樹來做查找,所以就應該把 tab[N] 得到的對象一定根是節點對象,而目前只是鏈表的第一個節點對象,所以要做相應的處理。
    //把紅黑樹的根節點設爲  其所在的數組槽 的第一個元素
    //首先明確:TreeNode既是一個紅黑樹結構,也是一個雙鏈表結構
    //下面這個方法裏做的事情,就是保證樹的根節點一定也要成爲鏈表的首節點
    moveRootToFront(tab, root);
}

小結一下putVal() 其主要完成了這幾個事情:

  1. 當桶數組 table 爲空時,通過擴容的方式初始化 table
  2. 查找要插入的鍵值對是否已經存在,存在的話根據條件判斷是否用新值替換舊值
  3. 如果不存在,則將鍵值對鏈入鏈表中,並根據鏈表長度決定是否將鏈表轉爲紅黑樹
  4. 判斷鍵值對數量是否大於閾值,大於的話則進行擴容操作

(關於`紅黑樹數據結構的一些操作,之後會補上)

4.3.3 擴容方法: resize

擴容機制

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

  1. 什麼時候才需要擴容
  2. HashMap的擴容是什麼

1.什麼時候才需要擴容

當HashMap中的元素個數超過數組大小(數組長度)*loadFactor(負載因子)時,就會進行數組擴容,loadFactor的默認值(DEFAUTL_LOAD_FACTOR)是.75. 也就是說,默認情況下,數組大小爲16, 那麼當HashMap中的元素個數超過 16 * .75 = 12(這個值就是閾值或者邊界值threshold)的時候,就把數組的大小擴展爲2*16=32 , 然後重新計算每個元素在數組中的位置, 而這是個非常耗時的操作,所以如果我們已經預知HashMap中元的個數,那麼是能夠有效地提高HashMap性能的.

補充:

當HashMap其中的一個鏈表對象個數如果達到了8個, 此時如果數組長度沒有達到64,那麼HashMap會先擴容解決; 如果已經達到了64, 那麼這個鏈表會變成紅黑樹,節點類型由Node變爲TreeNode類型.當然,如果映射關係被移除後,下次執行resize方法時判斷樹的節點個數小於6, 也會再次把樹轉換爲鏈表

綜上::

  • 當HashMap中的元素個數超過數組大小(數組長度)*loadFactor(負載因子)時,會進行數組擴容
  • 當一個鏈表對象個數如果達到了8個, 此時如果數組長度沒有達到64,也會進行擴容

2.HashMap的擴容是什麼?

進行擴容, 會伴隨着一次**重新hash分配,**並且會遍歷hash表中所有的元素,是非常耗時的. 在編寫程序中,儘量避免resize

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

原數組長度: n=16
(n-1) & hash
			0000 0000 0000 0000 0000 0000 0000 1111 15
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
			0000 0000 0000 0000 0000 0000 0000 0101 索引:5

			0000 0000 0000 0000 0000 0000 0000 1111 15
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101            
----------------------------------------------------
			0000 0000 0000 0000 0000 0000 0000 0101 索引:5


數組長度擴容==> 16*2=32 
(n-1) & hash
			0000 0000 0000 0000 0000 0000 0001 1111 31
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
			0000 0000 0000 0000 0000 0000 0000 0101 索引:5

			0000 0000 0000 0000 0000 0000 0001 1111 31
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101            
----------------------------------------------------
			0000 0000 0000 0000 0000 0000 0001 0101 索引:5+16=21

現在可以很好理解上面那句話了.

							00101 --> 5
0101 ==resize擴容(16*2)=> 
							10101 --> 5+16(oldCap)

在這裏插入圖片描述

搞懂了核心機制後,下面來看看源代碼:

  final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) { //oldCap不爲空
            if (oldCap >= MAXIMUM_CAPACITY) { //大於最大容量 2^30
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //左移一位(擴大一倍)也不能大於最大容量
                     oldCap >= DEFAULT_INITIAL_CAPACITY) //並且 原來容量應大於
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
      	//將原數組中的內容拷貝過來
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) { //j位置不空,將其賦給e
                    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 { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) { //高位處爲0,放在原位置不動(標記lo和hi)
                                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;//放在j+oldCap位置上
                        }
                    }
                }
            }
        }
        return newTab;
    }

4.3.4 刪除方法(remove)

理解了put方法之後, remove方法已經沒有什麼難度, 重複的內容不做詳細介紹

刪除先是找到元素的位置,如果是鏈表就遍歷鏈表找到元素後刪除,如果用紅黑樹遍歷後找到之後刪除,樹小於6的時候要轉回成鏈表

    //方法的具體實現在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方法:

    /**
     * Implements Map.remove and related methods.
     *
     * @param hash 	hash for key
     * @param key 	the key
     * @param value 	the value to match if matchValue, else ignored
     * @param matchValue 	if true only remove if value is equal
     * @param movable 	if false do not move other nodes while removing
     * @return the node, or null if none
     */
    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;
        if ((tab = table) != null && (n = tab.length) > 0 && //table不空 且 長度大於0 
            (p = tab[index = (n - 1) & hash]) != null) { //該索引賦給index, 該處節點賦給p,不能爲空
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash && //p的hash 等於 傳入的hash:
                ((k = p.key) == key || (key != null && key.equals(k))))//比較key內容相等
                node = p; //表明tab[index]即爲我們所要刪除的:  node 存放tab[index]
            else if ((e = p.next) != null) { //tab[index]不是我們要刪的
                if (p instanceof TreeNode) //是紅黑樹
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key); 
                else {	//是鏈表
                    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內容相等
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value || //node不空
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode)//是紅黑樹節點,紅黑樹刪除
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)//tab[index]是需要刪除的節點
                    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;
    }
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) { //first 存放所求到的元素
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))//hash等 並且 key內容也等
                return first;
            if ((e = first.next) != null) { //如果fist不爲所求,但還有後繼
                if (first instanceof TreeNode)//是樹節點,在樹中找
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do { //是鏈表, 按照鏈表方式遍歷比較
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

下面來關注下樹的中尋找的getTreeNode(hash,key):

        final TreeNode<K,V> getTreeNode(int h, Object k) {
            return ((parent != null) ? root() : this).find(h, k, null);
        }

又調用了find方法:

        /**
         * Finds the node starting at root p with the given hash and key.
         * The kc argument caches comparableClassFor(key) upon first use
         * comparing keys.
         */
        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;
        }

小結:

1.get方法實現的步驟:

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

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

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

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

    b:如果後續節點是鏈表節點,則通過遍歷鏈表根據key獲取value

3.查找紅黑樹.由於之前添加時已經保證這個樹是有序的了,因此查找時基本就是折半查找

4.這裏和插入時一樣,如果對比節點的哈希值和要查找的哈希值相等,就會判斷key是否相等,相等直接返回; 不相等就從子樹遞歸查找

  • 若爲樹,則在樹中通過key.equals(k)查找, O(logn)
  • 若爲鏈表,則在鏈表中通過key.equals(k)查找, O(n)

4.3.6 遍歷HashMap集合的幾種方式

創建實驗用例

    public static void main(String[] args) {
        HashMap<String,Integer> hm = new HashMap<>();
        hm.put("aaa",1000);
        hm.put("bbb",1200);
        hm.put("ccc",1400);
        hm.put("aaa",1400);
   		...
    }
  1. 分別獲取Key和Values

    簡言之: hashmap.keySet()獲取keys, hashmap.values() 獲取values

        /**
         * 分別獲取Keys和Values
         * @param hm
         */
        private static  void method1(HashMap<String, Integer> hm){
            //獲取所有key
            Set<String> keys = hm.keySet();
            for (String key : keys)
                System.out.println(key);
    
            //獲取所有value
            Collection<Integer> values = hm.values();
            for (Integer value : values)
                System.out.println(value);
    
        }
    
  2. 通過iterator獲取

    簡言之:先通過hashmap.entrySet獲取鍵值對集合entries,再用Iterator逐一遍歷

        /**
         * 使用iterator迭代器迭代
         *
         */    
    	private static void method2(HashMap<String,Integer> hm){
            Set<Map.Entry<String , Integer>> entries = hm.entrySet();
            for (Iterator<Map.Entry<String,Integer>> it = entries.iterator(); it.hasNext();){
                Map.Entry<String,Integer> entry = it.next();
                System.out.println(entry.getKey() + "---" + entry.getValue());
            }
        }
    
  3. 通過get(key)方式 (不建議使用–兩次使用迭代器,不建議使用)

    簡言之:先用keySet()獲取所有的key,在通過hashmap.get(key)獲得value

    keySet 其實是遍歷2次,一次轉爲Iterator對象,另一次從hashmap中去除key所對應的value. 而entrySet只是遍歷了一次就把key和value都放在了entry中.

        /**
         * 通過get(key)
         */
        private static  void method3(HashMap<String , Integer> hm){
            Set<String> keys = hm.keySet();
            for (String key : keys){
                Integer value = hm.get(key);
                System.out.println(key + "===" value);
            }
        }
    
  4. jdk8 以後使用Map接口中的默認方法forEach(BiConsumer<? super K, ? super V> action)

    使用很簡單:

        /**
         * jdk8 以後使用Map接口中的默認方法
         */
        private static void method4(HashMap<String, Integer> hm) {
            hm.forEach((key,value)->{	//函數式接口
                System.out.println(key + "----" + value);
            });
        }
    

    進入forEach中看看:

    //HashMap.java
        @Override
        public void forEach(BiConsumer<? super K, ? super V> action) {
            Node<K,V>[] tab;
            if (action == null)
                throw new NullPointerException();
            if (size > 0 && (tab = table) != null) {
                int mc = modCount;
                for (int i = 0; i < tab.length; ++i) {
                    for (Node<K,V> e = tab[i]; e != null; e = e.next)
                        action.accept(e.key, e.value);
                }
                if (modCount != mc)
                    throw new ConcurrentModificationException();
            }
        }
    

    BiConsumer是個啥東東? 和Consumer有什麼關係? 進去看看.

    @FunctionalInterface
    public interface BiConsumer<T, U> {
    
        /**
         * Performs this operation on the given arguments.
         *
         * @param t the first input argument
         * @param u the second input argument
         */
        void accept(T t, U u);
    
        default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) {
            Objects.requireNonNull(after);
    
            return (l, r) -> {
                accept(l, r);
                after.accept(l, r);
            };
        }
    }
    

    和Consumer很像,其實是Consumer相關的函數式接口,Consumer中有一個核心方法:

    void accept(T t); //對給定的參數T執行定義的操作
    

    說白了Comsumer就是給定義一個參數,對其進行(消費)處理,處理的方式可以是任意操作.(抽象方法嘛)

    這裏BiConsumer莫不是給定兩個參數進行操作.

    void accept(T t, U u);
    

    關於這個函數式接口再說兩句, java.util.function中 Function, Supplier, Consumer, Predicate和其他函數式接口廣泛用在支持lambda表達式的API中。這些接口有一個抽象方法,會被lambda表達式的定義所覆蓋.

    回到forEach中可以看到, K,V或其父類, 經過一系列操作,將逐一打印出key和value; 換句話說, 傳入給forEach函數的兩個參數,即是每個entry的key和value.

    補充一點:<? super T> 與 <? extend U> ,前者表示任何T泛型的父類或者T(T是下限),後者表示任何U泛型子類或者就是U(U是上限). 上面的函數實現參數BiConsumer<? super K, ? super V> action使用super,表示K和V就是其下限(子類),能對K和V進行的操作就一定能不會蹦(子類能滿足,父類也能行).
    關於這一點, 有機會會專門寫一寫諸如<T extends Comparable<? super T>>這樣的…

5 關於HashMap初始化再談

5.1 HashMap初始化問題描述

如果我們確切知道有多少個鍵值對要進行存儲,那麼我們在初始化HashMap的時候就應該指定它的容量,以防止HashMap自動擴容,影響使用效率.

默認情況下HashMap的容量爲16.但是若用戶通過構造函數指定了一個數字作爲其容量,那麼其會選擇大於該數字的第一個2的冪作爲容量(3–>4 , 16–>32), 這是前面已經談過的.

<阿里巴巴Java開發手冊>建議我們設置HashMap的初始化容量

[推薦]集合初始化時,指定集合初始值大小

​ 說明: HashMap使用HashMap(int initialCapacity)初始化.

爲啥?

HashMap的擴容機制,就是當達到擴容條件時會進行擴容. HashMap的擴容條件就是當HashMap中的元素個數(size)超過臨界值(threshold)時會自動擴容. threshold = loadFactor *capacity

so, 如果我們沒有設置初始化容量大小,隨着元素不斷增加,HashMap可能會發生多次擴容,而HashMap中的擴容機制會在每次擴容是進行拷貝, 重新hash,很影響性能的.

不過, 設置初始化容量時,設置的數值不同也會很影響性能,那麼當我們已知HasMap中即將存放KV個數時,容量設置爲多少較好呢? 比如我們有20個KV時,是直接給20麼?

5.2 HashMap中容量的初始化

正例: initialCapacity = (需要存儲的元素個數 / 負載因子) + 1 . 負載因子loadFactor默認爲0.75

仔細想想, 假如我們就有7個KV, 然後我們設置HashMap(7), 經過jdk處理後,會被設置成8. 但是,這個HashMap在元素個數達到8*0.75=6的時候就會擴容了, 這不是我們希望看到的, 我們應該儘量減少擴容.

也即是說, 如果我們通過 initialCapacity/0.75 + 1.0計算: 7 / 0.75 + 1 =10, 經過jdk處理後, 會被變成16 , 這便大大減少了擴容機率.

簡單說就是: 你有7個元素需要HashMap操作, 通過計算使用HashMap(10), 這時內部自動幫你擴容到16,threshold爲12. 當然這麼操作會犧牲一些內存.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章