ConcurrentHashMap 在 Java7 和 8 有何不同?

ConcurrentHashMap 在 Java7 和 8 有何不同?

前言

本章的部分內容在之前的文章 Java併發編程|第十篇:ConcurrentHashMap源碼分析 也有提到,但是之前的文章更偏重於源碼的分析,相對比較複雜和枯燥。而本章主要是針對面試的重點進行討論,以及之前內容的總結與回顧。

1.Java 7

在這裏插入圖片描述
從圖中我們可以看出,在 ConcurrentHashMap 內部進行了 Segment 分段,Segment 繼承了 ReentrantLock,可以理解爲一把鎖,各個 Segment 之間都是相互獨立上鎖的,互不影響。相比於之前的 Hashtable 每次操作都需要把整個對象鎖住而言,大大提高了併發效率。因爲它的鎖與鎖之間是獨立的,而不是整個對象只有一把鎖。

每個 Segment 的底層數據結構與 HashMap 類似,仍然是數組和鏈表組成的拉鍊法結構。默認有 0~15 共 16 個 Segment,所以最多可以同時支持 16 個線程併發操作(操作分別分佈在不同的 Segment 上)。16 這個默認值可以在初始化的時候設置爲其他值,但是一旦確認初始化以後,是不可以擴容的。

2.Java 8

在這裏插入圖片描述
圖中的節點有三種類型

  • 第一種是最簡單的,空着的位置代表當前還沒有元素來填充。
  • 第二種就是和 HashMap 非常類似的拉鍊法結構,在每一個槽中會首先填入第一個節點,但是後續如果計算出相同的 Hash 值,就用鏈表的形式往後進行延伸。
  • 第三種結構就是紅黑樹結構,這是 Java 7 的 ConcurrentHashMap 中所沒有的結構,在此之前我們可能也很少接觸這樣的數據結構。

當第二種情況的鏈表長度大於某一個閾值(默認爲 8),且同時滿足一定的容量要求的時候,ConcurrentHashMap 便會把這個鏈表從鏈表的形式轉化爲紅黑樹的形式,目的是進一步提高它的查找性能。所以,Java 8 的一個重要變化就是引入了紅黑樹的設計,由於紅黑樹並不是一種常見的數據結構,所以我們在此簡要介紹一下紅黑樹。

紅黑樹的特點:

  • 根節點是黑色的

  • 每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據

  • 任何相鄰的節點都不能同時爲紅色,也就是說,紅色節點是被黑色節點隔開的

  • 每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點

紅黑樹是一種特殊平衡二叉查找樹(AVL樹),查找效率高,會自動平衡,防止極端不平衡從而影響查找效率的情況發生。

由於自平衡的特點,即左右子樹高度幾乎一致,所以其查找性能近似於二分查找,時間複雜度是 O(logn) ;反觀鏈表,它的時間複雜度就不一樣了,如果發生了最壞的情況,可能需要遍歷整個鏈表才能找到目標元素,時間複雜度爲 O(n),遠遠大於紅黑樹的 O(logn),尤其是在節點越來越多的情況下,O(logn) 體現出的優勢會更加明顯。

關於 O(logn) 的查詢效率,可以參考博主之前的一篇文章:

《二分查找-上》 也是一種時間複雜度爲 O(logn) 的查找方法,第三小節 3.O(logn) 驚人的查找速度

3.重要的方法回顧

3.1 Node 數組

ConcurrentHashMap 內部是一個 Node 的數組

transient volatile Node<K,V>[] table;

Node

    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 裏面是 key-value 的形式,並且把 value 用 volatile 修飾,以便保證可見性,同時內部還有一個指向下一個節點的 next 指針,方便產生鏈表結構。

initTable() 方法中默認初始化一個長度爲 16 的 Node 數組,關鍵代碼如下:

Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;//將值賦值給table

3.2 put 方法

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        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;
            if (tab == null || (n = tab.length) == 0) //如果node數組爲空 
                tab = initTable();// 初始化
            // 找該 hash 值對應的數組下標
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                //如果該位置是空的,就用 CAS 的方式放入新值
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            //hash值等於 MOVED 代表在擴容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);//協助擴容,邏輯和transfer類似
            else {
                V oldVal = null;
                synchronized (f) {//對頭節點加鎖
                    if (tabAt(tab, i) == f) {//判斷table數組當前位置是否爲頭節點
                        if (fh >= 0) {// 表示鏈表形式
                            binCount = 1;//記錄鏈表的長度
                            for (Node<K,V> e = f;; ++binCount) {// 遍歷鏈表
                                K ek;
                                //如果發現該 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;
                                //到了鏈表的尾部也沒有發現該 key,說明之前不存在,就把新值添加到鏈表的最後
                                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);//如果鏈表的長度>=8 鏈表轉化爲紅黑樹
                    if (oldVal != null) //putVal 的返回是添加前的舊值,所以返回 oldVal
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);// 集合的大小+1
        return null;
    }

put方法大致分爲四個階段

  • 第一個階段可以稱爲初始化階段,在第一次調用 put 方法時,table 數組爲空,調用 initTable 方法進行 table 數組的初始化

  • 第二個階段,判斷 table 數組對應位置節點是否爲空,如果該位置是空的,就用 CAS 的方式放入新值

  • 第三個階段,產生 hash 碰撞,對頭節點上鎖,採用拉鍊法將衝突的元素轉爲鏈表

  • 最後一個階段,如果鏈表的長度>=8(且同時滿足一定的容量要求的時候) 鏈表轉化爲紅黑樹

當然還有併發擴容,集合大小併發計算等等,可以從之前的源碼分析文章進行了解。

3.3 get 方法

大致分爲 5 步:

  • 計算 Hash 值,通過 e = tabAt(tab, (n - 1) & h)) 獲取到對應的槽點(節點)
  • 如果 table 數組是空的或者該位置爲 null,那麼直接返回 null
  • 如果該節點剛好就是我們需要的節點,直接返回該節點的值
  • 如果這個節點位置正在擴容(eh=-1)或者是紅黑樹的 Root 節點(eh=-2),那麼調用節點的 find 方法繼續查找
  • 否則就遍歷鏈表進行查找

源碼如下:

    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());//計算hash值
        // 如果 table 爲空或者當前節點爲空,直接返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            //獲取當前tab[i]的node
            if ((eh = e.hash) == h) {//如果該節點的hash值和h相等
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;//如果key值相同,返回當前節點的val值
            }
            // eh = -1,該節點爲fwd節點 數組正在擴容
            // eh = -2,該節點爲紅黑樹的Root節點
            else if (eh < 0)//調用該節點的find方法查找
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {//遍歷鏈表
                //在當節點一直向下查找下一個節點,判斷是否有key值相等
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

4.對比Java7 和Java8 的異同和優缺點

4.1 數據結構不同

Java 7 採用 Segment 分段鎖來實現,而 Java 8 中的 ConcurrentHashMap 使用數組 + 鏈表 + 紅黑樹

4.2 併發度

Java 7 中,每個 Segment 獨立加鎖,最大併發個數就是 Segment 的個數,默認是 16。

Java 8 中,鎖粒度更細,理想情況下 table 數組元素的個數(也就是數組長度)就是其支持併發的最大個數,併發度比之前有提高。

4.3 保證併發安全的原理

Java 7 採用 Segment 分段鎖來保證安全,而 Segment 是繼承自 ReentrantLock。

Java 8 中放棄了 Segment 的設計,採用 Node + CAS + synchronized 保證線程安全。

4.4 遇到 Hash 碰撞

Java 7 在 Hash 衝突時,會使用拉鍊法,也就是鏈表的形式。

Java 8 先使用拉鍊法,在鏈表長度超過一定閾值時,將鏈表轉換爲紅黑樹,來提高查找效率。

4.5 查詢時間複雜度

Java 7 遍歷鏈表的時間複雜度是 O(n),n 爲鏈表長度。

Java 8 如果變成遍歷紅黑樹,那麼時間複雜度降低爲 O(logn),n 爲樹的節點個數。

5.參考

  • 《Java 併發編程 78 講》- 徐隆曦
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章