ConcurrentHashMap

N1ce2cu發表於2024-07-27

ConcurrentHashMap 是 Java 併發包 (java.util.concurrent) 中的一種執行緒安全的雜湊表實現。

HashMap 在多執行緒環境下擴容會出現 CPU 接近 100% 的情況,因為 HashMap 並不是執行緒安全的,我們可以透過 Collections 的Map<K,V> synchronizedMap(Map<K,V> m)將 HashMap 包裝成一個執行緒安全的 map。

比如 SynchronizedMap 的 put 方法原始碼就是加鎖過的:

public V put(K key, V value) {
    synchronized (mutex) {return m.put(key, value);}
}

ConcurrentHashMap 的變化


JDK 1.7

ConcurrentHashMap 在 JDK 1.7 中,提供了一種粒度更細的加鎖機制,這種機制叫分段鎖「Lock Striping」。整個雜湊表被分為多個段,每個段都獨立鎖定。讀取操作不需要鎖,寫入操作僅鎖定相關的段。這減小了鎖衝突的機率,從而提高了併發效能。

這種機制的優點:在併發環境下將實現更高的吞吐量,而在單執行緒環境下只損失非常小的效能。

可以這樣理解分段鎖,就是將資料分段,對每一段資料分配一把鎖。當一個執行緒佔用鎖訪問其中一個段資料的時候,其他段的資料也能被其他執行緒訪問。

有些方法需要跨段,比如 size()isEmpty()containsValue(),它們可能需要鎖定整個表而不僅僅是某個段,這需要按順序鎖定所有段,操作完後,再按順序釋放所有段的鎖。如下圖:

ConcurrentHashMap 是由 Segment 陣列結構和 HashEntry 陣列構成的。Segment 是一種可重入的鎖 ReentrantLock,HashEntry 則用於儲存鍵值對資料。

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

單一的 Segment 結構如下:

像這樣的 Segment 物件,在 ConcurrentHashMap 集合中有多少個呢?有 2 的 N 次方個,共同儲存在一個名為 segments 的陣列當中。 因此整個 ConcurrentHashMap 的結構如下:

可以說,ConcurrentHashMap 是一個二級雜湊表。在一個總的雜湊表下面,有若干個子雜湊表。

Case1:不同 Segment 的併發寫入(可以併發執行)

Case2:同一 Segment 的一寫一讀(可以併發執行)

Case3:同一 Segment 的併發寫入

Segment 的寫入是需要上鎖的,因此對同一 Segment 的併發寫入會被阻塞。

由此可見,ConcurrentHashMap 中每個 Segment 各自持有一把鎖。在保證執行緒安全的同時降低了鎖的粒度,讓併發操作效率更高。

ConcurrentHashMap 讀寫過程如下:

get 方法

  • 為輸入的 Key 做 Hash 運算,得到 hash 值。
  • 透過 hash 值,定位到對應的 Segment 物件
  • 再次透過 hash 值,定位到 Segment 當中陣列的具體位置。

put 方法

  • 為輸入的 Key 做 Hash 運算,得到 hash 值。
  • 透過 hash 值,定位到對應的 Segment 物件
  • 獲取可重入鎖
  • 再次透過 hash 值,定位到 Segment 當中陣列的具體位置。
  • 插入或覆蓋 HashEntry 物件。
  • 釋放鎖。

JDK 1.8

而在 JDK 1.8 中,ConcurrentHashMap 主要做了兩個最佳化:

  • 同 HashMap 一樣,連結串列也會在長度達到 8 的時候轉化為紅黑樹,這樣可以提升大量衝突時候的查詢效率;
  • 以某個位置的頭結點(連結串列的頭結點或紅黑樹的 root 結點)為鎖,配合自旋 + CAS 避免不必要的鎖開銷,進一步提升併發效能。

相比 JDK1.7 中的 ConcurrentHashMap,JDK1.8 中的 ConcurrentHashMap 取消了 Segment 分段鎖,採用 CAS + synchronized 來保證併發安全性,整個容器只分為一個 Segment,即 table 陣列。

JDK1.8 中的 ConcurrentHashMap 對節點 Node 類中的共享變數,和 JDK1.7 一樣,使用 volatile 關鍵字,保證多執行緒操作時,變數的可見性!

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;
    }
......
}

ConcurrentHashMap 的欄位


// 裝載 Node 的陣列,作為 ConcurrentHashMap 的底層容器,採用懶載入的方式,直到第一次插入資料的時候才會進行初始化操作,陣列的大小總是為 2 的冪次方
transient volatile Node<K,V>[] table;

// 擴容時使用,平時為 null,只有在擴容的時候才為非 null
private transient volatile Node<K,V>[] nextTable;

// 該屬性用來控制 table 陣列的大小,根據是否初始化和是否正在擴容有幾種情況:
// 當值為負數時: 如果為-1 表示正在初始化,如果為 -N 則表示當前正有 N-1 個執行緒進行擴容操作;
// 當值為正數時: 如果當前陣列為 null 的話表示 table 在初始化過程中,sizeCtl 表示為需要新建陣列的長度;若已經初始化了,表示當前資料容器(table 陣列)可用容量,也可以理解成臨界值(插入節點數超過了該臨界值就需要擴容),具體指為陣列的長度 n 乘以 載入因子 loadFactor;
// 當值為 0 時,即陣列長度為預設初始值。
private transient volatile int sizeCtl;

// 用了大量的 U.compareAndSwapXXXX 方法去修改 ConcurrentHashMap 的一些屬性。
private static final sun.misc.Unsafe U;

ConcurrentHashMap 的內部類


Node

Node 類實現了 Map.Entry 介面,主要存放 key-value 對,並且具有 next 域

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

另外可以看出很多屬性都是用 volatile 關鍵字修飾的,也是為了保證記憶體可見性。

TreeNode

樹節點,繼承於承載資料的 Node 類。紅黑樹的操作是針對 TreeBin 類的,從該類的註釋也可以看出,TreeBin 是對 TreeNode 的再一次封裝

static final class TreeNode<K,V> extends Node<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
		......
}

TreeBin

這個類並不負責使用者的 key、value 資訊,而是封裝了很多 TreeNode 節點。實際的 ConcurrentHashMap “陣列”中,存放的都是 TreeBin 物件,而不是 TreeNode 物件。

static final class TreeBin<K,V> extends Node<K,V> {
        TreeNode<K,V> root;
        volatile TreeNode<K,V> first;
        volatile Thread waiter;
        volatile int lockState;
        // values for lockState
        static final int WRITER = 1; // set while holding write lock
        static final int WAITER = 2; // set when waiting for write lock
        static final int READER = 4; // increment value for setting read lock
		......
}

ForwardingNode

在擴容時會出現的特殊節點,其 key、value、hash 全部為 null。並擁有 nextTable 引用的新 table 陣列。

static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) {
        super(MOVED, null, null, null);
        this.nextTable = tab;
    }
   .....
}

ConcurrentHashMap 的 CAS


ConcurrentHashMap 會大量使用 CAS 來修改它的屬性和進行一些操作。因此,在理解 ConcurrentHashMap 的方法前,我們需要了解幾個常用的利用 CAS 演算法來保障執行緒安全的操作。

// 獲取 table 陣列中索引為 i 的 Node 元素
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

// 利用 CAS 操作設定 table 陣列中索引為 i 的元素
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

// 設定 table 陣列中索引為 i 的元素
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

ConcurrentHashMap 的方法


構造方法

ConcurrentHashMap 一共提供了以下 5 個構造方法:

// 構造一個空的map,即table陣列還未初始化,初始化放在第一次插入資料時,預設大小為16
ConcurrentHashMap()
// 給定map的大小
ConcurrentHashMap(int initialCapacity)
// 給定一個map
ConcurrentHashMap(Map<? extends K, ? extends V> m)
// 給定map的大小以及載入因子
ConcurrentHashMap(int initialCapacity, float loadFactor)
// 給定map大小,載入因子以及併發度(預計同時運算元據的執行緒)
ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
public ConcurrentHashMap(int initialCapacity) {
	// 小於0直接拋異常
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
	// 判斷是否超過了允許的最大值,超過了話則取最大值,否則再對該值進一步處理
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
	// 賦值給sizeCtl
    this.sizeCtl = cap;
}

// 將構造方法指定的大小轉換成一個 2 的冪次方數
private static final int tableSizeFor(int c) {
    int n = c - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

當呼叫構造方法之後,sizeCtl 的大小就代表了 ConcurrentHashMap 的大小,即 table 陣列的長度。

呼叫構造方法時並初始化 table 陣列,而只算出了 table 陣列的長度,當第一次向 ConcurrentHashMap 插入資料時才會真正的完成初始化,並建立 table 陣列。

initTable 方法

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)
			// 1. 保證只有一個執行緒正在進行初始化操作
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
					// 2. 得出陣列的大小
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
					// 3. 這裡才真正的初始化陣列
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
					// 4. 計算陣列中可用的大小:實際大小n*0.75(載入因子)
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

可能存在這樣一種情況,多個執行緒同時進入到這個方法,為了保證能夠正確地初始化,第 1 步會先透過 if 進行判斷,如果當前已經有一個執行緒正在初始化,這時候其他執行緒會呼叫 Thread.yield() 讓出 CPU 時間片。

正在進行初始化的執行緒會呼叫 U.compareAndSwapInt 方法將 sizeCtl 改為 -1,即正在初始化的狀態。

另外還需要注意,在第四步中會進一步計算陣列中可用的大小,即陣列的實際大小 n 乘以載入因子 0.75,0.75 就是四分之三,這裡n - (n >>> 2)剛好是n-(1/4)n=(3/4)n,挺有意思的吧?

如果選擇是無參的構造方法,這裡在 new Node 陣列的時候會使用預設大小DEFAULT_CAPACITY(16),然後乘以載入因子 0.75,結果為 12,也就是說陣列當前的可用大小為 12。

put 方法

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
	//1. 計算key的hash值
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
		//2. 如果當前table還沒有初始化先呼叫initTable方法將tab進行初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
		//3. tab中索引為i的位置的元素為null,則直接使用CAS將值插入即可
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
		//4. 當前正在擴容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
					//5. 當前為連結串列,在連結串列中插入新的鍵值對
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
					// 6.當前為紅黑樹,將新的鍵值對插入到紅黑樹中
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
			// 7.插入完鍵值對後再根據實際大小看是否需要轉換成紅黑樹
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
	//8.對當前容量大小進行檢查,如果超過了臨界值(實際大小*載入因子)就需要擴容
    addCount(1L, binCount);
    return null;
}

ConcurrentHashMap 是一個雜湊桶陣列,如果不出現雜湊衝突的時候,每個元素均勻的分佈在雜湊桶陣列中。當出現雜湊衝突的時候,採用拉鍊法的解決方案,將 hash 值相同的節點轉換成連結串列的形式,另外,在 JDK 1.8 版本中,為了防止拉鍊過長,當連結串列的長度大於 8 的時候會將連結串列轉換成紅黑樹。

確定好陣列的索引 i 後,可以呼叫 tabAt() 方法獲取該位置上的元素,如果當前 Node 為 null 的話,可以直接用 casTabAt 方法將新值插入。

如果當前節點不為 null,且該節點為特殊節點(forwardingNode),就說明當前 concurrentHashMap 正在進行擴容操作。怎麼確定當前這個 Node 是特殊節點呢?

透過判斷該節點的 hash 值是不是等於 -1(MOVED):

static final int MOVED     = -1; // hash for forwarding nodes

table[i] 不為 null 並且不是 forwardingNode 時,以及當前 Node 的 hash 值大於0(fh >= 0)時,說明當前節點為連結串列的頭節點,那麼向 ConcurrentHashMap 插入新值就是向這個連結串列插入新值。透過 synchronized (f) 的方式進行加鎖以實現執行緒安全。

往連結串列中插入節點的部分程式碼如下:

if (fh >= 0) {
    binCount = 1;
    for (Node<K,V> e = f;; ++binCount) {
        K ek;
		// 找到hash值相同的key,覆蓋舊值即可
        if (e.hash == hash &&
            ((ek = e.key) == key ||
             (ek != null && key.equals(ek)))) {
            oldVal = e.val;
            if (!onlyIfAbsent)
                e.val = value;
            break;
        }
        Node<K,V> pred = e;
        if ((e = e.next) == null) {
			//如果到連結串列末尾仍未找到,則直接將新值插入到連結串列末尾即可
            pred.next = new Node<K,V>(hash, key,
                                      value, null);
            break;
        }
    }
}

這部分程式碼很好理解,就兩種情況:

  1. 如果在連結串列中找到了與待插入的 key 相同的節點,就直接覆蓋;
  2. 如果找到連結串列的末尾都還沒找到的話,直接將待插入的鍵值對追加到連結串列的末尾。

當連結串列長度超過 8(預設值)時,連結串列就轉換為紅黑樹,利用紅黑樹快速增刪改查的特點可以提高 ConcurrentHashMap 的效能:

if (f instanceof TreeBin) {
    Node<K,V> p;
    binCount = 2;
    if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                   value)) != null) {
        oldVal = p.val;
        if (!onlyIfAbsent)
            p.val = value;
    }
}

這段程式碼很簡單,呼叫 putTreeVal 方法向紅黑樹插入新節點,同樣的邏輯,如果在紅黑樹中存在 Key 相同(hash 值相等並且 equals 方法判斷為 true)的節點,就覆蓋舊值,否則向紅黑樹追加新節點

當完成資料新節點插入後,會進一步對當前連結串列大小進行調整:

if (binCount != 0) {
    if (binCount >= TREEIFY_THRESHOLD)
        treeifyBin(tab, i);
    if (oldVal != null)
        return oldVal;
    break;
}

至此,put 方法就分析完了,我們來做個總結:

  1. 對每一個放入的值,先用 spread 方法對 key 的 hashcode 進行 hash 計算,由此來確定這個值在 table 中的位置;
  2. 如果當前 table 陣列還未初始化,進行初始化操作;
  3. 如果這個位置是 null,那麼使用 CAS 操作直接放入;
  4. 如果這個位置存在節點,說明發生了 hash 碰撞,先判斷這個節點的型別,如果該節點 ==MOVED 的話,說明正在進行擴容;
  5. 如果是連結串列節點(fh>0),先獲取頭節點,再依次向後遍歷確定這個新加入節點的位置。如果遇到 key 相同的節點,直接覆蓋。否則在連結串列尾插入;
  6. 如果這個節點的型別是 TreeBin,直接呼叫紅黑樹的插入方法插入新的節點;
  7. 插入完節點之後再次檢查連結串列的長度,如果長度大於 8,就把這個連結串列轉換成紅黑樹;
  8. 對當前容量大小進行檢查,如果超過了臨界值(實際大小*載入因子)就需要擴容。

get 方法

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
	// 1. 重hash
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 2. table[i]桶節點的key與查詢的key相同,則直接返回
		if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
		// 3. 當前節點hash小於0說明為樹節點,在紅黑樹中查詢即可
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
		//4. 從連結串列中查詢,查詢到則返回該節點的value,否則就返回null即可
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}
  • 雜湊: 對傳入的鍵的雜湊值進行雜湊,這有助於減少雜湊衝突的可能性。使用 spread 方法可以保證不同的鍵更均勻地分佈在桶陣列中。
  • 直接查詢: 查詢的第一步是檢查鍵的雜湊值是否位於表的正確位置。如果在該桶的第一個元素中找到了鍵,則直接返回該元素的值。這裡使用了 == 運算子和 equals 方法來比較鍵,這有助於處理可能的 null 值和確保正確的相等性比較。
  • 紅黑樹查詢: 如果第一個節點的雜湊值小於 0,那麼這個桶的資料結構是紅黑樹(Java 8 引入了樹化結構來改進連結串列在雜湊衝突時的效能)。在這種情況下,使用 find 方法在紅黑樹中查詢鍵。
  • 連結串列查詢: 如果前兩個條件都不滿足,那麼程式碼將遍歷該桶中的連結串列。如果在連結串列中找到了具有相同雜湊值和鍵的元素,則返回其值。如果遍歷完整個連結串列都未找到,則返回 null。

transfer 方法

當 ConcurrentHashMap 容量不足的時候,需要對 table 進行擴容。這個方法的基本思想跟 HashMap 很像,但由於支援併發擴容,所以要複雜一些。transfer 方法原始碼如下:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
	//1. 新建Node陣列,容量為之前的兩倍
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;
    }
    int nextn = nextTab.length;
	//2. 新建forwardingNode引用,在之後會用到
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false; // to ensure sweep before committing nextTab
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        //3. 確定遍歷中的索引i
		while (advance) {
            int nextIndex, nextBound;
            if (--i >= bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
		//4.將原陣列中的元素複製到新陣列中去
		//4.5 for迴圈退出,擴容結束脩改sizeCtl屬性
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
		//4.1 當前陣列中第i個元素為null,用CAS設定成特殊節點forwardingNode(可以理解成佔位符)
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
		//4.2 如果遍歷到ForwardingNode節點  說明這個點已經被處理過了 直接跳過  這裡是控制併發擴容的核心
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
						//4.3 處理當前節點為連結串列的頭結點的情況,根據最高位為1還是為0(最高位指陣列長度位),將原連結串列拆分為兩個連結串列,分別放到新陣列的i位置和i+n位置。這裡還透過巧妙的處理措施,使得原連結串列中的一部分能直接平移到新連結串列(即lastRun及其後面跟著的一串節點),剩下部分才需要透過new方式克隆移動到新連結串列中(採用頭插法)。
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln); //可以看到是逆序插入新節點的(頭插)
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                       //在nextTable的i位置上插入一個連結串列
                         setTabAt(nextTab, i, ln);
                         //在nextTable的i+n的位置上插入另一個連結串列
                         setTabAt(nextTab, i + n, hn);
                         //在table的i位置上插入forwardNode節點  表示已經處理過該節點
                         setTabAt(tab, i, fwd);
                         //設定advance為true 返回到上面的while迴圈中 就可以執行i--操作
                         advance = true;
                    }
					//4.4 處理當前節點是TreeBin時的情況,操作和上面的類似
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

程式碼邏輯請看註釋,整個擴容操作分為兩個部分

第一部分是構建一個 nextTable,它的容量是原來的兩倍,這個操作是單執行緒完成的。

第二個部分是將原來 table 中的元素複製到 nextTable 中,主要是遍歷複製的過程。 得到當前遍歷的陣列位置 i,然後利用 tabAt 方法獲得 i 位置的元素:

  1. 如果這個位置為空,就在原 table 中的 i 位置放入 forwardNode 節點,這個也是觸發併發擴容的關鍵;
  2. 如果這個位置是 Node 節點(fh>=0),並且是連結串列的頭節點,就把這個連結串列分裂成兩個連結串列,把它們分別放在 nextTable 的 i 和 i+n 的位置上;
  3. 如果這個位置是 TreeBin 節點(fh<0),也做一個反序處理,並且判斷是否需要 untreefi,把處理的結果分別放在 nextTable 的 i 和 i+n 的位置上;
  4. 遍歷所有的節點,就完成複製工作,這時讓 nextTable 作為新的 table,並且更新 sizeCtl 為新容量的 0.75 倍 ,完成擴容。

size 相關方法

對於 ConcurrentHashMap 來說,這個 table 裡到底裝了多少東西是不確定的,因為不可能在呼叫 size() 方法的時候“stop the world”讓其他執行緒都停下來去統計,對於這個不確定的 size,ConcurrentHashMap 仍然花費了大量的力氣。

為了統計元素的個數,ConcurrentHashMap 定義了一些變數和一個內部類。

/**
 * A padded cell for distributing counts.  Adapted from LongAdder
 * and Striped64.  See their internal docs for explanation.
 */
@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

/******************************************/

/**
 * 實際上儲存的是HashMap中的元素個數  利用CAS鎖進行更新
 但它並不用返回當前HashMap的元素個數
 */
private transient volatile long baseCount;
/**
 * Spinlock (locked via CAS) used when resizing and/or creating CounterCells.
 */
private transient volatile int cellsBusy;

/**
 * Table of counter cells. When non-null, size is a power of 2.
 */
private transient volatile CounterCell[] counterCells;

再來看如何統計的原始碼:

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}
 /**
 * Returns the number of mappings. This method should be used
 * instead of {@link #size} because a ConcurrentHashMap may
 * contain more mappings than can be represented as an int. The
 * value returned is an estimate; the actual count may differ if
 * there are concurrent insertions or removals.
 *
 * @return the number of mappings
 * @since 1.8
 */
public long mappingCount() {
    long n = sumCount();
    return (n < 0L) ? 0L : n; // ignore transient negative values
}

 final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;//所有counter的值求和
        }
    }
    return sum;
}

size 方法返回 Map 中的元素數量,但結果被限制在 Integer.MAX_VALUE 內。如果計算的大小超過這個值,則返回 Integer.MAX_VALUE。如果計算的大小小於 0,則返回 0。

mappingCount 方法也返回 Map 中的元素數量,但允許返回一個 long 值,因此可以表示大於 Integer.MAX_VALUE 的數量。與 size() 方法類似,該方法也會忽略負值,返回 0。

sumCount 方法計算 Map 的實際大小。ConcurrentHashMap 使用一個基礎計數 baseCount 和一個 CounterCell 陣列 counterCells 來跟蹤大小。這種結構有助於減少多執行緒環境中的爭用,因為不同的執行緒可能會更新不同的 CounterCell。

在計算總和時,sumCount() 方法將 baseCount 與 counterCells 陣列中的所有非空單元的值相加。

在 put 方法結尾處呼叫了 addCount 方法,把當前 ConcurrentHashMap 的元素個數 +1,這個方法一共做了兩件事,更新 baseCount 的值,檢測是否進行擴容。

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 利用CAS方法更新baseCount的值
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    // 如果check值大於等於0 則需要檢驗是否需要進行擴容操作
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                 // 如果已經有其他執行緒在執行擴容操作
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            // 當前執行緒是唯一的或是第一個發起擴容的執行緒  此時nextTable=null
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

ConcurrentHashMap 示例


假設我們想要構建一個執行緒安全的高併發統計使用者訪問次數的功能。在這裡,ConcurrentHashMap 是一個很好的選擇,因為它提供了高併發效能。

import java.util.concurrent.ConcurrentHashMap;

public class UserVisitCounter {

    private final ConcurrentHashMap<String, Integer> visitCountMap;

    public UserVisitCounter() {
        this.visitCountMap = new ConcurrentHashMap<>();
    }

    // 使用者訪問時呼叫的方法
    public void userVisited(String userId) {
        visitCountMap.compute(userId, (key, value) -> value == null ? 1 : value + 1);
    }

    // 獲取使用者的訪問次數
    public int getVisitCount(String userId) {
        return visitCountMap.getOrDefault(userId, 0);
    }

    public static void main(String[] args) {
        UserVisitCounter counter = new UserVisitCounter();

        // 模擬使用者訪問
        counter.userVisited("user1");
        counter.userVisited("user1");
        counter.userVisited("user2");

        System.out.println("User1 visit count: " + counter.getVisitCount("user1")); 		// 輸出: User1 visit count: 2
        System.out.println("User2 visit count: " + counter.getVisitCount("user2")); 		// 輸出: User2 visit count: 1
    }
}

在上述示例中:

  • 我們使用了 ConcurrentHashMap 來儲存使用者的訪問次數。
  • 當使用者訪問時,我們透過 userVisited 方法更新訪問次數。
  • 使用 ConcurrentHashMap 的 compute 方法可以確保原子地更新使用者的訪問次數。
  • 可以透過 getVisitCount 方法檢索任何使用者的訪問次數。

ConcurrentHashMap 使我們能夠無需擔心併發問題就能構建這樣一個高效的統計系統。

相關文章