【源碼閱讀】ConcurrentHashMap 1.7

一、爲什麼引入 ConcurrentHashMap?

  • HashMap 可能會在擴容時的 transfer 操作發生併發問題導致鏈表循環引用,導致在進行 get 操作時發生死循環。
  • HashTable 可以提供併發功能,但它是用 synchronize 關鍵字修飾每一個存在併發需求的方法上,也就是給整個 table 都加了鎖,在多線程環境下,可能存在所有線程都等正競爭一把鎖的情況,這也就造成了效率低下的問題。-

  • Q1:怎麼解決/優化上述問題?
  • A1:採用對 table 的不同數據集分別加鎖的方案代替鎖住整個 table 的數據。
  • Q2:上述方案可行的原理是什麼?
  • A2:我們知道 hash 值不同,在 rehash 時並不會造成線程安全問題,所以分別鎖住別個數據段是可行的。

二、源碼閱讀

(1) 底層數據結構

在 JDK1.7 版本中,ConcurrentHashMap 的數據結構是由一個內含多個 HashEntry 組 的 Segment 數組構成。先總覽一下 ConcurrentHashMap 幾個重要的成員變量:

	//默認的數組大小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;
		}
	}
	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;

看下 Segment 數組,它的意義是:將一個 Segment 分割成多個小的 table 來進行加鎖,也就是上面的提到的分段鎖,而每一個 Segment 元素存儲的是 HashEntry 數組,每一個 HashEntry (數組+鏈表)

有沒有覺得 Segment 很像 HashMap 的組成...

static final class Segment<K,V> extends ReentrantLock implements Serializable {

	private static final long serialVersionUID = 2249069246763182397L;
	
	// 和 HashMap 中的 HashEntry 作用一樣,真正存放數據的桶
	transient volatile HashEntry<K,V>[] table;
	
	transient int count;
	
	transient int modCount;
	
	transient int threshold;
	
	final float loadFactor;
}

再看下 HashEntry 的內部構造

static final class HashEntry<K,V> {
	final int hash;
	final K key;
	volatile V value;
	volatile HashEntry<K,V> next;
	//其他省略...
}

(2) 構造方法

public ConcurrentHashMap(int initialCapacity,
                               float loadFactor, int concurrencyLevel) {
     if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
         throw new IllegalArgumentException();
     // 最大併發數爲 1<<16=65536
     if (concurrencyLevel > MAX_SEGMENTS)
         concurrencyLevel = MAX_SEGMENTS;
     // 2 的sshif次方等於ssize,例:ssize=16,sshift=4;ssize=32,sshif=5
    int sshift = 0;
    //ssize 爲segments數組長度,根據concurrentLevel計算得出
    int ssize = 1;
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    //默認值,concurrencyLevel 爲 16,sshift 爲 4,那麼計算出 segmentShift 爲 28,segmentMask 爲 15
    //segmentShift和segmentMask這兩個變量在定位segment位置時會用到,後面會詳細講
    this.segmentShift = 32 - sshift;
    this.segmentMask = ssize - 1;
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    /* initialCapacity設置整個 segm 的初始容量,根據 initialCapacity 計算 Segment 數組的每個位置可以分配的大小
       如 initialCapacity 爲 64,那麼每個 Segment 可以分到大小爲 4 的 HashEntry 數組*/
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    // 默認 MIN_SEGMENT_TABLE_CAPACITY 是 2,爲什麼是 2 呢?因爲對於具體的槽上,插入一個元素不會立刻擴容
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    while (cap < c)
        cap <<= 1;
    //創建segments數組並初始化第一個Segment,其餘的Segment延遲初始化
    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);
    this.segments = ss;
}

總結一下,無外乎幾步:

  • 確定最大併發數 concurrencyLevel(最大爲 2^{16})
  • 根據 concurrencyLevel 確定 sshift 和 ssize
    • ssize 初始值爲 1,通過左移得到一個 >= concurrencyLevel 的最小 2 的冪次方數。
    • sshift 表示 sszie 左移的次數。
  • 根據 sshift 算出 segmentShift = 32 - sshift 根據 ssize 算出 segmentMask = ssize - 1,因爲 ssize 的值爲 2^n,所以減 1 就變成爲二進制位中第 n 位以下全是 1 的二進制數。
  • 確定每個槽的初始容量 initialCapacity,但最大不能超過 MAXIMUM_CAPACITY = 2
  • 根據 ssize 和 initialCapacity 算出每個槽中 HashEntry 數組的長度 cap(這也一定要是一個 2 的冪次方數,具體看代碼)
  • 根據 loadFactor (0.75)、cap 創建第一個 Segment 對象 s0,這個是有講究的,後面在說。

(3) put 方法

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)	   //1. ConcurrentHashMap不允許valus爲空
        throw new NullPointerException();
    int hash = hash(key);  //2. 根據key計算hash值,key也不能爲null,否則hash(key)報空指針
    //3. 根據hash值計算在segments數組中的位置
      // hash 是 32 位,無符號右移 segmentShift(28) 位,用剩下的高 4 位,
      // 和 segmentMask(15) 做一次與操作,也就是說數組下標 j 是 hash 值的高 4 位
    int j = (hash >>> segmentShift) & segmentMask; 
    //4. 取第Segment數組的 j 個位置的元素
    if ((s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null) // nonvolatile + in ensureSegment
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

總結一下:

  • 調用 hash 方法,算出非空 key 的 32 位整數變量 hash
  • 用 hash 右移 segmentShift 位的結果對 segmentMask 進行按位與 & 操作得到 Segment 數組的下標 j:
    • 如果該位置的 Segment 還沒有初始化,就會通過 CAS 操作對位置的 Segment 代用 ensureSegment 方法進行賦值。
    • 否則,直接調用 Segment 的 put 方法進行賦值。

CAS(compare and swap):即比較並交換,CAS 機制當中使用了 3 個基本操作數:

  • 內存地址 V,預期的舊值 A,要修改的新值 B。
  • 更新一個變量的時候,只有當變量的預期的舊值 A 和內存地址 V 當中的實際值相同時,纔會將內存地址 V 對應的值修改爲 B。

接着看一下 ensureSegment 方法的邏輯

3.1 ensureSegment 方法

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;
    // 在 seg 的第 j 個位置爲空的情況下(見put方法)進到該方法內部
    // 因爲可能存在併發情況,故要檢查是否被其他線程先初始化了 seg 的 u 位置,是就先返回
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        // 這裏相當於利用seg[0]來初始化了一個"HashMap"
        // 可以直接使用"當前"的 seg[0] 處的數組長度和負載因子來初始化 segment[k],省去一些瑣碎的計算 cap、lf、thre...
        // 爲什麼說“當前”呢,是因爲seg[0]可能被其他線程修改過(擴容等等)
        Segment<K,V> proto = ss[0];
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
        // 初始化 segment[u] 內部的數組
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        // 再次檢查該槽是否被其他線程初始化,儘量減少下面的初始化操作
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))== null) { 
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            // 使用保險的自旋:用 CAS 一直檢查,直到當前線程成功設值或其他線程成功設值後才退出
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
            	// 使用 cas 操作來更新值(內存地址ss[u]、預期值null、新值 seg),存在兩種情況:
            	 // 1.因爲被其他線程搶先操作了(不等於預期值 null),所以更新失敗,然後繼續循環直到滿足預期值爲止
            	 // 2.更新成功,break
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

看完我有點感嘆:不得不說高併發設計是多麼嚴格啊,只要我還沒有到初始化成功,我就要用 CAS 機制檢查我要賦值的位置是否被其他線程先初始化了!

總結一下:

  • 根據索引 k 計算出 segment 數組的第 u 個位置
  • 檢查 segment 數組的第 u 個位置是否已經有值
    • 沒有,則繼續利用 ss[0] 的屬性來初始化 seg
    • 有則,返回該位置的已存在的值
  • 因爲中間經歷了一些耗時的初始化動作,所以又檢查了一遍 ss[u] 是否有值,如果沒有,繼續按部就班。
  • 最後利用 while + if 的 cas 自旋操作一直檢查到,cas 成功爲止。

執行完 ConcurrentHashMap 的 put 方法,接下來就是執行返回的 Segment 對象的的 put 方法了

3.2 Segment.put()

這個方法作用就是在獲取的 segment 對象內部的 HashEntry 數組/鏈表中放入/插入參數 key、value 等元素

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 在往該 segment 對象放入之前,先非阻塞地嘗試獲取該 segment 對象的鎖
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table; 		 //獲取segment對象內部的數組
        int index = (tab.length - 1) & hash; //再利用參數hash求出放置k、v的數組下標
        HashEntry<K,V> first = entryAt(tab, index); // 獲取數組index位置的鏈表表頭
        for (HashEntry<K,V> e = first;;) {	//遍歷鏈表
            if (e != null) {
                K k;						//if操作檢查是否需要覆蓋舊值
                if ((k = e.key) == key || (e.hash == hash && key.equals(k))) {	
                    oldValue = e.value;
                    if (onlyIfAbsent == false) {//是否替換取決於調用者的意願
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;	
            }else {// 判斷node到底是否爲null,這個要看獲取鎖的過程,不過和這裏都沒有關係。
                   // 如果不爲 null,使用頭插法插在鏈表表頭;如果是null,初始化並設置爲鏈表表頭。
                if (node != null) node.setNext(first);
                else			  node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                	  // 超過了該 segment 的閾值,這個 segment 需要擴容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else // 沒有達到閾值,將 node 放到數組 tab 的 index 位置
                	 // 注:裏面是調用UNSAFE的put方法保證將對象存到內存中,而不是僅僅插在線程的工作空間中
                    setEntryAt(tab, index, node);	// 鏈表下移
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock(); //解鎖
    }
    return oldValue;
}

對 scanAndLockForPut 方法有一些疑惑,不妨看看這個場景:

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

在這裏插入圖片描述

此時正好 Thread2 也來存值,通過下標計算,Thread2 被定位到 seg[5] 中 HashEntry[] 的 0 號位置,接下來 Thread2 也調用當前 seg 對象的 put 方法,一開始先嚐試獲取鎖,沒有成功 (Thread1 還未釋放,沒有插入完畢),就會去執行 scanAndLockForPut() 方法:

private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    HashEntry<K,V> first = entryForHash(this, hash); //根據hash值獲取當前HashEntry數組的對應位置的結點
    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) // 進到這裏說明數組該位置的鏈表是空的,沒有任何元素
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            else if (key.equals(e.key)) retries = 0;//如果發現key重複證明該位置的值不空
            else						e = e.next; //否則繼續遍歷
        }
        // 重試次數如果超過 MAX_SCAN_RETRIES(單核重試1次多核64次)
        else if (++retries > MAX_SCAN_RETRIES) {
            lock();			// lock() 是阻塞方法,直到獲取鎖後返回
            break;
        }else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first) {				
        	//每間隔2次重試就檢查鏈表是否發生被其它線程膝蓋,如果是,則重新自旋獲取鎖
            e = first = f; 	// re-traverse if entry changed
            retries = -1;
        }
    }
    return node;
}

雖然 Thread2 沒有獲取到鎖,但它並不是閒着,而是在進入 scanAndLockForPut 方法等待鎖的過程中,預先計算好自己要存放的鍵值對的在 seg[5] 中的相應位置,以便拿到鎖時立刻執行賦值操作,達到節省時間的作用。

  • Q1:那爲什麼在自旋的時候還要去檢查鏈表是否被改變了呢?
  • A1:這是因爲當 Thread2 確定了插入的位置在 0 號位置,但 Thread1 已經完成插入了,那麼此時根據 new HashEntry<K,V>(hash, key, value, first) 計算出來的值可能會造成 hash 衝突,所以要重新咯...

看到這裏,其實整體感覺也不難,總結一下:

  • 非阻塞地嘗試獲取 seg 對象的可重入鎖
    • 這裏使用了非阻塞的 tryLock 去獲取鎖
    • 題外話:另外,如果是阻塞式地獲取鎖就應該調用可重入鎖的 lk.lock() 方法
  • 使用頭插法插入到合適的位置,位置可能是數組也可能是鏈表
  • 插入完畢還有一些是否需要擴容的檢查,下面會講到。

3.3 Segment.rehash()

private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table; //獲取原table
    int oldCapacity = oldTable.length;
    int newCapacity = oldCapacity << 1;//長度取原長的2倍
    threshold = (int)(newCapacity * loadFactor);//得到新的閾值
    HashEntry<K,V>[] newTable =		   //創建新table
        (HashEntry<K,V>[]) new HashEntry[newCapacity];
    int sizeMask = newCapacity - 1;	   //新的掩碼,如從16擴容到32,那麼 sizeMask 爲 31,對應二進制 ‘000...00011111’
    // 遍歷原數組,將原數組位置 i 處的鏈表拆分到新數組位置 i 和 i+oldCap 兩個位置
    for (int i = 0; i < oldCapacity ; i++) {
        //e是鏈表表頭
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {		 //不空才進行數據遷移
            HashEntry<K,V> next = e.next;
            //重定位,假設原數組長度爲16,e 在oldTable[3]處,那麼idx只可能是3或者是3 + 16 = 19
            int idx = e.hash & sizeMask;
            if (next == null)  			   //數組的鏈表只有一個元素
                newTable[idx] = e;
            else { 						   //循環遷移
                HashEntry<K,V> lastRun = e;//e 是鏈表表頭
                int lastIdx = idx;		   //idx是當前鏈表的頭結點 e 的新位置
                //該for循環會找到一個lastRun節點,區間[lastRun, end]中的結點的下標都是一樣的,所以在新數組的位置是一樣的
                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; //直接賦值就不用將lastRun後面的所有結點一個一個地插入
                //下面的for是遷移lastRun前面的節點
                //這些節點可能分配在另一個鏈表中,也可能分配到上面的那個鏈表中
                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);
                }
            }
        }
    }
    // 將要插入的node結點放到新數組中某個位置鏈表頭部
    int nodeIndex = node.hash & sizeMask;
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}

rehash 是在加鎖的 put 方法中調用的,所以不會產生線程不安全問題。邏輯也比較清楚,總結一下:

  • 計算新長度、新閾值、新掩碼。
  • 遍歷老數組,將位置 i 的元素遷移到新數組的兩個位置 i / i + oldCap 之一中去,中間涉及到了一些細節,可看上述代碼註釋。
  • 最後就將,要插入的新 node 插到新數組中某個位置鏈表頭部。

好,至此 ConcurrentHashMap 的 put 方法也就講解完畢,下面到它的 get 方法...

(4) get 方法

get 方法相對比較簡單

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key); 	// 1.獲取hash值
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    //2.根據hash值找到對應的 segment
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) {
        //3.從內存中找到segment對象相應位置的數組中的鏈表(防止併發問題)
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

(5) size 方法

public int size() {
    final Segment < K, V > [] segments = this.segments;
    int size;
    boolean overflow; // true if size overflows 32 bits
    long sum; 		  // 總修改次數
    long last = 0 L;  // 上一次的總修改次數
    int retries = -1; // first iteration isn't retry
    try {
        for(;;) { //如果遍歷次數達到2次以上,證明在第二次遍歷時存在併發修改問題,故在第三次遍歷時,對每個seg對象加上鎖
            if(retries++ == RETRIES_BEFORE_LOCK) {//RETRIES_BEFORE_LOCK=2
                for(int j = 0; j < segments.length; ++j) 
                	ensureSegment(j).lock();
            }
            sum = 0 L;
            size = 0;
            overflow = false;
            for(int j = 0; j < segments.length; ++j) {
                Segment < K, V > seg = segmentAt(segments, j);
                if(seg != null) {
                    sum += seg.modCount; //得到當前seg對象的修改次數(put、remove)
                    int c = seg.count;	 //得到單個seg的大小
                    if(c < 0 || (size += c) < 0) //標記是否如果產生溢出
                    	overflow = true;
                }
            }
            if(sum == last) 			//直到和上一次修改總次數得到的總和相等,ConcurrentHashMap 沒有被修改過
            	break;	
            last = sum;
        }
    } finally {
        if(retries > RETRIES_BEFORE_LOCK) {
            for(int j = 0; j < segments.length; ++j) 
            	segmentAt(segments, j).unlock(); //解鎖
        }
    }
    return overflow ? Integer.MAX_VALUE : size;	 //如果發生溢出,返回最大整形
}
  • Q1:爲什麼方法中的 modCount 只增不減,這樣設計的目的是什麼?
  • A1:還是從併發的角度來分析,這樣設計的目的是避免一個線程 put 元素和另一個線程 remove 元素後抵消了前面線程的 put 動作的 modCount,進而避免在統計 size 的時候產生死循環問題。

總結一下:

  • 前後兩次計算出 ConcurrentHashMap 內部的每一個 Segment 對象的 modCount 總和到 sum 和 last 中。
  • 如果兩次遍歷得到的結果不同,即 sum != last 則證明存在線程併發修改,到第三次遍歷就會對每一個 seg 對象都加上鎖,然後再次遍歷,直到 sum = last 退出循環
  • 其中需要記錄 map 中的元素總數是否發生溢出。

恢復: https://www.javadoop.com/post/hashmap#toc_3 https://www.jianshu.com/p/9c713de7bbdb https://juejin.im/post/5a2f2f7851882554b837823a https://www.cnblogs.com/study-everyday/p/6430462.html#autoid-2-0-0 https://www.cnblogs.com/chengxiao/p/6842045.html

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