1. 程式人生 > >java面試整理(四)—— HashMap、LinkedHashMap、TreeMap、Hashtable、HashSet和ConcurrentHashMap區別

java面試整理(四)—— HashMap、LinkedHashMap、TreeMap、Hashtable、HashSet和ConcurrentHashMap區別

注:本篇博文大部分借鑑與該篇博文系列

知識點總結

HashMap

  1. HashMap是基於雜湊表的Map介面的非同步實現,
  2. 允許使用null值和null鍵(HashMap最多隻允許一條記錄的鍵為null,允許多條記錄的值為null。)。此類不保證對映的順序,特別是它不保證該順序恆久不變。
  3. HashMap中不允許出現重複的鍵(Key)
  4. Hashmap是非執行緒安全的,
  5. 其迭代器是fail-fast的
  6. HashMap實際上是一個“連結串列雜湊”的資料結構,即陣列和連結串列的結合體,(JDK1.8增加了紅黑樹部分,會將時間複雜度從O(n)降為O(logn))。
  7. 資料儲存:先根據key的hashCode(使用key的hashCode()方法獲取)重新計算hash值,根據hash值算出這個元素在陣列中的位置(即下標), 如果陣列該位置上已經存放有其他元素了,那麼在這個位置上的元素將以連結串列的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。如果陣列該位置上沒有元素,就直接將該元素放到此陣列中的該位置上。
  8. 資料讀取:首先根據key的hashCode,找到其陣列中對應位置的資料(可能只有一個數據,也可能是多個數據,其表現形式是一個連結串列),然後通過key的equals方法在對應位置的連結串列中找到需要的元素。
  9. hashMap的預設初始容量是16個,其會有一個負載因子,用於當hashMap中的資料量等於容量*負載因子時,hashMap會進行擴容,擴大的容量是原本的2倍。負載因子的預設初始值為0.75

HashSet

  1. 它是基於HashMap實現的,底層採用HashMap來儲存元素,而且只使用了HashMap的key來實現各種特性。HashSet實現了Set介面
  2. HashSet較HashMap來說比較慢
  3. HashSet中的資料不是key-value鍵值對,其只是單值,雖然其藉助與HashMap來實現,但是其只是將值作為key來存入HashMap中,因為HashMap中的值是key-value鍵值對的,所以每個HashSet儲存到HashMap的資料對應的value值只是一個new Object()物件
  4. 當新增資料時,如果set中尚未包含指定元素,則新增指定元素。更確切地講,如果此 set 沒有包含滿足(e==null ? e2==null : e.equals(e2))的元素e2,則向此set 新增指定的元素e。如果此set已包含該元素,則該呼叫不更改set並返回false。但底層實際將將該元素作為key放入HashMap。

Hashtable

  1. Hashtable也是一個散列表,它儲存的內容是鍵值對。基於Dictionary類
  2. 儲存資料: 首先判斷value是否為空,為空則丟擲異常;計算key的hash值,並根據hash值獲得key在table陣列中的位置index,如果table[index]元素不為空,則進行迭代,如果遇到相同的key,則直接替換,並返回舊value;否則,我們可以將其插入到table[index]位置。
  3. key和value都不允許為null,Hashtable遇到null,直接返回NullPointerException。
  4. 執行緒安全,幾乎所有的public的方法都是synchronized的
  5. 較HashMap速度慢

LinkedHashMap

  1. LinkedHashMap是HashMap的一個子類,它保留插入順序,幫助我們實現了有序的HashMap。
  2. 其維護一個雙向連結串列,並不是說其除了維護存入的資料,另外維護了一個雙向連結串列物件,而是說其根據重寫HashMap的實體類Entry,來實現能夠將HashMap的資料組成一個雙向列表,其儲存的結構還是陣列+連結串列的形式,
  3. LinkedHashMap能夠做到按照插入順序或者訪問順序進行迭代順序。
  4. 修改Entry物件,Entry新增了其上一個元素before和下一個元素after的引用
  5. 根據連結串列中元素的順序可以分為:按插入順序的連結串列,和按訪問順序(呼叫get方法)的連結串列。預設是按插入順序排序,如果指定按訪問順序排序,那麼呼叫get方法後,會將這次訪問的元素移至連結串列尾部,不斷訪問可以形成按訪問順序排序的連結串列
  6. LinkedHashMap並未重寫父類HashMap的put方法,只是重寫了put方法裡面的recordAccess、addEntry、createEntry等方法,添加了特有的雙向連結列表
  7. LinkedHashMap重寫了父類HashMap的get方法,實際在呼叫父類getEntry()方法取得查詢的元素後,再判斷當排序模式accessOrder為true時,記錄訪問順序,將最新訪問的元素新增到雙向連結串列的表頭,並從原來的位置刪除。由於的連結串列的增加、刪除操作是常量級的,故並不會帶來效能的損失。
  8. 這個雖然是訪問元素,但是當設定以訪問排序時,其仍然會先將元素從原本位置remove掉,然後在將該元素以新元素插入到鏈頭,哪怕其新插入的位置還在原位置(所以如果以訪問排序,其過程會涉及到刪除資料和增添資料)。
  9. 讀取速度與容量無關

TreeMap

  1. TreeMap 是一個有序的key-value集合,它是通過紅黑樹實現的。 該對映根據其鍵的自然順序(字母排序)進行排序,或者根據建立對映時提供的 Comparator 進行排序,具體取決於使用的構造方法。
  2. TreeMap是非執行緒安全的。 它的iterator 方法返回的迭代器是fail-fast的。

ConcurrentHashMap

  1. ConcurrentHashMap是弱一致性,也就是說遍歷過程中其他執行緒可能對連結串列結構做了調整,因此get和containsKey返回的可能是過時的資料
  2. ConcurrentHashMap是基於分段鎖設計來實現執行緒安全性,只有在同一個分段內才存在競態關係,不同的分段鎖之間沒有鎖競爭。
  3. 併發度就是ConcurrentHashMap中的分段鎖個數,預設的併發度為16,ConcurrentHashMap會使用大於等於該值的最小2冪指數作為實際併發度(假如使用者設定併發度為17,實際併發度則為32)
  4. 通過將key的高n位(n = 32 – segmentShift)和併發度減1(segmentMask)做位與運算定位到所在的Segment(分段)

HashMap

HashMap是基於雜湊表的Map介面的非同步實現。此實現提供所有可選的對映操作,並允許使用null值和null鍵(HashMap最多隻允許一條記錄的鍵為null,允許多條記錄的值為null。)。此類不保證對映的順序,特別是它不保證該順序恆久不變

HashMap中不允許出現重複的鍵(Key)

Hashmap是非執行緒安全的,如果多個執行緒同時訪問一個HashMap,可能會導致資料不一致,所以當其中至少一個執行緒從結構上(指新增或者刪除一個或多個對映關係的任何操作)修改了,則必須保持外部同步,以防止對對映進行意外的非同步訪問。

其迭代器是fail-fast的

資料結構

HashMap實際上是一個“連結串列雜湊”的資料結構,即陣列和連結串列的結合體,(JDK1.8增加了紅黑樹部分,會將時間複雜度從O(n)降為O(logn))。

HashMap底層就是一個數組結構,陣列中的每一項又是一個連結串列。當新建一個HashMap的時候,就會初始化一個Entry陣列,其大小為capacity。如下圖:

這裡寫圖片描述

Entry就是陣列中的元素,每個Entry其實就是一個key-value對,它持有一個指向下一個元素的引用,這就構成了連結串列。

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

資料儲存

當我們往HashMap中put元素的時候,先根據key的hashCode(使用key的hashCode()方法獲取)重新計算hash值(具體的演算法這裡就不講解了,hash的演算法中包含了很多優化的點,是儲存的資料能夠陣列的每一位儘量只有一個值,而不是一個連結串列,這樣能夠提高查詢效率),根據hash值算出這個元素在陣列中的位置(即下標), 如果陣列該位置上已經存放有其他元素了,那麼在這個位置上的元素將以連結串列的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。如果陣列該位置上沒有元素,就直接將該元素放到此陣列中的該位置上。

當系統決定儲存HashMap中的key-value對時,完全沒有考慮Entry中的value,僅僅只是根據key來計算並決定每個Entry的儲存位置。我們完全可以把 Map 集合中的 value 當成 key 的附屬,當系統決定了 key 的儲存位置之後,value 隨之儲存在那裡即可。

資料讀取

hashMap的資料讀取相對簡單,首先根據key的hashCode,找到其陣列中對應位置的資料(可能只有一個數據,也可能是多個數據,其表現形式是一個連結串列),然後通過key的equals方法在對應位置的連結串列中找到需要的元素。

擴容

hashMap的預設初始容量是16個,其會有一個負載因子,用於當hashMap中的資料量等於容量*負載因子時,hashMap會進行擴容,擴大的容量是原本的2倍。負載因子的預設初始值為0.75,這個值是經過折中的取值,其也是合理的,所以如非特殊需求,不建議修改該因子,

對於初始容量和負載因子的設定,我們可以在建立HashMap物件時指定(也可以大於1),HashMap提供了三個構造方法用於我們使用:

HashMap():構建一個初始容量為 16,負載因子為 0.75 的 HashMap。
HashMap(int initialCapacity):構建一個初始容量為 initialCapacity,負載因子為 0.75 的 HashMap。
HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的負載因子建立一個 HashMap。

因為每次擴容都會對原本的資料進行重新的hash計算其在新陣列中的位置,所以擴容是非常消耗效能的,所以為了儘量少的避免多次擴容,我們如果知道資料的大概量,我們可以在建立時進行指定初始容量大小。

紅黑樹
即使負載因子和Hash演算法設計的再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響HashMap的效能。於是,在JDK1.8版本中,對資料結構做了進一步的優化,引入了紅黑樹。而當連結串列長度太長(預設超過8)時,連結串列就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點提高HashMap的效能,其中會用到紅黑樹的插入、刪除、查詢等演算法。本文不再對紅黑樹展開討論,可以參考這篇博文來詳細認識紅黑樹

歸納

簡單地說,HashMap 在底層將 key-value 當成一個整體進行處理,這個整體就是一個 Entry 物件。HashMap 底層採用一個 Entry[] 陣列來儲存所有的 key-value 對,當需要儲存一個 Entry 物件時,會根據hash演算法來決定其在陣列中的儲存位置,在根據equals方法決定其在該陣列位置上的連結串列中的儲存位置;當需要取出一個Entry時,
也會根據hash演算法找到其在陣列中的儲存位置,再根據equals方法從該位置上的連結串列中取出該Entry。

網上找到了一個HashMap的儲存流程圖
這裡寫圖片描述

HashSet

HashSet實現了Set介面,它不允許集合中出現重複元素。

對於HashSet而言,它是基於HashMap實現的,底層採用HashMap來儲存元素,而且只使用了HashMap的key來實現各種特性。相關HashSet的操作,基本上都是直接呼叫底層HashMap的相關方法來完成,我們應該為儲存到HashSet中的物件覆蓋hashCode()和equals(),以保證放入的物件的唯一性。

HashSet較HashMap來說比較慢

其迭代器是fail-fast的

儲存資料

HashSet中的資料不是key-value鍵值對,其只是單值,雖然其藉助與HashMap來實現,但是其只是將值作為key來存入HashMap中,因為HashMap中的值是key-value鍵值對的,所以每個HashSet儲存到HashMap的資料對應的value值只是一個new Object()物件,

當新增資料時,如果set中尚未包含指定元素,則新增指定元素。更確切地講,如果此 set 沒有包含滿足(e==null ? e2==null : e.equals(e2))的元素e2,則向此set 新增指定的元素e。如果此set已包含該元素,則該呼叫不更改set並返回false。但底層實際將將該元素作為key放入HashMap。

這是因為HashMap的put()方法新增key-value對時,當新放入HashMap的Entry中key與集合中原有Entry的key相同(hashCode()返回值相等,通過equals比較也返回true),新新增的Entry的value會將覆蓋原來Entry的value(HashSet中的value都是PRESENT),但key不會有任何改變,因此如果向HashSet中新增一個已經存在的元素時,新新增的集合元素將不會被放入HashMap中,原來的元素也不會有任何改變,這也就滿足了Set中元素不重複的特性

HashMap HashSet
HashMap實現了Map介面 HashSet實現了Set介面
HashMap儲存鍵值對 HashSet僅僅儲存物件
使用put()方法將元素放入map中 使用add()方法將元素放入set中
HashMap中使用鍵物件來計算hashcode值 HashSet使用成員物件來計算hashcode值,對於兩個物件來說hashcode可能相同,所以equals()方法用來判斷物件的相等性,如果兩個物件不同的話,那麼返回false
HashMap比較快,因為是使用唯一的鍵來獲取物件 HashSet較HashMap慢

Hashtable

和HashMap一樣,Hashtable也是一個散列表,它儲存的內容是鍵值對。

成員變數

Hashtable是通過”拉鍊法”實現的雜湊表。它包括幾個重要的成員變數:table, count, threshold, loadFactor, modCount。

table:是一個Entry[]陣列型別,而Entry實際上就是一個單向連結串列。雜湊表的”key-value鍵值對”都是儲存在Entry陣列中的。
count:是Hashtable的大小,它是Hashtable儲存的鍵值對的數量。
threshold:是Hashtable的閾值,用於判斷是否需要調整Hashtable的容量。threshold的值=”容量*載入因子”。
loadFactor:就是載入因子。
modCount:是用來實現fail-fast機制的(用於防止在讀取過程中,有更新操作,如果有更新操,該引數會修改,然後讀取操作會報錯)。

構造方法
Hashtable一共提供了4個構造方法:

public Hashtable(int initialCapacity, float loadFactor): 用指定初始容量和指定載入因子構造一個新的空雜湊表。useAltHashing為boolean,其如果為真,則執行另一雜湊的字串鍵,以減少由於弱雜湊計算導致的雜湊衝突的發生。
public Hashtable(int initialCapacity):用指定初始容量和預設的載入因子 (0.75) 構造一個新的空雜湊表。
public Hashtable():預設建構函式,容量為11,載入因子為0.75。
public Hashtable(Map<? extends K, ? extends V> t):構造一個與給定的 Map 具有相同對映關係的新雜湊表。

儲存資料

其儲存資料流程如下:
1.判斷value是否為空,為空則丟擲異常;
2.計算key的hash值,並根據hash值獲得key在table陣列中的位置index,如果table[index]元素不為空,則進行迭代,如果遇到相同的key,則直接替換,並返回舊value;
3.否則,我們可以將其插入到table[index]位置。

從上面可以看出,其儲存流程和HashMap相似。

獲取資料

相比較於put方法,get方法則簡單很多。其過程就是首先通過hash()方法求得key的雜湊值,然後根據hash值得到index索引(上述兩步所用的演算法與put方法都相同)。然後迭代連結串列,返回匹配的key的對應的value;找不到則返回null。

HashTable與HashMap比較

HashTable HashMap
基於Dictionary類 基於AbstractMap類
key和value都不允許為null,Hashtable遇到null,直接返回NullPointerException。 key和value都允許為null,HashMap遇到key為null的時候,呼叫putForNullKey方法進行處理。
執行緒安全,幾乎所有的public的方法都是synchronized的 非執行緒安全
速度慢 速度快

LinkedHashMap

概述

我們知道HashMap是無序的儲存,即使我們有序的將資料儲存到HashMap中,但是我們讀取出來的資料的順序,很大可能與儲存的順序不同。

而LinkedHashMap是HashMap的一個子類,它保留插入順序,幫助我們實現了有序的HashMap。LinkedHashMap維護著一個運行於所有條目的雙重連結列表,在每次插入資料,或者訪問、修改資料時,會增加節點、或調整連結串列的節點順序,用於定義迭代順序,該迭代順序可以是插入順序或者是訪問順序。

注:其維護一個雙向連結串列,並不是說其除了維護存入的資料,另外維護了一個雙向連結串列物件,而是說其根據重寫HashMap的實體類Entry,來實現能夠將HashMap的資料組成一個雙向列表,其儲存的結構還是陣列+連結串列的形式,看下面我自己理解的儲存和遍歷的流程圖大家可以看下

LinkedHashMap是Map介面的雜湊表和連結列表實現,具有可預知的迭代,LinkedHashMap能夠做到按照插入順序或者訪問順序進行迭代順序。此實現提供所有可選的對映操作,並允許使用null值和null鍵。此類不保證對映的順序,特別是它不保證該順序恆久不變。

和HashMap一樣,其不保證執行緒安全。

成員變數

LinkedHashMap採用的hash演算法和HashMap相同,但是它重新定義了陣列中儲存的元素Entry,該Entry除了儲存當前物件的引用外,還新增了其上一個元素before和下一個元素after的引用,從而在雜湊表的基礎上又構成了雙向連結列表。

初始化

在LinkedHashMap的構造方法中,實際呼叫了父類HashMap的相關構造方法來構造一個底層存放的table陣列,但額外可以增加accessOrder這個引數,如果不設定,預設為false,代表按照插入順序進行迭代;當然可以顯式設定為true,代表以訪問順序進行迭代(根據連結串列中元素的順序可以分為:按插入順序的連結串列,和按訪問順序(呼叫get方法)的連結串列。預設是按插入順序排序,如果指定按訪問順序排序,那麼呼叫get方法後,會將這次訪問的元素移至連結串列尾部,不斷訪問可以形成按訪問順序排序的連結串列。)。

public LinkedHashMap(int initialCapacity, float loadFactor,boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

我們已經知道LinkedHashMap的Entry元素繼承HashMap的Entry,提供了雙向連結串列的功能。在上述HashMap的構造器中,最後會呼叫init()方法,進行相關的初始化,這個方法在HashMap的實現中並無意義,只是提供給子類實現相關的初始化呼叫。

但在LinkedHashMap重寫了init()方法,在呼叫父類的構造方法完成構造後,進一步實現了對其元素Entry的初始化操作。

void init() {
  header = new Entry<>(-1, null, null, null);
  header.before = header.after = header;
}

資料儲存

LinkedHashMap並未重寫父類HashMap的put方法(所以其呼叫的方法邏輯還是HashMap的put方法的儲存邏輯),而是重寫了父類HashMap的put方法呼叫的子方法void recordAccess(HashMap m) ,void addEntry(int hash, K key, V value, int bucketIndex) 和void createEntry(int hash, K key, V value, int bucketIndex),提供了自己特有的雙向連結列表的實現。
HashMap.put:

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
}

重寫方法:

void recordAccess(HashMap<K,V> m) {
    LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
    if (lm.accessOrder) {
        lm.modCount++;
        remove();
        addBefore(lm.header);
        }
}

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 呼叫create方法,將新元素以雙向連結串列的的形式加入到對映中。
    createEntry(hash, key, value, bucketIndex);

    // 刪除最近最少使用元素的策略定義
    Entry<K,V> eldest = header.after;
    if (removeEldestEntry(eldest)) {
        removeEntryForKey(eldest.key);
    } else {
        if (size >= threshold)
            resize(2 * table.length);
    }
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    HashMap.Entry<K,V> old = table[bucketIndex];
    Entry<K,V> e = new Entry<K,V>(hash, key, value, old);
    table[bucketIndex] = e;
    // 呼叫元素的addBrefore方法,將元素加入到雜湊、雙向連結列表。  
    e.addBefore(header);
    size++;
}

private void addBefore(Entry<K,V> existingEntry) {
    after  = existingEntry;
    before = existingEntry.before;
    before.after = this;
    after.before = this;
}

對於資料的儲存,其邏輯主要在上面的addBefore這個方法,其邏輯可以看下面三個圖:
1.新增一個key為1,value為7的資料。
這裡寫圖片描述

2.在上面的基礎上插入第二個資料,key為2 value為10的資料。
這裡寫圖片描述
3.同理插入第三個資料之後,其形成的雙向連結串列如圖所示。
這裡寫圖片描述

資料讀取

LinkedHashMap重寫了父類HashMap的get方法,實際在呼叫父類getEntry()方法取得查詢的元素後,再判斷當排序模式accessOrder為true時,記錄訪問順序,將最新訪問的元素新增到雙向連結串列的表頭,並從原來的位置刪除。由於的連結串列的增加、刪除操作是常量級的,故並不會帶來效能的損失。

資料遍歷
從上面的流程圖形成的雙向連結串列,我們應該很容易就能有序遍歷出來吧,這裡就不多說了。
說一下一種情況,就是刪除一個數據,怎麼維護其連結串列,或者以訪問排序情況下,訪問一個元素之後,會怎麼維護連結串列。
刪除一個元素,維護連結串列主要在remove()方法的邏輯:

private void remove() {
    before.after = after;//將刪除元素的before屬性儲存的引用的after資料改成刪除元素的after儲存的引用,(本來應該是刪除元素的引用)
    after.before = before;//將刪除元素的after屬性儲存的引用的before資料改成刪除元素的before儲存的引用,
}

如下圖
這裡寫圖片描述

如果以訪問排序情況下,訪問一個元素之後,會怎麼維護連結串列。
這個雖然是訪問元素,但是當設定以訪問排序時,其仍然會先將元素從原本位置remove掉,然後在將該元素以新元素插入到鏈頭,哪怕其新插入的位置還在原位置(所以如果以訪問排序,其過程會涉及到刪除資料和增添資料)。不過如果預設排序,則不會有此操作。如下圖,是訪問之後的雙鏈表。
這裡寫圖片描述

LinkedHashMap和HashMap比較

HashMap LinkedHashMap
無序儲存 有序儲存,以雙向連結串列實現
讀取速度與容量有關 讀取速度與容量無關
執行緒不安全 執行緒不安全
key-value都允許null key-value都允許null

TreeMap

TreeMap 是一個有序的key-value集合,它是通過紅黑樹實現的。
TreeMap 繼承於AbstractMap,所以它是一個Map,即一個key-value集合。
TreeMap 實現了NavigableMap介面,意味著它支援一系列的導航方法。比如返回有序的key集合。
TreeMap 實現了Cloneable介面,意味著它能被克隆。

TreeMap基於紅黑樹(Red-Black tree)實現。該對映根據其鍵的自然順序(字母排序)進行排序,或者根據建立對映時提供的 Comparator 進行排序,具體取決於使用的構造方法。
TreeMap的基本操作 containsKey、get、put 和 remove 的時間複雜度是 log(n)
TreeMap是非執行緒安全的。 它的iterator 方法返回的迭代器是fail-fast的。

這裡就不詳細介紹TreeMap了,我直接寫其與HashMap的區別

HashMap TreeMap
遍歷出來資料無序 自然排序或者建立對映提供的Comparator 進行排序
基於散列表 紅黑樹
取值速度快 取值速度慢
適用於在Map中插入、刪除和定位元素 適用於按自然順序或自定義順序遍歷鍵(key)

ConcurrentHashMap

如果大家想找詳細的說明,可以看這篇博文

我們知道HashMap是非執行緒安全的,我們想要執行緒安全的集合只能使用使用鎖的HashTable或者Collections.synchronizedMap(hashMap),但是由於其操作都是使用鎖進行鎖定操作的,其效能較差,所以ConcurrentHashMap就是為了解決HashMap的非執行緒安全性的。

ConcurrentHashMap是弱一致性,也就是說遍歷過程中其他執行緒可能對連結串列結構做了調整,因此get和containsKey返回的可能是過時的資料

ConcurrentHashMap究其根本,其還是一個HashMap的實現,不過是在HashMap的方法上面做了一些處理。

設計思路

ConcurrentHashMap採用了分段鎖的設計,只有在同一個分段內才存在競態關係,不同的分段鎖之間沒有鎖競爭。相比於對整個Map加鎖的設計,分段鎖大大的提高了高併發環境下的處理能力。

ConcurrentHashMap中的分段鎖稱為Segment,它即類似於HashMap的結構,即內部擁有一個Entry陣列,陣列中的每個元素又是一個連結串列;同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。ConcurrentHashMap中的HashEntry相對於HashMap中的Entry有一定的差異性:HashEntry中的value以及next都被volatile修飾,這樣在多執行緒讀寫過程中能夠保持它們的可見性,程式碼如下:

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

併發度

併發度可以理解為程式執行時能夠同時更新ConccurentHashMap且不產生鎖競爭的最大執行緒數,實際上就是ConcurrentHashMap中的分段鎖個數,即Segment[]的陣列長度。ConcurrentHashMap**預設的併發度為16**,但使用者也可以在建構函式中設定併發度。當用戶設定併發度時,ConcurrentHashMap會使用大於等於該值的最小2冪指數作為實際併發度(假如使用者設定併發度為17,實際併發度則為32)。執行時通過將key的高n位(n = 32 – segmentShift)和併發度減1(segmentMask)做位與運算定位到所在的Segment。segmentShift與segmentMask都是在構造過程中根據concurrency level被相應的計算出來。

如果併發度設定的過小,會帶來嚴重的鎖競爭問題;如果併發度設定的過大,原本位於同一個Segment內的訪問會擴散到不同的Segment中,CPU cache命中率會下降,從而引起程式效能下降。(文件的說法是根據你併發的執行緒數量決定,太多會導效能降低)

建立分段鎖

和JDK6不同,JDK7中除了第一個Segment之外,剩餘的Segments採用的是延遲初始化的機制:每次put之前都需要檢查key對應的Segment是否為null,如果是則呼叫ensureSegment()以確保對應的Segment被建立。

ensureSegment可能在併發環境下被呼叫,但與想象中不同,ensureSegment並未使用鎖來控制競爭,而是使用了Unsafe物件的getObjectVolatile()提供的原子讀語義結合CAS來確保Segment建立的原子性。

儲存資料

ConcurrentHashMap的put方法被代理到了對應的Segment中。與JDK6不同的是,JDK7版本的ConcurrentHashMap在獲得Segment鎖的過程中,做了一定的優化 - 在真正申請鎖之前,put方法會通過tryLock()方法嘗試獲得鎖,在嘗試獲得鎖的過程中會對對應hashcode的連結串列進行遍歷,如果遍歷完畢仍然找不到與key相同的HashEntry節點,則為後續的put操作提前建立一個HashEntry。當tryLock一定次數後仍無法獲得鎖,則通過lock申請鎖。

需要注意的是,由於在併發環境下,其他執行緒的put,rehash或者remove操作可能會導致連結串列頭結點的變化,因此在過程中需要進行檢查,如果頭結點發生變化則重新對錶進行遍歷。而如果其他執行緒引起了連結串列中的某個節點被刪除,即使該變化因為是非原子寫操作可能導致當前執行緒無法觀察到,但因為不影響遍歷的正確性所以忽略不計。

之所以在獲取鎖的過程中對整個連結串列進行遍歷,主要目的是希望遍歷的連結串列被CPU cache所快取,為後續實際put過程中的連結串列遍歷操作提升效能。

在獲得鎖之後,Segment對連結串列進行遍歷,如果某個HashEntry節點具有相同的key,則更新該HashEntry的value值,否則新建一個HashEntry節點,將它設定為連結串列的新head節點並將原頭節點設為新head的下一個節點。新建過程中如果節點總數(含新建的HashEntry)超過threshold,則呼叫rehash()方法對Segment進行擴容,最後將新建HashEntry寫入到陣列中。

put方法中,連結新節點的下一個節點(HashEntry.setNext())以及將連結串列寫入到陣列中(setEntryAt())都是通過Unsafe的putOrderedObject()方法來實現,這裡並未使用具有原子寫語義的putObjectVolatile()的原因是:JMM會保證獲得鎖到釋放鎖之間所有物件的狀態更新都會在鎖被釋放之後更新到主存,從而保證這些變更對其他執行緒是可見的。

刪除資料

和put類似,remove在真正獲得鎖之前,也會對連結串列進行遍歷以提高快取命中率。

獲取資料

get與containsKey兩個方法幾乎完全一致:他們都沒有使用鎖,而是通過Unsafe物件的getObjectVolatile()方法提供的原子讀語義,來獲得Segment以及對應的連結串列,然後對連結串列遍歷判斷是否存在key相同的節點以及獲得該節點的value。但由於遍歷過程中其他執行緒可能對連結串列結構做了調整,因此get和containsKey返回的可能是過時的資料,這一點是ConcurrentHashMap在弱一致性上的體現。如果要求強一致性,那麼必須使用Collections.synchronizedMap()方法。

Fail-Fast機制

我們知道java.util.HashMap不是執行緒安全的,因此如果在使用迭代器的過程中有其他執行緒修改了map,那麼將丟擲ConcurrentModificationException,這就是所謂fail-fast策略。

ail-fast 機制是java集合(Collection)中的一種錯誤機制。 當多個執行緒對同一個集合的內容進行操作時,就可能會產生 fail-fast 事件。

例如:當某一個執行緒A通過 iterator去遍歷某集合的過程中,若該集合的內容被其他執行緒所改變了;那麼執行緒A訪問集合時,就會丟擲 ConcurrentModificationException異常,產生 fail-fast 事件。

這一策略在原始碼中的實現是通過modCount域,modCount顧名思義就是修改次數,對HashMap內容(當然不僅僅是HashMap才會有,其他例如ArrayList也會)的修改都將增加這個值(大家可以再回頭看一下其原始碼,在很多操作中都有modCount++這句),那麼在迭代器初始化過程中會將這個值賦給迭代器的expectedModCount。

HashIterator() {
    expectedModCount = modCount;
    if (size > 0) { // advance to first entry
    Entry[] t = table;
    while (index < t.length && (next = t[index++]) == null)  
        ;
    }
}

在迭代過程中,判斷modCount跟expectedModCount是否相等,如果不相等就表示已經有其他執行緒修改了Map:

注意到modCount宣告為volatile,保證執行緒之間修改的可見性。

final Entry<K,V> nextEntry() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();

在HashMap的API中指出:

由所有HashMap類的“collection 檢視方法”所返回的迭代器都是快速失敗的:在迭代器建立之後,如果從結構上對對映進行修改,除非通過迭代器本身的 remove 方法,其他任何時間任何方式的修改,迭代器都將丟擲 ConcurrentModificationException。因此,面對併發的修改,迭代器很快就會完全失敗,而不冒在將來不確定的時間發生任意不確定行為的風險。

注意,迭代器的快速失敗行為不能得到保證,一般來說,存在非同步的併發修改時,不可能作出任何堅決的保證。快速失敗迭代器盡最大努力丟擲ConcurrentModificationException。因此,編寫依賴於此異常的程式的做法是錯誤的,正確做法是:迭代器的快速失敗行為應該僅用於檢測程式錯誤。

解決方案

在上文中也提到,fail-fast機制,是一種錯誤檢測機制。它只能被用來檢測錯誤,因為JDK並不保證fail-fast機制一定會發生。若在多執行緒環境下使用 fail-fast機制的集合,建議使用“java.util.concurrent包下的類”去取代“java.util包下的類”。