深入淺出java的Map

palapala發表於2020-12-15

HashMap的組成

首先了解陣列和連結串列兩個資料結構

1.陣列 定址容易,插入和刪除元素困難

陣列由於是緊湊連續儲存,可以隨機訪問,通過索引快速找到對應元素,而且相對節約儲存空間。

但正因為連續儲存,記憶體空間必須一次性分配夠,所以說陣列如果要擴容,需要重新分配一塊更大的空間,再把資料全部複製過去,時間複雜度 O(N);

而且你如果想在陣列中間進行插入和刪除,每次必須搬移後面的所有資料以保持連續,時間複雜度 O(N)。

2.連結串列 定址困難,插入和刪除元素容易

連結串列因為元素不連續,而是靠指標指向下一個元素的位置,所以不存在陣列的擴容問題;如果知道某一元素的前驅和後驅,操作指標即可刪除該元素或者插入新元素,時間複雜度O(1)。

但是正因為儲存空間不連續,你無法根據一個索引算出對應元素的地址,所以不能隨機訪問;而且由於每個元素必須儲存指向前後元素位置的指標,會消耗相對更多的儲存空間。

另外值得驚醒的一句話是:

資料結構的儲存方式只有兩種:陣列(順序儲存)和連結串列(鏈式儲存)

Hash表的實現就是結合了陣列和連結串列:https://blog.csdn.net/hadues/article/details/105384914

如下圖:左邊是一個陣列,每個陣列指向一個連結串列

鍵值對插入Map的過程

首先map的key拿到之後,通過Hash函式計算出它的hashCode, 結合陣列長度進行無符號右移(>>>)、按位異或、按位與(&)計算出索引,得到陣列的Position,繼而找到陣列Position所指向的連結串列。

如果兩個key的HashCode相同,我們會比較equals方法

  • 如果equals相同:則將後新增的value覆蓋之前的value
  • 如果equals不同:則產生了Hash衝突,會劃出一個節點儲存資料,連結到連結串列後面

JDK1.8以後,如果陣列長度大於64,並且連結串列長度大於8,則連結串列會進化成紅黑樹

HashMap集合的成員變數

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

   /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

計算hashCode在陣列中的哪個位置,實際上就是取餘,hash&(length-1)計算機中直接求餘運算不如位移運算。

參考:高效取餘運算 https://www.cnblogs.com/gne-hwz/p/10060260.html

如果陣列長度不是2的n次冪,計算出的索引特別容易相同,及其容易發生hash碰撞

  陣列長度為9的時候 3&(9-1)=0 2&(9-1)=0 發生了hash碰撞

  陣列長度為8的時候 3&(8-1)=3  2&(8-1)=2 沒發生雜湊碰撞

2.預設的負載因子,預設是0.75

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

Map擴容的時,並不是把集合存滿在擴容,集合數量達到載入因子*陣列長度(預設16*0.75=12),才會擴容

負載因子是0.75的時候,空間利用率比較高,而且避免了相當多的Hash衝突,使得底層的連結串列或者是紅黑樹的高度比較低,提升了空間效率。

3.當連結串列長度超過8時,會轉變成紅黑樹

    /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

紅黑樹的查詢效率雖然比連結串列高,但是佔用空間是連結串列的兩倍,之所以臨界值是8 是根據數學的泊松分佈概率 連結串列長度超過8的概率非常小

這是時間和空間的一個取捨

HashMap擴容機制

JDK1.7擴容時,會伴隨一次重新的hash分配,並且會遍歷Hash表中的所有元素,是非常耗時的。

JDK1.8擴容時,因為每次擴容都是翻倍,與原來計算的(n-1)&hash的結果相比,只是多了一個bit位

所以節點要麼就在原來的位置(e.hash&oldCap結果是0),要麼就被分配到 原位置+舊容量 這個位置(e.hash&oldCap結果不等於0)

https://blog.csdn.net/zlp1992/article/details/104376309

JDK1.7 連結串列採用頭插的方式 擴容時,在多執行緒的情況下可能出現迴圈連結串列

JDK1.8 連結串列採用的是尾插(不在倒序處理)

ConCurrentHashMap

JDK7:ConcurrentHashMap採用了分段鎖的,把容器預設分成16段,put值的時候 只是鎖定16斷中的一個部分,就是把鎖給細化了

JDK8:採用的CAS自旋

JDK1.7  對於ConCurrentHashMap的size統計,當經過了兩次計算(3次對比)之後,發現每次統計時Hash都有結構性的變化

這時它就會氣急敗壞的把所有Segment都加上鎖;而當自己統計完成後,才會把鎖釋放掉,再允許其他執行緒修改雜湊中的個數

JDK1.8 對於ConCurrentHashMap的size統計,JDK1.8藉助了baseCount和counterCells兩個屬性,並配合多次CAS的方法,避免的鎖的使用

 /**
     * Base counter value, used mainly when there is no contention,
     * but also as a fallback during table initialization
     * races. Updated via CAS.
     */
    private transient volatile long baseCount;/**
     * Table of counter cells. When non-null, size is a power of 2.
     */
    private transient volatile CounterCell[] counterCells;

過程

1.當併發量較小的時,優先使用CAS的方式直接更新baseCount

2.當更新baseCount衝突,則會認為進入到比較激烈的競爭狀態,通過啟用counterCells減少競爭,通過CAS的方式把總數更新情況記錄在counterCells對應的位置上

3.如果更新counterCells上的某個位置出現了多次失敗,則會通過擴容counterCells的方式減少衝突

4.當counterCells在擴容期間,會嘗試更新baseCount的值

對於元素總數的統計,邏輯就非常簡單了,只需要讓baseCount加上各counterCells內的資料,就可以得出雜湊內的總數,整個過程完全不需要藉助鎖。

疑問:HashMap有執行緒安全的ConcurrentHashMap 但是TreeMap為什麼沒有ConcurrentTreeMap

     因為CAS操作用在紅黑樹實現起來太複雜 

   所以用ConcurrentSkipListMap用CAS實現排序(跳錶代替Tree) 

   跳錶:在連結串列的基礎上一層一層的加一些個關鍵元素的連結串列,加了個索引。跳錶的查詢效率比連結串列本身要高,同時它的CAS實現難度比TreeMap容易很多

 

相關文章