JDK Map 集合總結

1. ConcurrentHashMap 的實現原理

ConcurrentHashMap 在 JDK 1.6 和 1.7 都采用了相同的數據結構,即分段鎖的技術來實現的。ConcurrentHashMap 內部有一個叫 Segment 的數組,里面存放的都是 Segment 對象。Segment 對象繼承了 ReentrantLock,這樣就使得每個段都有一把鎖。 Segment 里面有一個被 volatile 修飾的 HashEntry 的數組。(在 ConcurrentHashMap 初始化的時候,創建了 Segment 數組,并初始化第一個元素。)

JDK 1.7

重要變量

static final class Segment<K,V> extends ReentrantLock implements Serializable {

    private static final long serialVersionUID = 2249069246763182397L;

    static final int MAX_SCAN_RETRIES =
        Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

    transient volatile HashEntry<K,V>[] table;

    transient int count;

    transient int modCount;

    transient int threshold;

    final float loadFactor;
}
static final class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry<K,V> next;

    HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

put 操作

  1. 判斷 value 是否為 null, 如果 value 為 null 則拋出異常。

  2. 當用戶調用 put 方法的時候,首先根據 key 的 hash 值找到具體的 Segment 在 table 中的位置。

  3. 如果這個位置上的 Segment 沒有初始化,則進行初始化的操作。

  4. 最后委托給 Segment 的 put 方法。此方法中會根據計算出的 (tab.length - 1) & hash 的 index 定位到 HashEntry, 如果這個位置上的節點為null,則新建一個 HashEntry并返回 。如果不為 null,則遍歷 HashEntry 的每一個節點,如果有相同的 key 存在則更新 value, 如果沒有則新建一個 HashEntry 放入鏈表頭部的位置。

  5. 如果 ConcurrentHashMap 內存放的元素個數超過了閾值,那么需要對其進行擴容。整個操作都是加鎖的。

get 操作

get 操作的時候沒有對 ConcurrentHashMap 進行上鎖,

  1. 根據 key 的 hash 值計算出在哪個 Segment 上,再根據 hash 值計算出在哪個 HashEntry 上

  2. 然后遍歷 HashEntry 的所有節點,如果找到 key,那么就返回對應的 value,如果 key 沒有找到,就返回 null。

remove 操作

  1. 根據 key 的 hash 值找到在哪個 Segment 上

  2. 然后調用 Segment 的 remove 方法,根據 int index = (tab.length - 1) & hash; 計算出 index 并找到對應的 HashEntry

  3. 遍歷 HashEntry 的所有節點,找到相同的 key(調用 key 的 equals 和 hashCode 方法)并刪除,并且返回 value。

擴容操作(具體步驟還沒找到)

ConcurrentHashMap不會增加Segment的數量,而只會增加Segment中鏈表數組的容量大小,這樣的好處是擴容過程不需要對整個ConcurrentHashMap做rehash,而只需要對Segment里面的元素做一次 resize 就可以了。

整個步驟如下:

  1. 創建一個大小為原來 HashEntry 兩倍大小的數組,根據 hash 算法重新將老 table 中的元素放入到新 table 中去。

這里的重點就是:

首先找到一個lastRun,lastRun之后的元素和lastRun是在同一個桶中,所以后面的不需要進行變動。然后對開始到lastRun部分的元素,重新計算下設置到newTable中,每次都是將當前元素作為newTable的首元素,之前老的鏈表作為該首元素的next部分。

JDK 1.8

ConcurrentHashMap 在 JDK 1.8 中進行了大幅度的改進。取消了 Segment 分段鎖的概念。采用了數組 + 鏈表 + 紅黑樹的數據結構實現。內部存放了一個 Node<K,V>[] table 的 table。 ConcurrentHashMap 在初始化的時候只是設置了一些變量值,并沒有對整個 table 進行初始化,初始化的動作被放入到了第一次 put 元素的時候。

一些重要的變量

/**
 * races. Updated via CAS.
 * 記錄容器的容量大小,通過CAS更新
 */
private transient volatile long baseCount;

/**
 * 這個sizeCtl是volatile的,那么他是線程可見的,一個思考:它是所有修改都在CAS中進行,但是sizeCtl為什么不設計成LongAdder(jdk8出現的)類型呢?
 * 或者設計成AtomicLong(在高并發的情況下比LongAdder低效),這樣就能減少自己操作CAS了。
 *
 * 來看下注釋,當sizeCtl小于0說明有多個線程正則等待擴容結果,參考transfer函數
 *
 * sizeCtl等于0是默認值,大于0是擴容的閥值
 */
private transient volatile int sizeCtl;

/**
 *  自旋鎖 (鎖定通過 CAS) 在調整大小和/或創建 CounterCells 時使用。 在CounterCell類更新value中會使用,功能類似顯示鎖和內置鎖,性能更好
 *  在Striped64類也有應用
 */
private transient volatile int cellsBusy;
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    
    Node(int hash, K key, V val, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.val = val;
        this.next = next;
}

sizeCtl 變量

控制標識符,用來控制table初始化和擴容操作的,在不同的地方有不同的用途,其值也不同,所代表的含義也不同

  • 負數代表正在進行初始化或擴容操作

  • -1代表正在初始化

  • -N 表示有N-1個線程正在進行擴容操作

  • 正數或0代表hash表還沒有被初始化,這個數值表示初始化或下一次進行擴容的大小

put 操作

  1. 首先判斷 key 和 value 是否為 null, 如果為 null 則拋出異常。

  2. 然后在判斷 table 是否初始化,如果沒有初始化則通過 CAS 操作將 sizeCtl 的值設置為 -1,并執行 table 的初始化操作。

  3. 根據 key 的 hash 定位到 key 所在 table 的位置,如果這個位置上沒有元素,則直接插入元素后返回。

  4. 如果當前節點的 hash 值為 -1,說明當前的節點是 forwardingNode 節點,表示 table 正在擴容,當前線程需要幫助一起擴容。(上面的過程走完之后,說明當前的節點上有元素,需要對當前節點加鎖然后操作)。

  5. 如果當前節點的 hash 值大于等于 0,說明是一個鏈表結構,則遍歷鏈表,如果存在當前 key 節點則替換 value,否則插入到鏈表尾部。

  6. 如果 f 是 TreeBin 類型節點,則按照紅黑樹的方法更新或者增加節點。

  7. 若鏈表長度 > TREEIFY_THRESHOLD(默認是8),則將鏈表轉換為紅黑樹結構(并不是直接轉的,還需要進一步判斷,具體的在treeifyBin方法中)。

  8. 最后調用 addCount 方法,將 ConcurrentHashMap 的 size + 1,并判斷是否需要執行擴容操作,整個 put 過程結束。

get 操作

get 操作的時候沒有上鎖,如果整個table 為空,則返回null,否則根據 key 的 hash 值找到 table 的 index 位置,然后根據鏈表或者樹形方式找到相對應的節點,返回其 value 值。

remove 操作

源碼最后調用的是 replaceNode() 方法。具體沒有詳細看。

紅黑樹轉換

1. 什么時候轉換?

鏈表的元素個數達到了閾值 8 ,則會調用 treeifyBin 方法把鏈表轉換成紅黑樹,不過在結構轉換之前,會對數組長度進行判斷。如果數組長度n小于閾值 MIN_TREEIFY_CAPACITY ,默認是64,則會調用 tryPresize 方法把數組長度擴大到原來的兩倍,并觸發 transfer 方法,重新調整節點的位置。


擴容操作

http://www.lxweimin.com/p/487d00afe6ca

整個擴容操作分為兩步:

  1. 構建一個nextTable,其大小為原來大小的兩倍,這個步驟是在單線程環境下完成的。

  2. 將原來table里面的內容復制到nextTable中,這個步驟是允許多線程操作的,所以性能得到提升,減少了擴容的時間消耗。

并發擴容的具體步驟如下:

  1. 為每個內核分任務,并保證其不小于16

  2. 檢查nextTable是否為null,如果是,則初始化 nextTable,使其容量為 table 的兩倍。然后死循環遍歷節點,直到finished。節點從 table 復制到 nextTable 中,支持并發,思路如下:

  3. 如果節點 f 為 null,則插入 ForwardingNode(采用 Unsafe.compareAndSwapObject 方法實現),這個是觸發并發擴容的關鍵

  4. 如果 f 為鏈表的頭節點(fh >= 0),則先構造一個反序鏈表,然后把他們分別放在nextTable的 i 和 i + n位置,并將 ForwardingNode 插入原節點位置,代表已經處理過了

  5. 如果 f 為 TreeBin 節點,同樣也是構造一個反序鏈表 ,==同時需要判斷是否需要進行 unTreeify() 操作==,并把處理的結果分別插入到 nextTable 的 i 和 i+n 位置,并插入 ForwardingNode 節點

  6. 所有節點復制完成后,則將 table 指向 nextTable,同時更新 sizeCtl = nextTable 的 0.75 倍,完成擴容過程。

在多線程環境下,ConcurrentHashMap 用兩點來保證正確性:ForwardingNode 和 synchronized。當一個線程遍歷到的節點如果是 ForwardingNode,則繼續往后遍歷,如果不是,則將該節點加鎖,防止其他線程進入,完成后設置 ForwardingNode 節點,以便要其他線程可以看到該節點已經處理過了,如此交叉進行,高效而又安全。

更多關于 ConcurrentHashMap 的問題在這里羅列

  1. 為什么 ConcurrentHashMap 的 table 大小是 2 的次冪?

    參見擴容機制的描述。

  1. ConcurrentHashMap JDK 8 在什么樣的情況下會擴容?

    (1) 當前容量超過閾值時擴容

    (2) 當鏈表中元素個數超過默認設定(8個),當數組的大小還未超過64的時候,此時進行數組的擴容,如果超過則將鏈表轉化成紅黑樹。

  1. ConcurrentHashMap JDK 8 如何實現線程安全?

    使用 CAS + Synchronized 來保證并發更新的安全。

  1. HashMap 的 hash 算法

    參見擴容機制的描述。

  2. ConcurrentHashMap 的弱一致性

    (1) 在 JDK 1.6 的時候,一個線程在 ConcurrentHashMap 里 put 元素時寫 count,另一個線程 get 數據的時候不會得到最新的實時數據。這是因為源碼中 put 操作的寫 new HashEntry 和 get 操作的 getFirst() 存在 happened-before 關系。具體可以查看筆記。

    (2) 貌似在 JDK 1.7 的時候也存在同樣的問題。

2. HashMap 實現原理

JDK 1.7

HashMap 在 JDK 7 中的數據結構是數組 + 鏈表的實現。 HashMap 中存儲了一個 Entry[] 類型的數組,里面存儲了 Entry 對象。Entry 對象的結構如下:

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
}

put

  1. 當調用 put 方法的時候,會根據 key 的 hash 值定位到 key 要存到 table 的哪個 index 上

  2. 如果 key 為 null,那么就 put 到鏈表的頭結點上。

  3. 如果 key 不為 null,那么遍歷 index 上的鏈表,如果存在相同的 key,那么就更新 value。

  4. 如果不存在相同的 key,那么將 key 存到鏈表的頭結點中。

get

  1. 根據 key 的 hash 值計算出 key 在 table 的哪個 index 上。遍歷 index 上的鏈表,找到相同的 key,并返回 value。

  2. 如果 key 不存在則返回 null。

擴容

什么時候擴容?

當 HashMap 內的容量數超過了閾值(默認 12 個)的時候會觸發擴容。整個擴容過程如下:

  1. 新建一個比原來數組兩倍大的新數組。

  2. 重算 key 的 hash 值來得到在新數組的位置,并將 key 放入新數組中。

死循環問題

主要是多線程同時put時,如果同時觸發了rehash操作,會導致HashMap中的鏈表中出現循環節點,進而使得后面get的時候,會死循環。而且還會丟失元素。

主要重現過程可以看: http://blog.csdn.net/xuefeng0707/article/details/40797085

JDK 1.8

在 JDK 1.8 中, HashMap 重新設計了實現。放棄 1.7 中的數組 + 鏈表的存儲結構,改為了數組 + 鏈表 + 紅黑樹的實現。

put

  1. 根據 key 的 hash 值,計算出 table 中的 index。

  2. 如果 index 上沒有元素,那么直接插入元素。

  3. 如果 index 上有元素的話,并且是鏈表結構的話,就遍歷鏈表,判斷是否有相同的 key 存在,如果存在則替換 value,如果不存在則新建 Node ==放入鏈表尾部==。同時判斷當前鏈表是否過長,如果超過 TREEIFY_THRESHOLD 的話,則需要將鏈表轉換成紅黑樹。

  4. 如果 index 上的節點是 TreeNode 類型的話,則用紅黑樹的方式添加元素。

  5. 最后判斷 HashMap 中的元素是否超過了閾值,如果超過了需要進行 resize 擴容。

get

  1. 根據 key 的 hash 值定位到 table 中的 index。

  2. 如果 index 上沒有元素,則返回 null。

  3. 如果 index 上有元素,那么根據節點類型的不同,調用鏈表或紅黑樹的方式獲取 value。

擴容

在 JDK 1.8 的實現中,優化了高位運算的算法,通過 hashCode() 的高 16 位異或低 16 位實現的:(h = k.hashCode()) ^ (h >>> 16),主要是從速度、功效、質量來考慮的,這么做可以在數組table的length比較小的時候,也能保證考慮到高低Bit都參與到Hash的計算中,同時不會有太大的開銷。

經過觀測可以發現,我們使用的是2次冪的擴展(指長度擴為原來2倍),所以,元素的位置要么是在原位置,要么是在原位置再移動2次冪的位置??聪聢D可以明白這句話的意思,n為table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容后key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希與高位運算結果。

image

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

image

因此,我們在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”,可以看看下圖為16擴充為32的resize示意圖

image

有一點注意區別,JDK1.7中rehash的時候,舊鏈表遷移新鏈表的時候,如果在新表的數組索引位置相同,則鏈表元素會倒置,但是從上圖可以看出,JDK1.8不會倒置。

紅黑樹轉換

如果鏈表上的元素大于 8 個,那么需要轉換成紅黑樹。不過在結構轉換之前,會對數組長度進行判斷。如果數組長度n小于閾值 MIN_TREEIFY_CAPACITY ,默認是64,則會調用 tryPresize 方法把數組長度擴大到原來的兩倍,并觸發 transfer 方法,重新調整節點的位置。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,559評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 178,442評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,581評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,922評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,096評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,639評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,374評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,591評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,789評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,322評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,554評論 2 379

推薦閱讀更多精彩內容