面試連環炮:HashMap線程不安全 怎麼解決?你來說說ConcurrentHashMap怎麼就安全(萬字長文)

面試官:HashMap線程不安全 怎麼解決?你來說說ConcurrentHashMap (JDK7)

從構造方法探尋ConcurrentHashMap的數據結構

我們先大概看一張圖 讓大家先有一個認識 在去通過構造方法深入的分析

在這裏插入圖片描述

這裏 我們要去瞭解到 ConcurrentHashMap 由一個個的Segment組成 而一個個的Segment由一個個HashEntry組成 每個HashEntry裏存放了Key-Value鍵值對 我們先大概清楚這個結構 然後再去進入源碼分析究竟是怎麼回事

這個是 ConcurrentHashMap的構造方法 傳入了3個參數

 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
        int sshift = 0;
        int ssize = 1;
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;
        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;
        // create segments and segments[0]
        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;
    }

這個是ConcurrentHashMap的1.7下的構造方法 我們先了解默認情況下構造方法的三個參數的默認賦值是怎樣的

    static final int DEFAULT_INITIAL_CAPACITY = 16;

    static final float DEFAULT_LOAD_FACTOR = 0.75f;
   
    static final int DEFAULT_CONCURRENCY_LEVEL = 16;

我們選取代碼塊一步步的分析

  	int sshift = 0;
    int ssize = 1;
    while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }

初始情況下 ssize=1 concurrencyLevel=16

第一次循環 ssize=1<16 進入循環 ssize左移兩位變成2;

第二次循環 ssize=2<16 進入循環 ssize左移兩位變成4;

第三次循環 ssize=4<16 進入循環 ssize左移兩位變成8;

第四次循環 ssize=8<16 進入循環 ssize左移變成16;

此時 ssize變成了16 不小於16 跳出循環

這塊代碼的目的是去找到一個最小的大於等於concurrencyLevel的2的冪次方數

大於等於16的最小2的冪次方數就是16呀 所以我們找到ssize就是16

同時 心細一點的同學跟着剛纔的循環去計算 得到的sshift是4 也就是找到了2^4等於16的這個4


我們往下看下一部分代碼

我們先註釋掉一部分不去管它

 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;

這塊代碼就要引出ConcurrentHashMap的底層結構了 要認真理解這塊

//initialCapacity的默認值是16
//ssize上面我們算出來是16
//我們可以得到下面這個寫的不太規範的式子
int c=16/16=1

這裏的c是什麼呢 ? 這裏的c先把他理解成就是Segment數組的大小(其實c的含義是不對的 但我們先這麼理解) 我們是根據 initialCapacity和ssize計算出來得

彆着急 下面還有完善

 int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;

這個MIN_SEGMENT_TABLE_CAPACITY的**默認值是2(也就是說設計者設計這個Segment數組最小長度就是2) **就是說現在cap=2;c=1;

2不小於1 所以不會進入這個while循環 接着往下

我們這裏算的c=1 比設計者設計的Segment數組最小長度還要小 所以我們按cap=2去初始化

在這裏插入圖片描述

如上圖所示 cap指定了每一個Segment可以放幾個HashEntry

ssize指定了一個ConcurrentHashMap可以放多少個Segment

現在 我們就可以重新繪製一下這個數據結構圖了

這其實就是默認情況下 ConcurrentHashMap的數據結構了

在這裏插入圖片描述


這裏 我們把剛剛註釋掉的代碼打開 再去分析一波

 int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;

假設 我這裏指定了initialCapacity是33

呢我們這個時候33/16 計算出來得c就是2 這個時候就會進入if這裏 c就會加1 此時c等於了3

 while (cap < c)
            cap <<= 1;

當代碼走到這裏 cap=2 c=3 2<3

就會進入while當中 cap左移變成了4 這裏實質性的改變就是對應的每一個Segment就會有4個HashEntry

這裏說明一下 爲什麼cap使用了左移 從2變成了4 這是因爲設計者要Segment的大小不論是幾

都應該是2的冪次方數

此時的數據結構是這樣

在這裏插入圖片描述


ConcurrentHashMap的數據插入原理(put方法)—怎麼去保證線程安全

public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
    	//(1)  j是算出來的Segment數組的下標
        int j = (hash >>> segmentShift) & segmentMask;
    	//(2)  通過Unsafe類去取segments數組第j個位置的元素看是不是null
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            //(3) 如果是null 去生成一個Segment對象
            s = ensureSegment(j);
    		//(4) 去調用生成的Segment對象的put方法
        return s.put(key, hash, value, false);
    }

我們把(3)處的ensureSegment方法展開說明一下 去理解他是怎麼生成一個Segment對象並保證線程安全的

 private Segment<K,V> ensureSegment(int k) {
        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) {
            //這部分代碼就是將一開始構造方法生成的ss[0]作爲一個原型(雛形)
            //利用ss[0]去初始化我們此時的Segment對象
            //但是真正初始化在下面一個if之後 這裏可以理解是做了一個準備工作
            //準備了一些需要的屬性 如負載因子啊 cap長度啊
            Segment<K,V> proto = ss[0]; // use segment 0 as prototype
            int cap = proto.table.length;
            float lf = proto.loadFactor;
            int threshold = (int)(cap * lf);
            HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
            
            if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                == null) { // recheck
                //這時候又再次判斷該位置有沒有其他線程進行了初始化
                //沒有其他線程的話   這時候真正去創建一個Segment對象
                //但是這裏還沒有把Segment對象放到數組對應的位置
                Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
                while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                       == null) {
                    //這裏的這個CAS操作真正對第u個位置進行賦值
                    if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                        break;
                }
            }
        }
        return seg;
    }

這個時候問題的關鍵來了 他在創建Segment對象的過程中是怎麼確保線程安全的呢

我們選取這部分代碼

 if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
            //...省略
          
            if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                == null) { // recheck
               
                while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                       == null) {
                    //這裏利用cas原子操作真正對數組第u個位置賦值
                    if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                        break;
                }
            }
        }
        return seg;

首先 他運用了一個雙重檢驗判斷 這個判斷類似單例模式的雙重檢驗判斷

public class Singleton {
    private static Singleton instance=null;

    private Singleton(){
    }

    public static Singleton getInstance() {
        if(instance==null){//檢查判斷1
            synchronized (Singleton.class){
                if(instance==null){//檢查判斷2
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}

(1)當線程A、B同時調用getInstance()方法,他們同時發現 instance == null成立(檢查判斷 1),同時去獲取的Singleton.class鎖
(2)其中線程A獲取到鎖,線程B 處於等待狀態;線程A會創建一個SingleTon實例,之後釋放鎖
(3)線程A釋放鎖後,線程B 被喚醒,線程B獲取到鎖,然後線程B檢查
instance == null 不成立(檢查判斷2),不會再創建Singleton實例對象

第二 也是更重要的是 他在馬上要賦值的時候 利用了CAS這個原子操作

CAS這個原子操作是不能被中斷的 我們這裏簡單談一下CAS幹了什麼

CAS就是先獲取主物理內存中的值作爲期望值 然後我們再去獲得此時主物理內存的真實值 如果期望值與真實值一致 我們就進行修改 否則 就一直取值比較 直到成功

所以每到一個關鍵節點 他就會做一次關於線程安全的判斷 利用了雙重檢查機制 也利用了CAS思想

總結一下 這個方法幹了什麼事情 也就是在new 一個Segment對象的時候保證了線程安全

在這裏插入圖片描述


我們把(4)處的Segment對象的put方法展開說明一下

	//(4) 去調用生成的Segment對象的put方法
        return s.put(key, hash, value, false);

我們去繼續學習這個Segment對象的put方法

這裏我們先去抽象出一個數據結構 也就是說Segment內部維護的一個個HashEntry整合起來 就好像一個小的HashMap一樣 也是數組+鏈表的形式(Segment內部就像一個小的HashMap)

在這裏插入圖片描述

我們先不去看加鎖的邏輯 我們先把中間怎麼put數據的流程大致理解清楚

 final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                //取tab數組 下表爲index的值作爲first
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    //鏈表的頭結點不爲空的情況
                    if (e != null) {
                        K k;
                        //遍歷當前位置的鏈表
                        //判斷傳入的key 和當前遍歷的 key 是否相等,相等則覆蓋舊的 value
                        //這裏根HashMap的邏輯很像
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    //爲空的情況
                    //情況1 頭結點爲空 把key-value放在頭結點
                    //情況2 遍歷完整個鏈表 然後頭插法插入
                    else {
                        if (node != null)
                            node.setNext(first);
                        else
                            //生成了一個HashEntry對象 記爲node
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        //如果超過閾值 就rehash
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            //沒有超過閾值 就把剛剛生成的node通過setEntryAt這個方法放進去
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }

我們上面初步分析了一下 put添加數據的過程

下面我們重點分析一下加鎖的過程

HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);

首先 我們去理解 scanAndLockForPut這個方法幹了什麼事情

我們這裏要做一個小的鋪墊

trylock()
lock()

trylock()這個方法 如果能夠獲取到鎖 就會立馬返回一個true

trylock()這個方法 如果獲取不到鎖 就會立馬返回一個false

trylock()不會阻塞

lock()這個方法如果獲取不到鎖 就會一直阻塞在這裏

這個scanAndLockForPut方法大概幹了什麼事情 我給大家解釋一下

當trylock()獲取不到鎖的時候 通過剛剛我們的鋪墊我們知道trylock()是不會阻塞的

那我們不能傻傻的等在這裏 我們既然不會阻塞 我們在這個過程中可以準備一些什麼事情呀?

這個過程就好比做飯 你在燒水等水開的過程中 可以去準備個涼菜 算是合理安排 提高效率

我們這裏的合理安排就是根據key-value去new一個HashEntry 我們把這個HashEntry記成node
在這裏插入圖片描述

這裏我們就把scanAndLockForPut這個方法做的事情給大家大致說明白了

scanAndLockForPut這個方法本身就設計的非常精妙 由於篇幅的原因就不在展開描述

之後會有更加詳細的說明解釋

 tryLock() ? null : scanAndLockForPut(key, hash, value);

這裏不是有個三目運算符嗎 trylock()獲取不到鎖的時候 就會走scanAndLockForPut這個方法準備一個node對象出來

第二 我們理解一下這裏的保證線程安全 爲什麼用了lock

在這裏插入圖片描述

如上圖所示 我們根本的目的是把key-value放進去

我們要在鏈表當中去插入元素 注意是插入元素 這個時候CAS就沒有更好的辦法了 因爲CAS對某一個具體的位置賦 值還是可以的 但是讓CAS去插入是不能實現的 所以這個插入時候我們爲了保證線程安全 就要去加鎖

這裏保證線程安全的方法很實在 就是加了一把鎖 讓同一時間只有一個線程去put數據


我們總結一下jdk7 下 ConcurrentHashMap是怎麼保證併發安全的

第一 在進行一些鏈表的插入數據時 用了ReentrantLock去加了一把鎖

第二 用了UNSAFE的各種方法 這其中包括了我們最熟悉的CAS 還有UNSAFE類的一些其他方法呀

​ 比如 UNSAFE.putOrderedObject等等



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