ConcurrentHashMap 在 Java7 和 java8 有何不同?
在 Java8 中,對於 ConcurrentHashMap 這個常用的工具類進行了很大的升級,對比之前 Java7 版本在諸多方面都進行了調整和變化。
不過,在 Java7 中的 Segment 的設計思想依然具有參考和學習的價值,所以在很多情況下面試官都會問你:
ConcurrentHashMap 在 Java 7 和 Java8 中的結構分別是什麼?
它們有什麼相同點和不同點?
Java 7 版本的 ConcurrentHashMap
我們首先來看一下 Java7 版本中的 ConcurrentHashMap 的結構示意圖:
從圖中我們可以看出,在 ConcurrentHashMap 內部進行了 Segment
相比於之前的 Hashtable 每次操作都需要把整個物件鎖住而言,大大提高了併發效率。因為它的鎖與鎖之間是獨立的,而不是整個物件只有一把鎖。
每個 Segment 的底層資料結構與 HashMap 類似,仍然是陣列和連結串列組成的拉鍊法結構。預設有 0~15 共 16 個 Segment,所以最多可以同時支援 16 個執行緒併發操作(操作分別分佈在不同的 Segment 上)。
16 這個預設值可以在初始化的時候設定為其他值,但是一旦確認初始化以後,是不可以擴容的。
Java 8 版本的 ConcurrentHashMap
在 Java 8 中,幾乎完全重寫了 ConcurrentHashMap,程式碼量從原來 Java 7 中的 1000 多行,變成了現在的 6000 多行,所以也大大提高了原始碼的閱讀難度。
而為了方便我們理解,我們還是先從整體的結構示意圖出發,看一看總體的設計思路,然後再去深入細節。
圖中的節點有3種類型。
- 第一種是最簡單的,空著的位置代表當前還沒有元素來填充。
- 第二種就是和 HashMap 非常類似的拉鍊法結構,在每一個槽中會首先填入第一個節點,但是後續如果計算出相同的 Hash 值,就用連結串列的形式往後進行延伸。
- 第三種結構就是紅黑樹結構,這是 Java 7 的 ConcurrentHashMap 中所沒有的結構,在此之前我們可能也很少接觸這樣的資料結構。
當第二種情況的連結串列長度大於某一個閾值(預設為 8),且同時滿足一定的容量要求的時候,ConcurrentHashMap 便會把這個連結串列從連結串列的形式轉化為紅黑樹的形式,目的是進一步提高它的查詢效能。所以,Java 8 的一個重要變化就是引入了紅黑樹的設計,由於紅黑樹並不是一種常見的資料結構,所以我們在此簡要介紹一下紅黑樹的特點。
紅黑樹是每個節點都帶有顏色屬性的二叉查詢樹,顏色為紅色或黑色,紅黑樹的本質是對二叉查詢樹 BST 的一種平衡策略,我們可以理解為是一種平衡二叉查詢樹,查詢效率高,會自動平衡,防止極端不平衡從而影響查詢效率的情況發生。
由於自平衡的特點,即左右子樹高度幾乎一致,所以其查詢效能近似於二分查詢,時間複雜度是 O(log(n)) 級別;反觀連結串列,它的時間複雜度就不一樣了,如果發生了最壞的情況,可能需要遍歷整個連結串列才能找到目標元素,時間複雜度為 O(n),遠遠大於紅黑樹的 O(log(n)),尤其是在節點越來越多的情況下,O(log(n)) 體現出的優勢會更加明顯。
紅黑樹的一些其他特點:
- 每個節點要麼是紅色,要麼是黑色,但根節點永遠是黑色的。
- 紅色節點不能連續,也就是說,紅色節點的子和父都不能是紅色的。
- 從任一節點到其每個葉子節點的路徑都包含相同數量的黑色節點。
正是由於這些規則和要求的限制,紅黑樹保證了較高的查詢效率,所以現在就可以理解為什麼 Java 8 的 ConcurrentHashMap 要引入紅黑樹了。
好處就是避免在極端的情況下衝突連結串列變得很長,在查詢的時候,效率會非常慢。而紅黑樹具有自平衡的特點,所以,即便是極端情況下,也可以保證查詢效率在 O(log(n))。
分析 Java 8 版本的 ConcurrentHashMap 的重要原始碼
-
Node 節點
我們先來看看最基礎的內部儲存結構 Node,這就是一個一個的節點,如這段程式碼所示:
可以看出,每個 Node 裡面是 key-value 的形式,並且把 value 用 volatile 修飾,以便保證可見性,同時內部還有一個指向下一個節點的 next 指標,方便產生連結串列結構。
下面我們看兩個最重要、最核心的方法。
-
put 方法原始碼分析
put 方法的核心是 putVal 方法,為了方便閱讀,我把重要步驟的解讀用註釋的形式補充在下面的原始碼中。我們逐步分析這個最重要的方法,這個方法相對有些長,我們一步一步把它看清楚。
finalVputVal(Kkey,Vvalue,booleanonlyIfAbsent){ if(key==null||value==null){ thrownewNullPointerException(); } //計算hash值 inthash=spread(key.hashCode()); intbinCount=0; for(Node<K,V>[]tab=table;;){ Node<K,V>f; intn,i,fh; //如果陣列是空的,就進行初始化 if(tab==null||(n=tab.length)==0){ tab=initTable(); } //找該hash值對應的陣列下標 elseif((f=tabAt(tab,i=(n-1)&hash))==null){ //如果該位置是空的,就用CAS的方式放入新值 if(casTabAt(tab,i,null, newNode<K,V>(hash,key,value,null))){ break; } } //hash值等於MOVED代表在擴容 elseif((fh=f.hash)==MOVED){ tab=helpTransfer(tab,f); } //槽點上是有值的情況 else{ VoldVal=null; //用synchronized鎖住當前槽點,保證併發安全 synchronized(f){ if(tabAt(tab,i)==f){ //如果是連結串列的形式 if(fh>=0){ binCount=1; //遍歷連結串列 for(Node<K,V>e=f;;++binCount){ Kek; //如果發現該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=newNode<K,V>(hash,key, value,null); break; } } } //如果是紅黑樹的形式 elseif(finstanceofTreeBin){ Node<K,V>p; binCount=2; //呼叫putTreeVal方法往紅黑樹裡增加資料 if((p=((TreeBin<K,V>)f).putTreeVal(hash,key, value))!=null){ oldVal=p.val; if(!onlyIfAbsent){ p.val=value; } } } } } if(binCount!=0){ //檢查是否滿足條件並把連結串列轉換為紅黑樹的形式,預設的TREEIFY_THRESHOLD閾值是8 if(binCount>=TREEIFY_THRESHOLD){ treeifyBin(tab,i); } //putVal的返回是新增前的舊值,所以返回oldVal if(oldVal!=null){ returnoldVal; } break; } } } addCount(1L,binCount); returnnull; }
通過以上的原始碼分析,我們對於 putVal 方法有了詳細的認識,可以看出,方法中會逐步根據當前槽點是未初始化、空、擴容、連結串列、紅黑樹等不同情況做出不同的處理。
-
get 方法原始碼分析
get 方法比較簡單,我們同樣用原始碼註釋的方式來分析一下:
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; //計算 hash 值 int h = spread(key.hashCode()); //如果整個陣列是空的,或者當前槽點的資料是空的,說明 key 對應的 value 不存在,直接返回 null if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { //判斷頭結點是否就是我們需要的節點,如果是則直接返回 if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } //如果頭結點 hash 值小於 0,說明是紅黑樹或者正在擴容,就用對應的 find 方法來查詢 else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; //遍歷連結串列來查詢 while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }
總結一下 get 的過程:
- 計算 Hash 值,並由此值找到對應的槽點;
- 如果陣列是空的或者該位置為 null,那麼直接返回 null 就可以了;
- 如果該位置處的節點剛好就是我們需要的,直接返回該節點的值;
- 如果該位置節點是紅黑樹或者正在擴容,就用 find 方法繼續查詢;
- 否則那就是連結串列,就進行遍歷連結串列查詢。
對比Java7 和Java8 的異同和優缺點
資料結構
Java 7 採用 Segment 分段鎖來實現,
而 Java 8 中的 ConcurrentHashMap 使用陣列 + 連結串列 + 紅黑樹,在這一點上它們的差別非常大。
併發度
Java 7 中,每個 Segment 獨立加鎖,最大併發個數就是 Segment 的個數,預設是 16。
但是到了 Java 8 中,鎖粒度更細,理想情況下 table 陣列元素的個數(也就是陣列長度)就是其支援併發的最大個數,併發度比之前有提高。
保證併發安全的原理
Java 7 採用 Segment 分段鎖來保證安全,而 Segment 是繼承自 ReentrantLock。
Java8 中放棄了 Segment 的設計,採用 Node + CAS + synchronized 保證執行緒安全。
遇到 Hash 碰撞
Java 7 在 Hash 衝突時,會使用拉鍊法,也就是連結串列的形式。
Java8 先使用拉鍊法,在連結串列長度超過一定閾值時,將連結串列轉換為紅黑樹,來提高查詢效率。
查詢時間複雜度
Java 7 遍歷連結串列的時間複雜度是 O(n),n 為連結串列長度。
Java8 如果變成遍歷紅黑樹,那麼時間複雜度降低為 O(log(n)),n 為樹的節點個數。