【源碼學習】深入剖析核心源碼之 ConcurrentHashMap(JDK1.7 和JDK1.8)

面試中常被問到的數據結構就是哈希表,一般都是先問HashMap,再接着問ConcurrentHashMap,所以深入學習源碼以及相關的知識是很重要的。
大家也可以參考我之前的 深入剖析核心源碼之 HashMap

1.爲什麼不繼續使用HashMap?

因爲HashMap在多線程的情況下不安全,只適合單線程下使用,在JDK1.7時,HashMap採用頭插方式,這樣如果使用多線程在擴容進行重新再散列後,就會產生環形鏈表的問題。雖然在1.8後優化爲了尾插,解決了環形鏈表的問題,但由於它所有的方法都不是線程安全的,所有多線程環境下並不適合使用。

2.有什麼方式保證多線程安全?

一般在多線程下,有以下幾種方式代替HashMap:

  • 使用Collections.synchronizedMap(Map)創建線程安全的map集合;
  • 使用 Hashtable
  • 使用 ConcurrentHashMap

其中,ConcurrentHashMap的效率要高於前兩種方式

3. Collections.synchronizedMap(Map)怎麼實現的?

首先傳入一個我們自己的Map m後,會創建一個SynchronizedMap類的實例。

在這裏插入圖片描述 接着,來看看SynchronizedMap類是怎麼實現的: 在這裏插入圖片描述 這個類維護了一個Map用來接收我們的傳入參數,還維護了一個Object的對象用來作爲鎖對象,也可以自己傳入一個對象作爲鎖對象。而在這個類的內部,定義的所有方法都是用synchronized關鍵字對mutex對象加鎖包裹一個代碼塊,實現了多線程下的安全問題。 在這裏插入圖片描述 當然,使用這種方式的效率會很低,由於鎖的是同一個對象,所以哪怕是兩個線程同時想讀取數據,其中一個也會被鎖住。

4.HashTable又是怎麼實現的?

在前面學習的 HashMap中,對於HashMap有個描述:HashMap和HashTable實現基本是相同的,除了HashMap是允許null爲Key以及HashMap是線程不安全的。從這裏就可以看出,HashTable與HashMap最大的不同就是它是線程安全的。而HashTable實現線程安全的方式也很簡單粗暴,那就是給每一個方法都加上synchronized關鍵字

大家可以自己去查看源碼:
在這裏插入圖片描述

5. HashMap和HashTable的區別有哪些?

  • 線程安全性不同: HashMap不是線程安全的,Hashtable是線程安全的。

  • null爲Key的要求不同:HashMap 中null 可以作爲Key,而Hashtable中不可以。

  • 實現方式不同:Hashtable 繼承了 Dictionary類,而 HashMap 繼承的是 AbstractMap 類。

  • 初始化容量不同:HashMap 的初始容量爲:16,Hashtable 初始容量爲:11,兩者的負載因子默認都是:0.75。

  • 底層實現不同:HashMap底層採用 數組+鏈表/紅黑樹 來實現的,HashTable採用 數組+鏈表實現。

  • 擴容機制不同:HashMap 擴容規則爲當前容量2倍,Hashtable 擴容規則爲當前容量2倍 + 1

  • 迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 是fail—safe 的

小知識:

  • fail-fast (快速失敗):在用迭代器遍歷一個集合對象時,如果遍歷過程中對集合對象的內容進行了修改(增加、刪除、修改)則會拋出Concurrent Modification Exception。java.util包下的集合類都是快速失敗的,不能在多線程下發生併發修改(迭代過程中被修改)
  • fail—safe (安全失敗): 採用安全失敗機制的集合容器,在遍歷時不是直接在集合內容上訪問的,而是先複製原有集合內容,在拷貝的集合上進行遍歷,所以元素的更新不影響遍歷。java.util.concurrent包下的容器都是安全失敗,可以在多線程下併發使用,併發修改。

6.ConcurrentHashMap在JDK1.7中是底層結構是怎樣的?

在JDK1.7 中使用的是分段鎖機制,它的底層結構圖如下:
在這裏插入圖片描述
其中Segment是一種可重入鎖(ReentrantLock) 而HashEntry則用於存儲鍵值對數據。這就實現了使用不同的鎖鎖住不同的數據段,避免了訪問所有方法都競爭同一把鎖,提高了併發效率。
一個ConcurrentHashMap中包含着一個Segment數組,Segment結構 是數組+鏈表,也就是一個Segment中包含一個HashEntry的數組,每個HashEntry又是一個鏈表結構的元素(HahsEntry就像HashMap中的Node一樣,是真正存放數據的桶)。也就是把HashMap的數組拆分成不同段,然後給不同段加上相應的Segment鎖,這樣,只有在對同一個Segment中的元素進行操作時纔會加同一把鎖,而對不同的Segment中元素操作時加不同的鎖,不會由器線程阻塞。
在這裏插入圖片描述

7.ConcurrentHashMap在JDK1.7中各種操作是怎樣的?

get操作:

首先會進過一次再散列,得到散列值通過散列運算定位到對應的Segment,再通過散列算法定位到元素。 get操作的高效之處就在於整個過程是不需要加鎖,爲什麼呢?因爲它將所有的共享變量都定義成了volatile類型,這樣就能保證變量在線程之間的可見性,能夠被多線程同時讀,且保證讀到的都是最新的、正確的值。

put操作:

爲了保證多線程安全,put操作是必須要加鎖的。還是同樣定位到相應的Segment後在其中插入元素,此時就考慮是否需要對Segment中的HashEntry擴容,如果需要擴容就會調用rehash方法將容量擴大爲原來的兩倍,注意,這裏只是擴大某個Segment而不是整個Map

源碼如下:

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          
         (segments, (j << SSHIFT) + SBASE)) == null) 
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
   // 嘗試獲取鎖,如果獲取失敗肯定就有其他線程存在競爭,利用 scanAndLockForPut() 自旋獲取鎖。
     HashEntry<K,V> node = tryLock() ? null :
         scanAndLockForPut(key, hash, value);
     V oldValue;
     try {
         HashEntry<K,V>[] tab = table;
         int index = (tab.length - 1) & hash;
         HashEntry<K,V> first = entryAt(tab, index);
         for (HashEntry<K,V> e = first;;) {
             if (e != null) {
                 K k;
// 遍歷該 HashEntry,如果不爲空則判斷傳入的 key 和當前遍歷的 key 是否相等,相等則覆蓋舊的 value。
                 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 {
          // 不爲空則需要新建一個 HashEntry 並加入到 Segment 中,同時會先判斷是否需要擴容。
                 if (node != null)
                     node.setNext(first);
                 else
                     node = new HashEntry<K,V>(hash, key, value, first); //新put的節點
                 int c = count + 1;
                 if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                     rehash(node); //擴容
                 else
                     setEntryAt(tab, index, node);
                 ++modCount;
                 count = c;
                 oldValue = null;
                 break;
             }
         }
     } finally {
         unlock();
     }
     return oldValue;
 }

size操作:

要統計整個的size,就要統計所有的Segment離元素的大小後求和。雖然Segment裏面的count變量是個volatile類型的變量,但還是不可以直接相加。因爲不能保證在進行相加的這個過程中沒有修改某一個Segment中的count值。第一個想到的就是把能使得count改變的操作都鎖住,比如:put、remove等鎖住,但這樣實在是有些低效。
因爲在累加count時,之前的count變化的機率比較小,所以,ConcurrentHashMap做法就是先嚐試2次不鎖的方式來統計大小,如果在統計過程中,count發生了變化在採用加鎖的方式統計大小。

8.ConcurrentHashMap的JDK1.7版本有什麼問題呢?

可以看出,1.7版本的 ConcurrentHashMap在線程安全問題上時做到位了,但還存在一點點的不足之處,那就是如果一個HashEntry中數據過多,那麼在查詢中只能遍歷一遍,這樣的時間複雜度就是 O(n) 查詢的效率相對低下,所以在1.8版本又對 ConcurrentHashMap進行了進一步的改進。

9.ConcurrentHashMap在JDK1.8中是底層結構是怎樣的?

JDK1.8中ConcurrentHashMap採用數據 + 鏈表/紅黑樹的結構實現的,結構圖如下:
在這裏插入圖片描述
在JDK1.8中,拋棄了原有的分段鎖結構,改爲了CAS + synchronized 來保證併發安全性。也把之前的HashEntry改成了Node,但是作用不變,把值和next採用了volatile去修飾,保證了可見性,並且也引入了紅黑樹,在鏈表大於一定值的時候會轉換(默認是8)。

10.ConcurrentHashMap在JDK1.8中各種操作是怎樣的?

各種屬性:

//最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//初始容量
private static final int DEFAULT_CAPACITY = 16;
//數組最大容量
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//默認併發度,兼容1.7及之前版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//加載/擴容因子,實際使用n - (n >>> 2)
private static final float LOAD_FACTOR = 0.75f;
//鏈表轉紅黑樹的節點數閥值
static final int TREEIFY_THRESHOLD = 8;
//紅黑樹轉鏈表的節點數閥值
static final int UNTREEIFY_THRESHOLD = 6;
//當數組長度還未超過64,優先數組的擴容,否則將鏈表轉爲紅黑樹
static final int MIN_TREEIFY_CAPACITY = 64;
//擴容時任務的最小轉移節點數
private static final int MIN_TRANSFER_STRIDE = 16;
//sizeCtl中記錄stamp的位數
private static int RESIZE_STAMP_BITS = 16;
//幫助擴容的最大線程數
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//size在sizeCtl中的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
 
// ForwardingNode標記節點的hash值(表示正在擴容)
static final int MOVED     = -1; // hash for forwarding nodes
// TreeBin節點的hash值,它是對應桶的根節點
static final int TREEBIN   = -2; // hash for roots of trees
static final int RESERVED  = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
 
//存放Node元素的數組,在第一次插入數據時初始化
transient volatile Node<K,V>[] table;
//一個過渡的table表,只有在擴容的時候纔會使用
private transient volatile Node<K,V>[] nextTable;
//基礎計數器值(size = baseCount + CounterCell[i].value)
private transient volatile long baseCount;
/**
 * 控制table數組的初始化和擴容,不同的值有不同的含義:
 * -1:表示正在初始化
 * -n:表示正在擴容
 * 0:表示還未初始化,默認值
 * 大於0:表示下一次擴容的閾值
 */
private transient volatile int sizeCtl;
//節點轉移時下一個需要轉移的table索引
private transient volatile int transferIndex;
//元素變化時用於控制自旋
private transient volatile int cellsBusy;
// 保存table中的每個節點的元素個數 長度是2的冪次方,初始化是2,每次擴容爲原來的2倍
// size = baseCount + CounterCell[i].value

get操作:

與1.7一樣,get方法是全程沒有加鎖的,但由於變量是volatile的,所以可以保證線程安全。它的流程就是:

  1. 根據計算出來的 hashcode 尋址,如果就在桶上那麼直接返回值。
  2. 如果是紅黑樹那就按照樹的方式獲取值。
  3. 如果不滿足那就按照鏈表的方式遍歷獲取值。

源碼如下:
在這裏插入圖片描述
put操作:

put操作就做了一些改動了,也是比較複雜的,它的流程如下:

  1. 根據 key 計算出 hashcode 找到下標索引
  2. 判斷是否需要進行初始化。
  3. 如果定位出的Node爲null,就利用 CAS 嘗試寫入,無條件自旋保證成功。
  4. 判斷是否在進行擴容(別的線程在擴容,就幫助去擴容)
  5. 如果都不滿足,那就利用 synchronized 鎖進行寫入數據(分爲鏈表和紅黑樹兩種方式)。
  6. 最後插入後,如果數量大於 TREEIFY_THRESHOLD 則要轉換爲紅黑樹。

源碼如下:

final V putVal(K key, V value, boolean onlyIfAbsent) {
		//Key和value都不爲空
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode()); //得到hash值
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0) //進行初始化整個Map
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //元素定位出的Node位置爲null,表示可直接寫入,使用CAS自旋直到寫入成功
                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 {
            //在桶或紅黑樹中插入數據,使用synchronized鎖住要插入的位置節點
                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;
                            }
                        }
                    }
                }
                //插入後看鏈表是否需要樹化
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

JDK1.7到1.8對ConcurrentHashMap有着很大的修改,最大的還是將原本的segment分段鎖改爲了CAS和synchronized(空節點插入用CAS,有節點插入用synchronized),因爲在1.6以後,synchronized進行優化後,不像之前那樣 “ 笨重 ” 而是加上了偏向鎖,輕量級鎖,重量級鎖 之間的一個鎖膨脹的過程,所以1.8優化後不僅保證了高效率下的線程安全,同時解決了1.7中查詢效率慢的問題,改爲紅黑樹後,查詢效率大大提高。

綜合而言,1.8的ConcurrentHashMap已經很接近HashMap了,只是增加了併發控制,所以在理解了HashMap的設計後再理解ConcurrentHashMap,就比較容易了。

11.ConcurrentHashMap 1.7和1.8版本的異同

相同點:

  1. 讀操作都沒加鎖,使用volatile保證了可見性
  2. 讀寫分離,無論是對Segment加鎖還是對Node加鎖,只是對一部分數據加鎖,多線程對於不同的Segment和Node都可以併發執行。
  3. 使用fail-safe迭代器,創建迭代器後可對元素進行更新

不同點:

  1. JDK1.7 使用數組加鏈表,1.8使用數組+鏈表/紅黑樹
  2. JDK1.7 使用分段鎖機制,基於ReentrantLock實現,JDK1.8基於CAS和synchronized實現

嘮嘮叨叨
在理解HashMap的基礎上再來理解HashTable和ConcurrentHashMap就會容易很多,也要理解1.7和1.8的異同點以及各自的put方法都是重點,還有很多細節點都需要自己一點點去看源碼理解。文章如果有什麼問題歡迎留言指正,另外如果對你有幫助也歡迎小夥伴們點贊關注一起進步!

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