多執行緒知識梳理(7) ConcurrentHashMap 實現原理

澤毛發表於2017-12-21

一、前言

ConcurrentHashMap是執行緒安全並且高效的HashMap,其它的類似容器有以下缺點:

  • HashMap在併發執行put操作時,會導致Entry連結串列形成環形資料結構,就會產生死迴圈獲取Entry
  • HashTable使用synchronized來保證執行緒安全,但線上程競爭激烈的情況下HashTable的效率非常低下。

ConcurrentHashMap高效的原因在於它採用 鎖分段技術,首先將資料分成一段一段地儲存,然後給每段資料配一把鎖,當一個執行緒佔用鎖並且訪問一段資料的時候,其他段的資料也能被其他執行緒訪問。

二、 ConcurrentHashMap 的結構

ConcurrentHashMap是由Segment陣列結構和HashEntry陣列結構組成:

  • Segment是一種可重入鎖,在ConcurrentHashMap裡面扮演鎖的角色。
  • HashEntry則用於儲存鍵值對資料。

一個ConcurrentHashMap裡包含一個Segment陣列,它的結構和HashMap類似,是一種陣列和連結串列結構。

segment
一個Segment裡包含一個HashEntry陣列,每個HashEntry是一個連結串列結構的元素,每個Segment守護著一個HashEntry裡的元素,當對HashEntry陣列的資料進行修改時,必須首先獲得與它對應的Segment鎖。

Segment 結構

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile int count;
    transient int modCount;
    transient int threshold;
    transient volatile HashEntry<K,V>[] table;
    final float loadFactor;
}
複製程式碼
  • countSegment中元素的數量
  • modCount:對table的大小造成影響的操作的數量
  • threshold:閾值,Segment裡面元素的數量超過這個值依舊就會對Segment進行擴容
  • table:連結串列陣列,陣列中的每一個元素代表了一個連結串列的頭部
  • loadFactor:負載因子,用於確定threshold

HashEntry 結構

static final class HashEntry<K,V> {
    final K key;
    final int hash;
    volatile V value;
    final HashEntry<K,V> next;
}
複製程式碼

2.1 初始化

ConcurrentHashMap的初始化方法是通過initialCapacityloadFactorconcurrencyLevel等幾個引數來初始化segment陣列、段偏移量segmentShift、段掩碼segmentMask和每個segment裡的HashEntry來實現的。

2.1.1 初始化 segment 陣列

初始化segment的原始碼如下,它會計算出:

  • ssizesegment陣列的長度
  • segmentShiftsshift等於ssize1向左移位的次數,segmentShift等於32-sshiftsegmentShift用於 定位參與雜湊運算的位數
  • segmentMask雜湊運算的掩碼,等於ssize-1
if (concurrencyLevel > MAX_SEGMENTS)
    concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
int ssize = 1;
//計算 segments 陣列的長度,它是大於等於 concurrencyLevel 的最小的 2 的 N 次方。
while (ssize < concurrencyLevel) {
    ++sshift;
    ssize <<= 1;
}
segmentShift = 32 - sshift;
segmentMask = ssize - 1;
this.segments = Segment.newArray(ssize);
複製程式碼

2.1.2 初始化每個 segment

輸入引數initialCapacityConcurrentHashMap的初始化容量,loadFactor是每個segment的負載因子,在構造方法裡通過這兩個引數來初始化陣列中的每個segment

if (initialCapacity < MAXIMUM_CAPACITY) {
    initialCapacity = MAXIMUM_CAPACITY;
}
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity) {
    ++c;
}
int cap = 1;
while (cap < c) {
    cap <<= 1;
}
for (int i = 0; i < this.segments.length; i++) {
    this.segments[i] = new Segment<K, V>(cap, loadFactor);
}
複製程式碼

cap 是 segment 裡 HashEntry 陣列的長度,它等於initialCapacity / ssize,如果c大於1,就會取大於等於c2N次方。segment的容量threshold等於(int) cap * loadFactor,預設情況下initialCapacity等於16ssize等於16loadFactor等於0.75,因此cap等於1threshold等於0

2.2 定位 segment

在插入和獲取元素的時候,必須先通過雜湊演算法定位到SegmentConcurrentHashMap會首先對元素的hashCode()進行一次再雜湊。

private static int hash(int h) {
    h += (h << 15) ^ 0xffffcd7d;
    h ^= (h >>> 10);
    h += (h << 3);
    h ^= (h >>> 6);
    h += (h << 2) + (h << 14);
    return h ^ (h >>> 16);
}
複製程式碼

再雜湊的目的是減少雜湊衝突,使元素能夠均勻地分佈在不同的Segment上,從而提高容器的存取效率。

2.3 操作

2.3.1 get 操作

segmentget操作過程為:先進行一次再雜湊,然後使用這個雜湊值通過雜湊運算定位到Segment,再通過雜湊演算法定位到元素。

public V get(Object key) {
    int hash = hash(key.hashCode());
    return segmentFor(hash).get(key, hash);
}
複製程式碼

get操作的高效之處在於整個get過程不需要加鎖,除非讀到的值為空才加鎖重讀。在它的get方法裡,將要使用的共享變數都定義成volatile型別,如用於統計當前segment大小的count欄位和用於儲存值的HashEntryvalue,定義成volatile的變數,能夠線上程之間保持可見性,能夠被多執行緒同時讀,並且保證不會讀到過期的值,在get操作裡,只需要讀而不需要寫共享變數countvalue,所以可以不用加鎖。

transient volatile int count;
volatile V value;
複製程式碼

2.3.2 put 操作

由於put方法需要對共享變數進行寫入,所以為了執行緒安全,在操作共享變數時必須加鎖。put方法首先定位到Segment,然後在Segment裡進行插入操作。插入操作需要經歷兩個步驟:

  • 判斷是否需要對Segment裡的HashEntry陣列進行擴容
  • 定位新增元素的位置,然後將其放在HashEntry陣列裡

2.3.3 size 操作

如果要統計整個ConcurrentHashMap裡元素的大小,就必須統計所有Segment元素的大小後求和,雖然每個Segment的全域性變數count是一個volatile變數,在相加時可以獲取最新值,但是不能保證之前累加過的Segment大小不發生變化。

因此,ConcurrentHashMap會先嚐試2次通過不鎖住Segment的方式來統計各個Segment大小,如果統計的過程中,容器的count發生了變化,則再採用加鎖的方式來統計所有Segment的大小。

檢測容器大小是否發生變化的原理為:在putremoveclean方法裡操作元素前會將變數modCount進行加1,那麼在統計size前後比較modCount是否發生變化,從而得知容器的大小是否發生變化。

三、參考文獻

<<Java併發程式設計的藝術>> - Java併發容器和框架

相關文章