ConcurrentHashMap詳解

爲什麼要用ConcurrentHashMap?

1、線程不安全的HashMap

在多線程環境下,使用HashMap的put操作會引起死循環,原因是多線程會導致HashMap的Entry鏈表形成環形數據結構,導致Entry的next節點永遠不爲空,就會產生死循環獲取Entry。

2、效率低下的HashTable

HashTable容器使用sychronized來保證線程安全,採取鎖住整個表結構來達到同步目的,在線程競爭激烈的情況下,當一個線程訪問HashTable的同步方法,其他線程也訪問同步方法時,會進入阻塞或輪詢狀態;如線程1使用put方法時,其他線程既不能使用put方法,也不能使用get方法,效率非常低下。

3、ConcurrentHashMap的鎖分段技術可提升併發訪問效率

首先將數據分成一段一段地存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問。

ConcurrentHashMap的結構

  • ConcurrentHashMap由Segment數組結構和HashEntry數組結構組成;
  • Segment是一種可重入鎖(ReentrantLock),HashEntry用於存儲鍵值對數據;
  • 一個ConcurrentHashMap包含一個由若干個Segment對象組成的數組,每個Segment對象守護整個散列映射表的若干個桶,每個桶是由若干個HashEntry對象鏈接起來的鏈表,table是一個由HashEntry對象組成的數組,table數組的每一個數組成員就是散列映射表的一個桶。
    img

HashEntry類

static final class HashEntry<K,V> { 
       final K key;                       // 聲明 key 爲 final 型
       final int hash;                   // 聲明 hash 值爲 final 型 
       volatile V value;                 // 聲明 value 爲 volatile 型
       final HashEntry<K,V> next;      // 聲明 next 爲 final 型 
 
       HashEntry(K key, int hash, HashEntry<K,V> next, V value) { 
           this.key = key; 
           this.hash = hash; 
           this.next = next; 
           this.value = value; 
       } 
}

在ConcurrentHashMap中,在散列時如果產生“碰撞”,將採用“分離鏈接法”來處理“碰撞”:把“碰撞”的HashEntry對象鏈接成一個鏈表。由於HashEntry的next域爲final型,所以新節點只能在鏈表的表頭處插入

下圖是在一個空桶中依次插入 A,B,C 三個 HashEntry 對象後的結構圖:
img

HashEntry對象的不變性

HashEntry對象的key、hash、next都聲明爲final類型,這意味着不能把節點添加到鏈表的中間和尾部,也不能在鏈表的中間和尾部刪除節點。這個特性可以保證:在訪問某個節點時,這個節點之後的鏈接不改變。

同時,HashEntry的value被聲明爲volatile類型,Java的內存模型可以保證:某個寫線程對value的寫入馬上可以被後續的讀線程看到。ConcurrentHashMap不允許用null爲鍵和值,當讀線程讀到某個HashEntry的value爲null時,便知道產生了衝突——發生了重排序現象,需要加鎖後重新讀這個value值。這些特性保證讀線程不用加鎖也能正確訪問ConcurrentHashMap。

結構性修改操作:put、remove、clear

  1. clear只是把容器中所有的桶置空,每個桶之前引用的鏈表依然存在,正在遍歷某個鏈表的讀線程依然可以正常執行對該鏈表的遍歷。
  2. put操作在插入一個新節點到鏈表時,會在鏈表頭部插入新節點,此時,鏈表原有節點的鏈表並沒有修改,不會影響讀操作正常遍歷這個鏈表。
  3. remove操作,首先根據散列碼找到具體的鏈表,然後遍歷這個鏈表找到要刪除的節點,最後把待刪除節點之後的所有節點原樣保留在新鏈表中,把待刪除節點之前的每個節點克隆到新鏈表中,注意克隆到新鏈表中的鏈接順序被反轉了。

刪除之前的原鏈表:
img

刪除節點C之後的鏈表:
img

總結:寫線程對某個鏈表的結構性修改不會影響其他的併發讀線程對這個鏈表的遍歷訪問。

Segment類

static final class Segment<K,V> extends ReentrantLock implements Serializable { 
       /** 
        * 在本 segment 範圍內,包含的 HashEntry 元素的個數
        * 該變量被聲明爲 volatile 型
        */ 
       transient volatile int count; 
 
       /** 
        * table 被更新的次數
        */ 
       transient int modCount; 
 
       /** 
        * 當 table 中包含的 HashEntry 元素的個數超過本變量值時,觸發 table 的再散列
        */ 
       transient int threshold; 
 
       /** 
        * table 是由 HashEntry 對象組成的數組
        * 如果散列時發生碰撞,碰撞的 HashEntry 對象就以鏈表的形式鏈接成一個鏈表
        * table 數組的數組成員代表散列映射表的一個桶
        * 每個 table 守護整個 ConcurrentHashMap 包含桶總數的一部分
        * 如果併發級別爲 16,table 則守護 ConcurrentHashMap 包含的桶總數的 1/16 
        */ 
       transient volatile HashEntry<K,V>[] table; 
 
       /** 
        * 裝載因子
        */ 
       final float loadFactor; 
 
       Segment(int initialCapacity, float lf) { 
           loadFactor = lf; 
           setTable(HashEntry.<K,V>newArray(initialCapacity)); 
       } 
 
       /** 
        * 設置 table 引用到這個新生成的 HashEntry 數組
        * 只能在持有鎖或構造函數中調用本方法
        */ 
       void setTable(HashEntry<K,V>[] newTable) { 
           // 計算臨界閥值爲新數組的長度與裝載因子的乘積
           threshold = (int)(newTable.length * loadFactor); 
           table = newTable; 
       } 
 
       /** 
        * 根據 key 的散列值,找到 table 中對應的那個桶(table 數組的某個數組成員)
        */ 
       HashEntry<K,V> getFirst(int hash) { 
           HashEntry<K,V>[] tab = table; 
           // 把散列值與 table 數組長度減 1 的值相“與”,
           // 得到散列值對應的 table 數組的下標
           // 然後返回 table 數組中此下標對應的 HashEntry 元素
           return tab[hash & (tab.length - 1)]; 
       } 
}

下圖是依次插入 ABC 三個 HashEntry 節點後,Segment 的結構示意圖:
img

ConcurrentHashMap類

  • ConcurrentHashMap在默認併發級別會創建包含16個Segment對象的數組。
  • 每個Segment的成員對象table包含若干個散列表的桶。
  • 每個桶是由HashEntry鏈接起來的一個鏈表。
  • 如果鍵能均勻散列,每個Segment大約守護整個散列表中桶總數的 1/16。
public class ConcurrentHashMap<K, V> extends AbstractMap<K, V> 
       implements ConcurrentMap<K, V>, Serializable { 
 
   /** 
    * 散列映射表的默認初始容量爲 16,即初始默認爲 16 個桶
    * 在構造函數中沒有指定這個參數時,使用本參數
    */ 
   static final int DEFAULT_INITIAL_CAPACITY= 16; 
 
   /** 
    * 散列映射表的默認裝載因子爲 0.75,該值是 table 中包含的 HashEntry 元素的個數與 table 數組長度的比值
    * 當 table 中包含的 HashEntry 元素的個數超過了 table 數組的長度與裝載因子的乘積時,將觸發再散列
    * 在構造函數中沒有指定這個參數時,使用本參數
    */ 
   static final float DEFAULT_LOAD_FACTOR= 0.75f; 
 
   /** 
    * 散列表的默認併發級別爲 16。該值表示當前更新線程的估計數
    * 在構造函數中沒有指定這個參數時,使用本參數
    */ 
   static final int DEFAULT_CONCURRENCY_LEVEL= 16; 
 
   /** 
    * segments 的掩碼值,對應的二進制每一位都是1,等於ssize-1,最大值是65535,默認值是15
    * key 的散列碼的高位用來選擇具體的 segment 
    */ 
   final int segmentMask; 
 
   /** 
    * 偏移量,用於定位參與散列運算的位數,等於32-sshift,最大值爲16,默認值是28
    */ 
   final int segmentShift; 
 
   /** 
    * 由 Segment 對象組成的數組
    */ 
   final Segment<K,V>[] segments; 
 
   /** 
    * 創建一個帶有指定初始容量、加載因子和併發級別的新的空映射。
    */ 
   public ConcurrentHashMap(int initialCapacity, 
                            float loadFactor, int concurrencyLevel) { 
       if(!(loadFactor > 0) || initialCapacity < 0 || 
concurrencyLevel <= 0) 
           throw new IllegalArgumentException(); 
 
       if(concurrencyLevel > MAX_SEGMENTS) 
           concurrencyLevel = MAX_SEGMENTS; 
 
       // ssize從1向左移位的次數 
       int sshift = 0; 
       // Segment數組的長度,爲2的N次方
       int ssize = 1; 
       while(ssize < concurrencyLevel) { 
           ++sshift; 
           ssize <<= 1; 
       } 
       segmentShift = 32 - sshift;       // 偏移量值
       segmentMask = ssize - 1;           // 掩碼值 
       this.segments = Segment.newArray(ssize);   // 創建數組
 
       if (initialCapacity > MAXIMUM_CAPACITY) 
           initialCapacity = MAXIMUM_CAPACITY; 
       int c = initialCapacity / ssize; 
       if(c * ssize < initialCapacity) 
           ++c; 
        // HashEntry數組的長度
       int cap = 1; 
       while(cap < c) 
           cap <<= 1; 
 
       // 依次遍歷每個數組元素
       for(int i = 0; i < this.segments.length; ++i){ 
            // 初始化每個數組元素引用的 Segment 對象
            this.segments[i] = new Segment<K,V>(cap, loadFactor); 
        } 
 
   /** 
    * 創建一個帶有默認初始容量 (16)、默認加載因子 (0.75) 和 默認併發級別 (16) 的空散列映射表。
    */ 
   public ConcurrentHashMap() { 
    // 使用三個默認參數,調用上面重載的構造函數來創建空散列映射表
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL); 
    }
}

ConcurrentHashMap的操作

put操作

1、根據key算出對應的hash值

public V put(K key, V value) { 
        if (value == null)          //ConcurrentHashMap 中不允許用 null 作爲映射值
           throw new NullPointerException();
        // 計算鍵對應的散列碼
        int hash = hash(key.hashCode());        
        // 根據散列碼找到對應的 Segment 
        return segmentFor(hash).put(key, hash, value, false); 
}

2、根據hash值找到對應的Segment對象

/** 
 * 使用 key 的散列碼來得到 segments 數組中對應的 Segment 
 */ 
final Segment<K,V> segmentFor(int hash) { 
    // 將散列值無符號右移 segmentShift 個位,並在高位填充 0 
    // 然後把得到的值與 segmentMask 相“與”
    // 從而得到 hash 值對應的 segments 數組的下標值
    // 最後根據下標值返回散列碼對應的 Segment 對象
    return segments[(hash >>> segmentShift) & segmentMask]; 
}

3、在Segment中執行具體的put操作

V put(K key, int hash, V value, boolean onlyIfAbsent) { 
        lock();  // 加鎖,這裏是鎖定某個 Segment 對象而非整個 ConcurrentHashMap 
        try { 
            int c = count; 
            if (c++ > threshold)     // 如果超過再散列的閾值
                rehash();            // 執行再散列,table 數組的長度將擴充一倍
 
            HashEntry<K,V>[] tab = table; 
            // 把散列碼值與 table 數組的長度減 1 的值相“與”
            // 得到該散列碼對應的 table 數組的下標值
            int index = hash & (tab.length - 1); 
            // 找到散列碼對應的具體的那個桶
            HashEntry<K,V> first = tab[index]; 
 
            HashEntry<K,V> e = first; 
            while (e != null && (e.hash != hash || !key.equals(e.key))) 
                   e = e.next; 
 
            V oldValue; 
            if (e != null) {            // 如果鍵值對以經存在
                oldValue = e.value; 
                if (!onlyIfAbsent) 
                    e.value = value;    // 設置 value 值
                } 
                else {                        // 鍵值對不存在 
                    oldValue = null; 
                    ++modCount;         // 要添加新節點到鏈表中,所以 modCont 要加 1  
                    // 創建新節點,並添加到鏈表的頭部 
                    tab[index] = new HashEntry<K,V>(key, hash, first, value); 
                    count = c;               // 寫 count 變量
                } 
                return oldValue; 
            } finally { 
                unlock();                     // 解鎖
            } 
       }

插入操作需要兩個步驟

1、是否需要擴容

插入元素前會先判斷Segment裏面的HashEntry數組是否超過容量(threshold),如果超過則進行擴容。Segment的擴容比HashMap更恰當,HashMap是在插入元素後再判斷元素是否已經到達容量。

2、如何擴容

首先會創建一個容量是原來容量兩倍的數組,然後將原數組裏面的元素進行再散列後插入到新數組裏;ConcurrentHashMap不會對整個容器進行擴容,只對某個Segment進行擴容。

get操作

先經過一次再散列,然後使用這個散列值通過散列運算定位到Segment,再通過散列算法定位到元素,代碼如下:

public V get(Object key){
    int hash = hash(key.hashCode());
    return segmentFor(hash).get(key, hash);
}

get操作的高效之處在於get過程不需要加鎖,除非讀到的值是null纔會加鎖重讀。原因是它的get方法裏面將要使用的共享變量都定義成volatile類型,在多線程之間保持可見性,原理是根據Java內存模型的happen-before原則,對volatile字段的寫入操作先於讀操作。

定位Segment和HashEntry的不同: 定位Segment使用的是元素的hashcode通過再散列後得到值的高位;定位HashEntry直接使用的是再散列後的值。

//定位Segment的算法
(hash >>> segmentShift) & segmentMask;

//定位HashEntry的算法
int index = hash & (tab.length-1);

size操作

做法是累加每個Segment裏面的全局變量count,它是volatile類型,用來統計Segment中HashEntry的個數,但是不能直接進行累加,因爲累加的時候count可能會發生變化。所以ConcurrentHashMap的做法是先嚐試2次通過不鎖住Segment的方式來統計各個Segment大小,如果統計過程中,容器的count發生了變化,則採用再加鎖的方式來統計所有Segment的大小

那是如何判斷在統計的時候容器是否發生了變化呢? 使用modCount變量,在put、remove和clean等結構性修改方法裏操作元素前都會將該變量進行加一,在統計size前後比較modCount是否發生變化,從而得知容器的大小是否發生了變化。

remove操作

V remove(Object key, int hash, Object value) { 
           lock();         // 加鎖
           try{ 
               int c = count - 1; 
               HashEntry<K,V>[] tab = table; 
               // 根據散列碼找到 table 的下標值
               int index = hash & (tab.length - 1); 
               // 找到散列碼對應的那個桶
               HashEntry<K,V> first = tab[index]; 
               HashEntry<K,V> e = first; 
               while(e != null&& (e.hash != hash || !key.equals(e.key))) 
                   e = e.next; 
 
               V oldValue = null; 
               if(e != null) { 
                   V v = e.value; 
                   if(value == null|| value.equals(v)) { // 找到要刪除的節點
                       oldValue = v; 
                       ++modCount; 
                       // 所有處於待刪除節點之後的節點原樣保留在鏈表中
                       // 所有處於待刪除節點之前的節點被克隆到新鏈表中
                       HashEntry<K,V> newFirst = e.next;// 待刪節點的後繼結點
                       for(HashEntry<K,V> p = first; p != e; p = p.next) 
                           newFirst = new HashEntry<K,V>(p.key, p.hash, 
                                                         newFirst, p.value); 
                       // 把桶鏈接到新的頭結點
                       // 新的頭結點是原鏈表中,刪除節點之前的那個節點
                       tab[index] = newFirst; 
                       count = c;      // 寫 count 變量
                   } 
               } 
               return oldValue; 
           } finally{ 
               unlock();               // 解鎖
           } 
}

ConcurrentHashMap實現高併發的總結

讀操作的高效率

在實際的應用中,散列表一般的應用場景是:除了少數插入操作和刪除操作外,絕大多數都是讀取操作,而且讀操作在大多數時候都是成功的。正是基於這個前提,ConcurrentHashMap針對讀操作做了大量的優化。通過HashEntry對象的不變性和用volatile型變量協調線程間的內存可見性,使得大多數時候,讀操作不需要加鎖就可以正確獲得值。

比HashTable和HashMap擁有更高併發性

相比於HashTable和用同步包裝器包裝的HashMap

Collections.synchronizedMap(new HashMap());

ConcurrentHashMap擁有更高的併發性。在HashTable和由同步包裝器包裝的HashMap中,使用一個全局的鎖來同步不同線程間的併發訪問。同一時間點,只能有一個線程持有鎖,也就是說在同一時間點,只能有一個線程能訪問容器。這雖然保證多線程間的安全併發訪問,但同時也導致對容器的訪問變成串行化的了。

ConcurrentHashMap的高併發性主要來自於三個方面

  1. 用分離鎖實現多個線程間的更深層次的共享訪問。
  2. 用HashEntery對象的不變性來降低執行讀操作的線程在遍歷鏈表期間對加鎖的需求。
  3. 通過對同一個volatile變量的寫/讀訪問,協調不同線程間讀/寫操作的內存可見性。

參考來源:

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