圖解ConcurrentHashMap

HuYounger發表於2019-03-04

概述

上篇文章介紹了 HashMap 在多執行緒併發情況下是不安全的,多執行緒併發推薦使用 ConcurrentHashMap ,那麼 ConcurrentHashMap 是什麼?它的設計思想是什麼,原始碼是怎麼實現的?

ConcurrentHashMap是什麼

Concurrent翻譯過來是併發的意思,字面理解它的作用是處理併發情況的 HashMap,在介紹它之前先回顧下之前的知識。通過前面兩篇學習,我們知道多執行緒併發下 HashMap 是不安全的(如死迴圈),更普遍的是多執行緒併發下,由於堆記憶體對於各個執行緒是共享的,而 HashMap 的 put 方法不是原子操作,假設Thread1先 put 值,然後 sleep 2秒(也可以是系統時間片切換失去執行權),在這2秒內值被Thread2改了,Thread1“醒來”再 get 的時候發現已經不是原來的值了,這就容易出問題。

圖解ConcurrentHashMap

那麼如何避免這種多執行緒“奧迪變奧拓”的情況呢?常規思路就是給 HashMap 的 put 方法加鎖(synchronized),保證同一個時刻只允許一個執行緒擁有對 hashmap 有寫的操作許可權即可。然而假如執行緒1中操作耗時,佔著茅坑半天不出來,其他需要操作該 hashmap 的執行緒就需要在門口排隊半天,嚴重影響使用者體驗(HashTable 就是這麼幹的)。舉個生活中的例子,很多銀行除了存取錢,還支援存取貴重物品,貴重物品都放在保險箱裡,把 HashMap 和 HashTable 比作銀行,結構:

圖解ConcurrentHashMap

把執行緒比作人,對應的情況如下:

  • HashMap牌銀行:我們的服務宗旨是不用排隊,同一時間多人都有機會修改保險櫃裡的東西,你以為你存的是美元?取出來的其實是日元,破產就在一瞬間,刺不刺激。
  • HashTable牌銀行:我們的服務宗旨是要排隊,同一時間只有一個人有機會修改保險櫃裡的東西,其餘的人只能看不能動手改,保你存的是美元取得還是美元。什麼?你說如果那人在裡面睡著了不出來怎麼辦?不要著急,來,坐下來打會麻將等他出來。
圖解ConcurrentHashMap

多執行緒下用 HashMap 不確定性太高,有破產的風險,不能選;用 HashTable 不會破產,但是使用者體驗不太好,那麼怎樣才能做到多人存取既不影響他人存值,又不用排隊呢?有人提議搞個「銀行者聯盟」,多開幾個像HashTable 這種「帶鎖」的銀行就好了,有多少人辦理業務,就開多少個銀行,一對一服務,這個區都是大老闆,開銀行的成本都是小錢,於是「銀行者聯盟」成立了。

接下來的情況是這樣的:比如蓋倫和亞索一起去銀行存他們的大寶劍,這個「銀行者聯盟」一頓操作,然後對蓋倫說,1號銀行現在沒人,你可以去那存,不用排隊,然後蓋倫就去1號銀行存他的大寶劍,1號銀行把蓋倫接進門,馬上拉閘,一頓操作,然後把蓋倫的大寶劍放在第x行第x個保險箱,等蓋倫辦妥離開後,再開閘;同樣「銀行者聯盟」對亞索說,2號銀行現在沒人,你可以去那存,不用排隊,然後亞索去2號銀行存他的大寶劍,2號銀行把亞索接進門,馬上拉閘,一頓操作把亞索的大寶劍放在第x行第x號保險箱,等亞索離開後再開閘,此時不管蓋倫和亞索在各自銀行裡面待多久都不會影響到彼此,不用擔心自己的大寶劍被人偷換了。這就是ConcurrentHashMap的設計思路,用一個圖來理解

圖解ConcurrentHashMap

從上圖可以看出,此時鎖的是對應的單個銀行,而不是整個「銀行者聯盟」。分析下這種設計的特點:

  • 多個銀行組成的「銀行者聯盟」
  • 當有人來辦理業務時,「銀行者聯盟」需要確定這個人去哪個銀行
  • 當此人去到指定銀行辦理業務後,該銀行上鎖,其他人不能同時執行修改操作,直到此人離開後解鎖

由這幾點基本思想可以引發一些思考,比如:

1.成立「銀行者聯盟」時初識銀行數是多少?怎麼設計合理?

上面這張圖沒有給出是否需要排隊的結論,這是因為需要結合實際情況分析,比如初識化有16個銀行,只有兩個人來辦理業務,那自然不需要排隊;如果現在16個銀行都有人在辦理業務,這時候來了第17個人,那麼他還是需要排隊的。由於「銀行者聯盟」事先無法得知會有多少人來辦理業務,所以在它創立的時候需要制定一個「標準」,即初始銀行數量,人多的情況「銀行者聯盟」應該多開幾家銀行,避免別人排隊;人少的情況應該少開,避免浪費錢(什麼,你說不差錢?那也不行)

2.當有人來辦理業務的時候,「銀行者聯盟」怎麼確定此人去哪個銀行?

正常情況下,如果所有銀行都是未上鎖狀態,那麼有人來辦理業務去哪都不用排隊,當其中有些銀行已經上鎖,那麼後續「銀行者聯盟」給人推薦的時候就不能把客戶往上鎖的銀行引了,否則分分鐘給人錘成麻瓜。因此「銀行者聯盟」需要時刻保持清醒的頭腦,對自己的銀行空閒情況瞭如指掌,每次給使用者推薦都應該是最好的選擇。

3.「銀行者聯盟」怎麼保證同一時間不會有兩個人在同一個銀行擁有存許可權?

通過對指定銀行加鎖/解鎖的方式實現。

原始碼分析

Java7 原始碼分析

通過 Java7 的原始碼分析下程式碼實現,先看下一些重要的成員

//預設的陣列大小16(HashMap裡的那個陣列)
static final int DEFAULT_INITIAL_CAPACITY = 16;

//擴容因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
 
//ConcurrentHashMap中的陣列
final Segment<K,V>[] segments

//預設併發標準16
static final int DEFAULT_CONCURRENCY_LEVEL = 16;

//Segment是ReentrantLock子類,因此擁有鎖的操作
 static final class Segment<K,V> extends ReentrantLock implements Serializable {
  //HashMap的那一套,分別是陣列、鍵值對數量、閾值、負載因子
  transient volatile HashEntry<K,V>[] table;
  transient int count;
  transient int threshold;
  final float loadFactor;

  Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
            this.loadFactor = lf;
            this.threshold = threshold;
            this.table = tab;
        }
 }
 
 //換了馬甲還是認識你!!!HashEntry物件,存key、value、hash值以及下一個節點
 static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;
 }
//segment中HashEntry[]陣列最小長度
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;

//用於定位在segments陣列中的位置,下面介紹
final int segmentMask;
final int segmentShift;
複製程式碼

上面這些一下出來有點接受不了沒關係,下面都會介紹到。

接下來從最簡單的初識化開始分析

ConcurrentHashMap concurrentHashMap = new ConcurrentHashMap();
複製程式碼

預設建構函式會呼叫帶三個引數的建構函式

    public ConcurrentHashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    }

    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;
        // Find power-of-two sizes best matching arguments
        //步驟① start
        int sshift = 0;
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;
        //步驟① end
        //步驟② start
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        //步驟② end
        // create segments and segments[0]
        //步驟③ start
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]);
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
        //步驟③ end
    }

複製程式碼

上面定義了許多臨時變數,註釋寫的又少,第一次看名字根本不知道這鬼東西代表什麼意思,不過我們可以把已知的資料代進去,算出這些變數的值,再分析能不能找出一些貓膩。假設這是第一次預設建立:

  • 步驟① concurrencyLevel = 16 ,可以計算出 sshift = 4,ssize = 16,segmentShift = 28,segmentMask = 15;
  • 步驟② c = 16/16 = 1,cap = 2;
  • 步驟③有句註釋,建立 Segment 陣列 segments 並初始化 segments [0] ,所以 s0 初始化後陣列長度為2,負載因子0.75,閾值為1;再看這裡的ss的初始化(重點,圈起來要考!!!), ssize 此時為16,所以預設陣列長度16,給人一種感覺正好和我們傳的 concurrencyLevel 一樣?看下下面的例子
例子1 例子2
ssize = 1,concurrencyLevel = 10 ssize = 1,concurrencyLevel = 8
ssize <<= 1 —> 2<10 滿足 ssize <<= 1 —> 2<10 滿足
ssize <<= 1 —> 4<10 滿足 ssize <<= 1 —> 4<10 滿足
ssize <<= 1 —> 8<10 滿足 ssize <<= 1 —> 8<10 不滿足 ssize = 8
ssize <<= 1 —> 16<10 不滿足 ssize = 16

所以我們傳 concurrencyLevel 不一定就是最後陣列的長度,長度的計算公式:

長度 = 2的n次方(2的n次方 >= concurrencyLevel)

到這裡只是建立了一個長度為16的Segment 陣列,並初始化陣列0號位置,segmentShift和segmentMask還沒派上用場,畫圖存檔:

圖解ConcurrentHashMap

接著看 put 方法

    public V put(K key, V value) {
        Segment<K,V> s;
        //步驟①注意valus不能為空!!!
        if (value == null)
            throw new NullPointerException();
        //根據key計算hash值,key也不能為null,否則hash(key)報空指標
        int hash = hash(key);
        //步驟②派上用場了,根據hash值計算在segments陣列中的位置
        int j = (hash >>> segmentShift) & segmentMask;
        //步驟③檢視當前陣列中指定位置Segment是否為空
        //若為空,先建立初始化Segment再put值,不為空,直接put值。
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }
複製程式碼

步驟①可以看到和 HashMap 的區別,這裡的 key/value 為空會報空指標異常;步驟②先根據 key 值計算 hash 值,再和前面算出來的兩個變數計算出這個 key 應該放在哪個Segment中(具體怎麼計算的有興趣可以去研究下,先高位運算再取與),假設我們算出來該鍵值對應該放在5號,步驟③判斷5號為空,看下 ensureSegment() 方法

private Segment<K,V> ensureSegment(int k) {
        //獲取segments
        final Segment<K,V>[] ss = this.segments;
        long u = (k << SSHIFT) + SBASE; // raw offset
        Segment<K,V> seg;
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
            //拷貝一份和segment 0一樣的segment
            Segment<K,V> proto = ss[0]; // use segment 0 as prototype
            //大小和segment 0一致,為2
            int cap = proto.table.length;
            //負載因子和segment 0一致,為0.75
            float lf = proto.loadFactor;
            //閾值和segment 0一致,為1
            int threshold = (int)(cap * lf);
            //根據大小建立HashEntry陣列tab
            HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
            //再次檢查
            if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                == null) { // recheck
                根據已有屬性建立指定位置的Segment
                Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
                while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                       == null) {
                    if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                        break;
                }
            }
        }
        return seg;
    }
複製程式碼

該方法重點在於拷貝了segments[0],因此新建立的Segment與segment[0]的配置相同,由於多個執行緒都會有可能執行該方法,因此這裡通過UNSAFE的一些原子性操作的方法做了多次的檢查,到目前為止畫圖存檔:

圖解ConcurrentHashMap

現在“舞臺”也有了,請開始你的表演,看下 Segment 的put方法

        final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            //步驟① start
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            //步驟① end
            V oldValue;
            try {
                //步驟② start
                //獲取Segment中的HashEntry[]
                HashEntry<K,V>[] tab = table;
                //算出在HashEntry[]中的位置
                int index = (tab.length - 1) & hash;
                //找到HashEntry[]中的指定位置的第一個節點
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    //如果不為空,遍歷這條鏈
                    if (e != null) {
                        K k;
                        //情況① 之前已存過,則替換原值
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                        //情況② 另一個執行緒的準備工作
                        if (node != null)
                            //連結串列頭插入方式
                            node.setNext(first);
                        else //情況③ 該位置為空,則新建一個節點(注意這裡採用連結串列頭插入方式)
                            node = new HashEntry<K,V>(hash, key, value, first);
                        //鍵值對數量+1
                        int c = count + 1;
                        //如果鍵值對數量超過閾值
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            //擴容
                            rehash(node);
                        else //未超過閾值,直接放在指定位置
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        //插入成功返回null
                        oldValue = null;
                        break;
                    }
                }
            //步驟② end
            } finally {
                //步驟③
                //解鎖
                unlock();
            }
            //修改成功,返回原值
            return oldValue;
        }
複製程式碼

上面的 put 方法其實和 Java7 HashMap裡大致是一樣的,只是多了加鎖/解鎖兩步,也正因為這樣才保證了同一時刻只有一個執行緒擁有修改的許可權。按步驟分析下上面的流程:

  • 步驟① 執行 tryLock 方法獲取鎖,拿到鎖返回null,沒拿到鎖執行 scanAndLockForPut 方法;
  • 步驟② 和 HashMap 裡的那一套思路是一樣的,不理解可以看下之前的文章介紹(情況②下面介紹);
  • 步驟③ 執行 unLock 方法解鎖

假設現在Thread1進來存值,前面沒人來過,它可以成功拿到鎖,根據計算,得出它要存的鍵值對應該放在HashEntry[] 的0號位置,0號位置為空,於是新建一個 HashEntry,並通過 setEntryAt() 方法,放在0號位置,然而還沒等 Thread1 釋放鎖,系統的時間片切到了 Thread2 ,先畫圖存檔

圖解ConcurrentHashMap

Thread2 也來存值,通過前面的計算,恰好 Thread2 也被定位到 segments[5],接下來 Thread2 嘗試獲取鎖,沒有成功(Thread1 還未釋放),執行 scanAndLockForPut() 方法:

        private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
            //通過Segment和hash值尋找匹配的HashEntry
            HashEntry<K,V> first = entryForHash(this, hash);
            HashEntry<K,V> e = first;
            HashEntry<K,V> node = null;
            //重試次數
            int retries = -1; // negative while locating node
            //迴圈嘗試獲取鎖
            while (!tryLock()) {
                HashEntry<K,V> f; // to recheck first below
                //步驟①
                if (retries < 0) {
                    //情況① 沒找到,之前表中不存在
                    if (e == null) {
                        if (node == null) // speculatively create node
                            //新建 HashEntry 備用,retries改成0
                            node = new HashEntry<K,V>(hash, key, value, null);
                        retries = 0;
                    }
                    //情況② 找到,剛好第一個節點就是,retries改成0
                    else if (key.equals(e.key))
                        retries = 0;
                    //情況③ 第一個節點不是,移到下一個,retries還是-1,繼續找
                    else
                        e = e.next;
                }
                //步驟②
                //嘗試了MAX_SCAN_RETRIES次還沒拿到鎖,簡直B了dog!
                else if (++retries > MAX_SCAN_RETRIES) {
                    //泉水掛機
                    lock();
                    break;
                }
                //步驟③
                //在MAX_SCAN_RETRIES次過程中,key對應的entry發生了變化,則從頭開始
                else if ((retries & 1) == 0 &&
                         (f = entryForHash(this, hash)) != first) {
                    e = first = f; // re-traverse if entry changed
                    retries = -1;
                }
            }
            return node;
        }
複製程式碼

通過上面的註釋分析可以看出,Thread2 雖然此刻沒有許可權修改,但是它也沒閒著,利用等鎖的這個時間,把自己要放的鍵值對在陣列中哪個位置計算出來了,這樣當 Thread2 一拿到鎖就可以立馬定位到具體位置操作,節省時間。上面的步驟③稍微解釋下,比如 Thread2 通過查詢得知自己要修改的值在0號位置,但在 Thread1 裡面又把該值改到了1號位置,如果它還去0號操作那肯定出問題了,所以需要重新確定。

假設 Thread2 put 值為("亞索",“98”),對應1號位置,那麼在 scanAndLockForPut 方法中對應情況①,畫圖存檔:

圖解ConcurrentHashMap

再回到 Segment put 方法中的情況②,當 Thread1 釋放鎖後,Thread2 持有鎖,並準備把亞索放在1號位置,然而此時 Segment[5] 裡的鍵值對數量2 > 閾值1,所以呼叫 rehash() 方法擴容,

        private void rehash(HashEntry<K,V> node) {
            /*
             * Reclassify nodes in each list to new table.  Because we
             * are using power-of-two expansion, the elements from
             * each bin must either stay at same index, or move with a
             * power of two offset. We eliminate unnecessary node
             * creation by catching cases where old nodes can be
             * reused because their next fields won't change.
             * Statistically, at the default threshold, only about
             * one-sixth of them need cloning when a table
             * doubles. The nodes they replace will be garbage
             * collectable as soon as they are no longer referenced by
             * any reader thread that may be in the midst of
             * concurrently traversing table. Entry accesses use plain
             * array indexing because they are followed by volatile
             * table write.
             */
            //舊陣列引用
            HashEntry<K,V>[] oldTable = table;
            //舊陣列長度
            int oldCapacity = oldTable.length;
            //新陣列長度為舊陣列的2倍
            int newCapacity = oldCapacity << 1;
            //修改新的閾值
            threshold = (int)(newCapacity * loadFactor);
            //建立新表
            HashEntry<K,V>[] newTable =
                (HashEntry<K,V>[]) new HashEntry[newCapacity];
            int sizeMask = newCapacity - 1;
            //遍歷舊錶
            for (int i = 0; i < oldCapacity ; i++) {
                HashEntry<K,V> e = oldTable[i];
                if (e != null) {
                    HashEntry<K,V> next = e.next;
                    //確定在新表中的位置
                    int idx = e.hash & sizeMask;
                    //情況① 連結串列只有一個節點,指定轉移到新表指定位置
                    if (next == null)   //  Single node on list
                        newTable[idx] = e;
                    else { // Reuse consecutive sequence at same slot
                        HashEntry<K,V> lastRun = e;
                        int lastIdx = idx;
                        for (HashEntry<K,V> last = next;
                             last != null;
                             last = last.next) {
                            //情況② 擴容前後位置發生改變
                            int k = last.hash & sizeMask;
                            if (k != lastIdx) {
                                lastIdx = k;
                                lastRun = last;
                            }
                        }
                        //將改變的鍵值對放到新表的對應位置
                        newTable[lastIdx] = lastRun;
                        // Clone remaining nodes
                        //情況③ 把連結串列中剩下的節點拷到新表中
                        for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                            V v = p.value;
                            int h = p.hash;
                            int k = h & sizeMask;
                            HashEntry<K,V> n = newTable[k];
                            newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                        }
                    }
                }
            }
            //新增新的節點(連結串列頭插入方式)
            int nodeIndex = node.hash & sizeMask; // add the new node
            node.setNext(newTable[nodeIndex]);
            newTable[nodeIndex] = node;
            table = newTable;
        }
複製程式碼

同樣是擴容轉移,這裡的程式碼比 HashMap 中的 transfer 多了一些操作,在上上篇學習 HashMap 擴容可知,擴容後鍵值對的新位置要麼和原位置一樣,要麼等於原位置+舊陣列的長度,所以畫個圖來理解下上面程式碼這麼寫的原因:

圖解ConcurrentHashMap

前提:當前 HashEntry[] 長度為8,閾值為 8*0.75 = 6,所以 put 第7個鍵值對需要擴容 ,蓋倫和亞索擴容前後位置不變,妖姬和卡特擴容後位置需要加上原陣列長度,所以執行上面程式碼流程:

圖解ConcurrentHashMap 圖解ConcurrentHashMap 圖解ConcurrentHashMap 圖解ConcurrentHashMap 圖解ConcurrentHashMap 圖解ConcurrentHashMap

上面的程式碼先找出擴容前後需要轉移的節點,先執行轉移,然後再把該條鏈上剩下的節點轉移,之所以這麼寫是起到複用的效果,註釋中也說了,在使用預設閾值的情況下,只有大約 1/6 的節點需要被 clone 。注意到目前為止,可以看到無論是擴容轉移還是新增節點,Java7都是採用的頭插入方式,流程圖如下:

圖解ConcurrentHashMap

相比之下,get 方法沒有加鎖/解鎖的操作,程式碼比較簡單就不分析了。

稍微說下Java8

Java8 對比Java7有很大的不同,比如取消了Segments陣列,允許併發擴容。

先看下ConcurrentHashMap的初始化

public ConcurrentHashMap() {
}
複製程式碼

和Java7不一樣,這裡是個空方法,那麼它具體的初始化操作呢?直接看下 put 方法

public V put(K key, V value) {
    return putVal(key, value, false);
}

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // key/value不能為空!!!
    if (key == null || value == null) throw new NullPointerException();
    //計算hash值
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        //註釋① 表為null則初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        //CAS方法判斷指定位置是否為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
        }
        //當前節點正在擴容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        //指定位置不為空
        else {
            V oldVal = null;
            //註釋② 加鎖
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    //節點是連結串列的情況
                    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;
                            }
                        }
                    }
                    //節點是紅黑樹的情況
                    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;
                        }
                    }
                    else if (f instanceof ReservationNode)
                        throw new IllegalStateException("Recursive update");
                }
            }
            if (binCount != 0) {
                //連結串列中節點個數超過8轉成紅黑樹
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    //註釋③ 新增節點
    addCount(1L, binCount);
    return null;
}
複製程式碼

程式碼有點長,第一次看很有可能引起身體不適,主要是因為引入了紅黑樹的判斷和操作,以及執行緒安全的操作。同樣key/value 為空會報空指標異常,這也是和 HashMap 一個明顯的區別。

註釋①

呼叫 initTable 初始化陣列

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // sizeCtl小於0,當前執行緒讓出執行權
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        //CAS 操作將 sizeCtl 值改為-1
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    //預設建立大小為16的陣列
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                //初始化完再改回來
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}
複製程式碼

put方法並沒有加鎖,那麼它是如何保證建立新表的時候併發安全呢?答案就是這裡的 sizeCtl ,sizeCtl 預設值為0,當一個執行緒初始化陣列時,會將 sizeCtl 改成 -1,由於被 volatile 修飾,對於其他執行緒來說這個變化是可見的,上面程式碼看到後續執行緒判斷 sizeCtl 小於0 就會讓出執行權。

註釋②

Java8 摒棄了Segment,而是對陣列中單個位置加鎖。當指定位置節點不為 null 時,情況與 Java8 HashMap 操作類似,新節點的新增還是尾部插入方式。

註釋③

不管是連結串列的還是紅黑樹,確定之後總的節點數會加1,可能會引起擴容,Java8 ConcunrrentHashMap 支援併發擴容,之前擴容總是由一個執行緒將舊陣列中的鍵值對轉移到新的陣列中,支援併發的話,轉移所需要的時間就可以縮短了,當然相應的併發處理控制邏輯也就更復雜了,擴容轉移通過 transfer 方法完成,Java8中該方法很長,感興趣的可以看下原始碼。。。

用一個圖來表示 Java8 ConcurrentHashMap的樣子

圖解ConcurrentHashMap

總結

通過分析原始碼對比了 HashMap 與 ConcurrentHashMap的差別,以及Java7和Java8上 ConcurrentHashMap 設計的不同,當然還有很多坑沒有填,比如其中呼叫了很多UNSAFE的CAS方法,可以減少效能上的消耗,平時很少用,瞭解的比較少;以及紅黑樹的具體原理和實現,後續慢慢填。。。

相關文章