在上一章中咱們講到在高併發下,使用hashMap會致使一系列的問題。那麼咱們當咱們須要使用相似於hashMap那樣的存儲集合類的時候,咱們該怎麼作呢?java
Java的集合容器框架中,主要有四大類別:List、Set、Queue、Map,你們熟知的這些集合類ArrayList、LinkedList、HashMap這些容器都是非線程安全的。node
若是有多個線程併發地訪問這些容器時,就會出現問題。所以,在編寫程序時,在多線程環境下必需要求程序員手動地在任何訪問到這些容器的地方進行同步處理,這樣致使在使用這些容器的時候很是地不方便。程序員
併發類容器是專門針對多線程併發設計的,使用了鎖分段技術,只對操做的位置進行同步操做,可是其餘沒有操做的位置其餘線程仍然能夠訪問,提升了程序的吞吐量。算法
採用了CAS算法和部分代碼使用synchronized鎖保證線程安全。編程
ConcurrentHashMap是由Segment數組結構和HashEntry數組結構組成。Segment是一種可重入鎖(ReentrantLock),在ConcurrentHashMap裏扮演鎖的角色;HashEntry則用於存儲鍵值對數據。一個ConcurrentHashMap裏包含一個Segment數組。Segment的結構和HashMap相似,是一種數組和鏈表結構。一個Segment裏包含一個HashEntry數組,每一個HashEntry是一個鏈表結構的元素,每一個Segment守護着一個HashEntry數組裏的元素,當對HashEntry數組的數據進行修改時,必須首先得到與它對應的Segment鎖。數據結構以下數組
ConcurrentHashMap初始化方法是經過initialCapacity、loadFactor和concurrencyLevel(參數concurrencyLevel是用戶估計的併發級別,就是說你以爲最多有多少線程共同修改這個map,根據這個來肯定Segment數組的大小concurrencyLevel默認是DEFAULT_CONCURRENCY_LEVEL = 16;)等幾個參數來初始化segment數組、段偏移量segmentShift、段掩碼segmentMask和每一個segment裏的HashEntry數組來實現的。安全
併發級別能夠理解爲程序運行時可以同時更新ConccurentHashMap且不產生鎖競爭的最大線程數,實際上就是ConcurrentHashMap中的分段鎖個數,即Segment[]的數組長度。ConcurrentHashMap默認的併發度爲16,但用戶也能夠在構造函數中設置併發度。當用戶設置併發度時,ConcurrentHashMap會使用大於等於該值的最小2冪指數做爲實際併發度(假如用戶設置併發度爲17,實際併發度則爲32)。微信
若是併發度設置的太小,會帶來嚴重的鎖競爭問題;若是併發度設置的過大,本來位於同一個Segment內的訪問會擴散到不一樣的Segment中,CPU cache命中率會降低,從而引發程序性能降低。(文檔的說法是根據你併發的線程數量決定,太多會導性能下降)數據結構
segments數組的長度ssize是經過concurrencyLevel計算得出的。爲了能經過按位與的散列算法來定位segments數組的索引,必須保證segments數組的長度是2的N次方(power-of-two size),因此必須計算出一個大於或等於concurrencyLevel的最小的2的N次方值來做爲segments數組的長度。假如concurrencyLevel等於1四、15或16,ssize都會等於16,即容器裏鎖的個數也是16。多線程
輸入參數initialCapacity是ConcurrentHashMap的初始化容量,loadfactor是每一個segment的負載因子,在構造方法裏須要經過這兩個參數來初始化數組中的每一個segment。上面代碼中的變量cap就是segment裏HashEntry數組的長度,它等於initialCapacity除以ssize的倍數c,若是c大於1,就會取大於等於c的2的N次方值,因此segment裏HashEntry數組的長度不是1,就是2的N次方。
在默認狀況下, ssize = 16,initialCapacity = 16,loadFactor = 0.75f,那麼cap = 1,threshold = (int) cap * loadFactor = 0。
既然ConcurrentHashMap使用分段鎖Segment來保護不一樣段的數據,那麼在插入和獲取元素的時候,必須先經過散列算法定位到Segment。ConcurrentHashMap會首先使用Wang/Jenkins hash的變種算法對元素的hashCode進行一次再散列。
ConcurrentHashMap徹底容許多個讀操做併發進行,讀操做並不須要加鎖。ConcurrentHashMap實現技術是保證HashEntry幾乎是不可變的以及volatile關鍵字。
get操做先通過一次再散列,而後使用這個散列值經過散列運算定位到Segment(使用了散列值的高位部分),再經過散列算法定位到table(使用了散列值的所有)。整個get過程,沒有加鎖,而是經過volatile保證get老是能夠拿到最新值。
ConcurrentHashMap 初始化的時候會初始化第一個槽 segment[0],對於其餘槽,在插入第一個值的時候再進行初始化。
ensureSegment方法考慮了併發狀況,多個線程同時進入初始化同一個槽 segment[k],但只要有一個成功就能夠了。
具體實現是
put方法會經過tryLock()方法嘗試得到鎖,得到了鎖,node爲null進入try語句塊,沒有得到鎖,調用scanAndLockForPut方法自旋等待得到鎖。
scanAndLockForPut方法裏在嘗試得到鎖的過程當中會對對應hashcode的鏈表進行遍歷,若是遍歷完畢仍然找不到與key相同的HashEntry節點,則爲後續的put操做提早建立一個HashEntry。當tryLock必定次數後仍沒法得到鎖,則經過lock申請鎖。
在得到鎖以後,Segment對鏈表進行遍歷,若是某個HashEntry節點具備相同的key,則更新該HashEntry的value值,
不然新建一個HashEntry節點,採用頭插法,將它設置爲鏈表的新head節點並將原頭節點設爲新head的下一個節點。新建過程當中若是節點總數(含新建的HashEntry)超過threshold,則調用rehash()方法對Segment進行擴容,最後將新建HashEntry寫入到數組中。
擴容是新建立了數組,而後進行遷移數據,最後再將 newTable 設置給屬性 table。
爲了不讓全部的節點都進行復制操做:因爲擴容是基於2的冪指來操做,假設擴容前某HashEntry對應到Segment中數組的index爲i,數組的容量爲capacity,那麼擴容後該HashEntry對應到新數組中的index只可能爲i或者i+capacity,所以不少HashEntry節點在擴容先後index能夠保持不變。
假設原來table長度爲4,那麼元素在table中的分佈是這樣的
擴容後table長度變爲8,那麼元素在table中的分佈變成:
能夠看見 hash值爲34和56的下標保持不變,而15,23,77的下標都是在原來下標的基礎上+4便可,能夠快速定位和減小重排次數。
該方法沒有考慮併發,由於執行該方法以前已經獲取了鎖。
與put方法相似,都是在操做前須要拿到鎖,以保證操做的線程安全性。
而後對鏈表遍歷判斷是否存在key相同的節點以及得到該節點的value。但因爲遍歷過程當中其餘線程可能對鏈表結構作了調整,所以get和containsKey返回的多是過期的數據,這一點是ConcurrentHashMap在弱一致性上的體現。若是要求強一致性,那麼必須使用Collections.synchronizedMap()方法。
這些方法都是基於整個ConcurrentHashMap來進行操做的,他們的原理也基本相似:首先不加鎖循環執行如下操做:循環全部的Segment,得到對應的值以及全部Segment的modcount之和。在 put、remove 和 clean 方法裏操做元素前都會將變量 modCount 進行變更,若是連續兩次全部Segment的modcount和相等,則過程當中沒有發生其餘線程修改ConcurrentHashMap的狀況,返回得到的值。
當循環次數超過預約義的值時,這時須要對全部的Segment依次進行加鎖,獲取返回值後再依次解鎖。因此通常來講,應該避免在多線程環境下使用size和containsValue方法。
改進一:取消segments字段,直接採用transient volatile HashEntry<K,V>[] table保存數據,採用table數組元素做爲鎖,從而實現了對縮小鎖的粒度,進一步減小併發衝突的機率,並大量使用了採用了 CAS + synchronized 來保證併發安全性。
改進二:將原先table數組+單向鏈表的數據結構,變動爲table數組+單向鏈表+紅黑樹的結構。對於hash表來講,最核心的能力在於將key hash以後能均勻的分佈在數組中。若是hash以後散列的很均勻,那麼table數組中的每一個隊列長度主要爲0或者1。但實際狀況並不是老是如此理想,雖然ConcurrentHashMap類默認的加載因子爲0.75,可是在數據量過大或者運氣不佳的狀況下,仍是會存在一些隊列長度過長的狀況,若是仍是採用單向列表方式,那麼查詢某個節點的時間複雜度爲O(n);所以,對於個數超過8(默認值)的列表,jdk1.8中採用了紅黑樹的結構,那麼查詢的時間複雜度能夠下降到O(logN),能夠改進性能。
使用 Node(1.7 爲 Entry) 做爲鏈表的數據結點,仍然包含 key,value,hash 和 next 四個屬性。 紅黑樹的狀況使用的是 TreeNode(extends Node)。
根據數組元素中,第一個結點數據類型是 Node 仍是 TreeNode 能夠判斷該位置下是鏈表仍是紅黑樹。
用於判斷是否須要將鏈表轉換爲紅黑樹的閾值
用於判斷是否須要將紅黑樹轉換爲鏈表的閾值
Node
Node是最核心的內部類,它包裝了key-value鍵值對。
定義基本和1.7中的HashEntry相同。而這個map自己所持有的也是一個Node型的數組
增長了一個find方法來用以輔助map.get()方法。其實就是遍歷鏈表,子類中會覆蓋這個方法。
在map中還定義了Segment這個類,不過只是爲了向前兼容而已,不作過多考慮。
TreeNode
樹節點類,另一個核心的數據結構。當鏈表長度過長的時候,會轉換爲TreeNode。
與1.8中HashMap不一樣點:
一、它並非直接轉換爲紅黑樹,而是把這些結點放在TreeBin對象中,由TreeBin完成對紅黑樹的包裝。
二、TreeNode在ConcurrentHashMap擴展自Node類,而並不是HashMap中的擴展自LinkedHashMap.Entry<K,V>類,也就是說TreeNode帶有next指針。
TreeBin
負責TreeNode節點。它代替了TreeNode的根節點,也就是說在實際的ConcurrentHashMap「數組」中,存放的是TreeBin對象,而不是TreeNode對象。另外這個類還帶有了讀寫鎖機制。
特殊的ForwardingNode
一個特殊的 Node 結點,hash 值爲 -1,其中存儲 nextTable 的引用。有 table 發生擴容的時候,ForwardingNode 發揮做用,做爲一個佔位符放在 table 中表示當前結點爲 null 或者已經被移動。
sizeCtl
用來控制 table 的初始化和擴容操做。
負數表明正在進行初始化或擴容操做
0爲默認值,表明當時的table尚未被初始化
正數表示初始化大小或Map中的元素達到這個數量時,須要進行擴容了。
能夠發現,在new出一個map的實例時,並不會建立其中的數組等等相關的部件,只是進行簡單的屬性設置而已,一樣的,table的大小也被規定爲必須是2的乘方數。
真正的初始化在放在了是在向ConcurrentHashMap中插入元素的時候發生的。如調用put、computeIfAbsent、compute、merge等方法的時候,調用時機是檢查table==null。
get方法比較簡單,給定一個key來肯定value的時候,必須知足兩個條件 key相同 hash值相同,對於節點可能在鏈表或樹上的狀況,須要分別去查找。
總結來講,put方法就是,沿用HashMap的put方法的思想,根據hash值計算這個新插入的點在table中的位置i,若是i位置是空的,直接放進去,不然進行判斷,若是i位置是樹節點,按照樹的方式插入新的節點,不然把i插入到鏈表的末尾。
總體流程上,就是首先定義不容許key或value爲null的狀況放入 對於每個放入的值,首先利用spread方法對key的hashcode進行一次hash計算,由此來肯定這個值在table中的位置。
若是這個位置是空的,那麼直接放入,並且不須要加鎖操做。
若是這個位置存在結點,說明發生了hash碰撞,首先判斷這個節點的類型。若是是鏈表節點,則獲得的結點就是hash值相同的節點組成的鏈表的頭節點。須要依次向後遍歷肯定這個新加入的值所在位置。若是遇到hash值與key值都與新加入節點是一致的狀況,則只須要更新value值便可。不然依次向後遍歷,直到鏈表尾插入這個結點。若是加入這個節點之後鏈表長度大於8,就把這個鏈表轉換成紅黑樹。若是這個節點的類型已是樹節點的話,直接調用樹節點的插入方法進行插入新的值。
前面說過,構造方法中並無真正初始化,真正的初始化在放在了是在向ConcurrentHashMap中插入元素的時候發生的。具體實現的方法就是initTable
當ConcurrentHashMap容量不足的時候,須要對table進行擴容。這個方法的基本思想跟HashMap是很像的,可是因爲它是支持併發擴容的,因此要複雜的多。咱們不深刻源碼去講述,只講述其大概原理。
爲什麼要併發擴容?由於在擴容的時候,老是會涉及到從一個「數組」到另外一個「數組」拷貝的操做,若是這個操做可以併發進行,就能利用併發處理去減小擴容帶來的時間影響。
整個擴容操做分爲兩個部分:
第一部分是構建一個nextTable,它的容量是原來的2倍。
第二個部分就是將原來table中的元素複製到nextTable中,這裏容許多線程進行操做。
整個擴容流程就是遍歷和複製:
爲null或者已經處理過的節點,會被設置爲forwardNode節點,當線程準備擴容時,發現節點是forwardNode節點,跳過這個節點,繼續尋找未處理的節點,找到了,對節點上鎖,
若是這個位置是Node節點(fh>=0),說明它是一個鏈表,就構造一個反序鏈表,把他們分別放在nextTable的i和i+n的位置上
若是這個位置是TreeBin節點(fh<0),也作一個反序處理,而且判斷是否須要紅黑樹轉鏈表,把處理的結果分別放在nextTable的i和i+n的位置上
遍歷過全部的節點之後就完成了複製工做,這時讓nextTable做爲新的table,而且更新sizeCtl爲新容量的0.75倍 ,完成擴容。
併發擴容其實就是將數據遷移任務拆分紅多個小遷移任務,在實現上使用了一個變量stride做爲步長控制,每一個線程每次負責遷移其中的一部分。
移除方法的基本流程和put方法很相似,只不過操做由插入數據變爲移除數據而已,並且若是存在紅黑樹的狀況下,會檢查是否須要將紅黑樹轉爲鏈表的步驟。再也不重複講述。
用於將過長的鏈表轉換爲TreeBin對象。可是他並非直接轉換,而是進行一次容量判斷,若是容量沒有達到轉換的要求,直接進行擴容操做並返回;若是知足條件纔將鏈表的結構轉換爲TreeBin ,這與HashMap不一樣的是,它並無把TreeNode直接放入紅黑樹,而是利用了TreeBin這個小容器來封裝全部的TreeNode。
在JDK1.8版本中,對於size的計算,在擴容和addCount()方法就已經有處理了,能夠注意一下Put函數,裏面就有addCount()函數,早就計算好的,而後你size的時候直接給你。JDK1.7是在調用size()方法纔去計算,其實在併發集合中去計算size是沒有多大的意義的,由於size是實時在變的。
在具體實現上,計算大小的核心方法都是 sumCount()
能夠看見,統計數量時使用了 baseCount、和CounterCell 類型的變量counterCells 。其實baseCount就是記錄容器數量的,而counterCells則是記錄CAS更新baseCounter值時,因爲高併發而致使失敗的值。這兩個變量的變化在addCount() 方法中有體現,大體的流程就是:
一、對 baseCount 作 CAS 自增操做。
二、若是併發致使 baseCount CAS 失敗了,則使用 counterCells。
三、若是counterCells CAS 失敗了,在 fullAddCount 方法中,會繼續死循環操做,直到成功。
HashTable容器你點進去會發現,純粹是hashMaP方法中添加了synchronized,來保證線程安全。在線程競爭激烈的狀況下HashTable的效率很是低下。由於當一個線程訪問HashTable的同步方法,其餘線程也訪問HashTable的同步方法時,會進入阻塞或輪詢狀態。如線程1使用put進行元素添加,線程2不但不能使用put方法添加元素,也不能使用get方法來獲取元素,因此競爭越激烈效率越低。
Q:HashMap 和 HashTable 有什麼區別?
A:①、HashMap 是線程不安全的,HashTable 是線程安全的;
②、因爲線程安全,因此 HashTable 的效率比不上 HashMap;
③、HashMap最多隻容許一條記錄的鍵爲null,容許多條記錄的值爲null,而 HashTable 不容許;
④、HashMap 默認初始化數組的大小爲16,HashTable 爲 11,前者擴容時,擴大兩倍,後者擴大兩倍+1;
⑤、HashMap 須要從新計算 hash 值,而 HashTable 直接使用對象的 hashCode
Q:Java 中的另外一個線程安全的與 HashMap 極其相似的類是什麼?一樣是線程安全,它與 HashTable 在線程同步上有什麼不一樣?
A:ConcurrentHashMap 類(是 Java併發包 java.util.concurrent 中提供的一個線程安全且高效的 HashMap 實現)。
HashTable 是使用 synchronize 關鍵字加鎖的原理(就是對對象加鎖);
而針對 ConcurrentHashMap,在 JDK 1.7 中採用分段鎖的方式;JDK 1.8 中直接採用了CAS(無鎖算法)+ synchronized,也採用分段鎖的方式並大大縮小了鎖的粒度。
HashMap & ConcurrentHashMap 的區別?
A:除了加鎖,原理上無太大區別。
另外,HashMap 的鍵值對容許有null,可是ConCurrentHashMap 都不容許。
在數據結構上,紅黑樹相關的節點類
Q:爲何 ConcurrentHashMap 比 HashTable 效率要高?
A:HashTable 使用一把鎖(鎖住整個鏈表結構)處理併發問題,多個線程競爭一把鎖,容易阻塞;
ConcurrentHashMap
JDK 1.7 中使用分段鎖(ReentrantLock + Segment + HashEntry),至關於把一個 HashMap 分紅多個段,每段分配一把鎖,這樣支持多線程訪問。鎖粒度:基於 Segment,包含多個 HashEntry。
JDK 1.8 中使用 CAS + synchronized + Node + 紅黑樹。鎖粒度:Node(首結點)(實現 Map.Entry<K,V>)。鎖粒度下降了。
Q:針對 ConcurrentHashMap 鎖機制具體分析(JDK 1.7 VS JDK 1.8)?
JDK 1.7 中,採用分段鎖的機制,實現併發的更新操做,底層採用數組+鏈表的存儲結構,包括兩個核心靜態內部類 Segment 和 HashEntry。
①、Segment 繼承 ReentrantLock(重入鎖) 用來充當鎖的角色,每一個 Segment 對象守護每一個散列映射表的若干個桶;
②、HashEntry 用來封裝映射表的鍵-值對;
③、每一個桶是由若干個 HashEntry 對象連接起來的鏈表。
JDK 1.8 中,採用Node + CAS + Synchronized來保證併發安全。取消類 Segment,直接用 table 數組存儲鍵值對;當 HashEntry 對象組成的鏈表長度超過 TREEIFY_THRESHOLD 時,鏈表轉換爲紅黑樹,提高性能。底層變動爲數組 + 鏈表 + 紅黑樹。
Q:ConcurrentHashMap 在 JDK 1.8 中,爲何要使用內置鎖 synchronized 來代替重入鎖 ReentrantLock?
A:
一、JVM 開發團隊在1.8中對 synchronized作了大量性能上的優化,並且基於 JVM 的 synchronized 優化空間更大,更加天然。
二、在大量的數據操做下,對於 JVM 的內存壓力,基於 API 的 ReentrantLock 會開銷更多的內存。
Q:ConcurrentHashMap 簡單介紹?
A:
①、重要的常量:
private transient volatile int sizeCtl;
當爲負數時,-1 表示正在初始化,-N 表示 N - 1 個線程正在進行擴容;
當爲 0 時,表示 table 尚未初始化;
當爲其餘正數時,表示初始化或者下一次進行擴容的大小。
②、數據結構:
Node 是存儲結構的基本單元,繼承 HashMap 中的 Entry,用於存儲數據;
TreeNode 繼承 Node,可是數據結構換成了二叉樹結構,是紅黑樹的存儲結構,用於紅黑樹中存儲數據;
TreeBin 是封裝 TreeNode 的容器,提供轉換紅黑樹的一些條件和鎖的控制。
③、存儲對象時(put() 方法):
1.若是沒有初始化,就調用 initTable() 方法來進行初始化;
2.若是沒有 hash 衝突就直接 CAS 無鎖插入;
3.若是須要擴容,就先進行擴容;
4.若是存在 hash 衝突,就加鎖來保證線程安全,兩種狀況:一種是鏈表形式就直接遍歷到尾端插入,一種是紅黑樹就按照紅黑樹結構插入;
5.若是該鏈表的數量大於閥值 8,就要先轉換成紅黑樹的結構,break 再一次進入循環
6.若是添加成功就調用 addCount() 方法統計 size,而且檢查是否須要擴容。
④、擴容方法 transfer():默認容量爲 16,擴容時,容量變爲原來的兩倍。
helpTransfer():調用多個工做線程一塊兒幫助進行擴容,這樣的效率就會更高。
⑤、獲取對象時(get()方法):
1.計算 hash 值,定位到該 table 索引位置,若是是首結點符合就返回;
2.若是遇到擴容時,會調用標記正在擴容結點 ForwardingNode.find()方法,查找該結點,匹配就返回;
3.以上都不符合的話,就往下遍歷結點,匹配就返回,不然最後就返回 null。
Q:ConcurrentHashMap 的併發度是什麼?
A:1.7中程序運行時可以同時更新 ConccurentHashMap 且不產生鎖競爭的最大線程數。默認爲 16,且能夠在構造函數中設置。當用戶設置併發度時,ConcurrentHashMap 會使用大於等於該值的最小2冪指數做爲實際併發度(假如用戶設置併發度爲17,實際併發度則爲32)。
1.8中併發度則無太大的實際意義了,主要用處就是當設置的初始容量小於併發度,將初始容量提高至併發度大小。
5、總結
本章主要介紹ConcurrentHashMap的源碼以及與HashMap的區別。以及爲何平時咱們都不用HashTable。但願你們能夠經過本章學習到分段表的設計思想以及以及前面學習到的cas操做的應用,瞭解容器和它的方法,以及懂得在合適場景選擇最合適的容器和方法。
——————————————————————分割線————————————————————————————
下一節將給你們介紹下其餘的併發容器,同時也是併發專題的最後一篇。後續若是有了解到新的知識點會繼續更新相關專題。以及對之前老的博客進行更新,使其更加細緻。讓每一個開發者都能看懂。
其餘閱讀 併發編程專題
你們有問題能夠加我微信哈~