容器是Java編程中使用頻率很高的組件,但Java默認提供的基本容器(ArrayList,HashMap等)均不是線程安全的。當容器和多線程併發編程相遇時,程序員又該何去何從呢?html
一般有兩種選擇:java
一、使用synchronized關鍵字,將對容器的操做有序錯開,確保同一時刻對同一個容器只存在一個操做。Vector,HashTable等封裝後的容器本質也是這種解決思路,只不過synchronized關鍵字不須要咱們來書寫而已。程序員
二、使用java.util.concurrent包下提供的併發容器。好比常見的ConcurrentHashMap、CopyOnWriteArrayList等。編程
第一種選擇的優勢是上手快,簡單直接,易於調試,若是不考慮性能的話,幾乎沒有任何使用場景的限制,能夠保證數據操做的強一致性;那麼它的缺點也是很明顯的,因爲每次對容器的操做都鎖住了整個容器,若是對容器進行高併發的操做,將致使操做性能急劇降低。數組
第二種選擇的優勢是concurrent包下的併發容器一般都作了性能上的高度優化,能保障高併發場景下的操做性能;但缺點是這些容器的背後實現原理相對複雜,並且對使用場景有必定限制,通常只能保證數據操做的弱一致性。安全
本文將重點介紹併發容器背後的典型設計思路與實現原理,讀者瞭解了這些實現思路後,也能夠更好的理解併發容器的使用場景的限制。多線程
關於ConcurrentHashMap的實現原理,在JDK1.8與JDK1.8以前有不一樣的實現,關於它們具體的實現細節網上已經有不少優秀的文章進行介紹,好比:併發
一、《JDK1.7 ConcurrentHashMap原理分析》app
二、《JDK1.8 ConcurrentHashMap原理分析》jvm
三、 《ConcurrentHashMap在JDK1.7與JDK1.8中的對比》
此處便不在贅述了。
本文重點用簡潔易懂的語言帶領讀者快速掌握ConcurrentHashMap在JDK1.8中高併發實現的原理。
首先咱們簡單回顧一下普通HashMap的實現原理。
如上圖所示,咱們將Map中儲存的每個Entry抽象爲一個Node。Node根據其Key值Hash取餘後,映射到Table(一個Node數組)的某一個槽位上進行儲存。若是出現Hash衝突(即兩個Node的Key值Hash取餘結果相同),則以鏈表的形式在出現衝突的Table槽位上繼續追加Node。若是某一個槽位以鏈表的形式儲存了過多的Node(8個以上),則將鏈表轉換爲紅黑樹儲存,避免查詢Node時對長鏈表的遍歷,以下降查詢Node的時間複雜度。當Map中容納的Node總數大於Table長度乘以加載因子factor(默認0.75)時,Map會將Table成倍擴容,以減小Hash衝突的機率。
傳統的HashTable之因此併發性能不好,緣由在於鎖的範圍過大,更新任何一個數據,都要將全Map鎖住。
其實中HashMap的實現原理不難看出,HashMap自己自然就呈現出邊界清晰的分段儲存特性,即每個Table中的一個槽位,便可認爲是一個儲存段。那麼,若是咱們將鎖的精度精確到每個儲存段,就能夠實現更新每個數據,只會對與該數據相關的局部數據段加鎖。而每一個儲存段的頭結點,便可做爲加鎖對象。
JDK1.8中的核心源碼以下:
Node<K,V> f; f = tabAt(tab, i = (n - 1) & hash); //取出Tab指定槽中的頭結點 synchronized (f) { //對這個頭結點加鎖 //... ... }
若是某個槽位中尚不存在任何頭結點(即頭結點爲null),此時咱們不能對null進行加鎖,又如何規避該槽位首次插入Node時可能遭遇的併發衝突呢?
可使用CAS(Compare And Swap(Set))進行Node的首次插入。CAS的核心原理是更新某個數據前,檢查該數據的值是否仍是以前獲取獲得的舊值,若是是則說明該值尚未被其餘線程修改,能夠直接修改成新值,不然則說明該值已經被其餘線程修改了,則設置失敗。檢查舊值是否被修改與設置新值這兩步操做由CPU提供的單指令直接完成,保證原子性。
使用CAS技術加上CAS失敗後的不斷重試,便可實現無鎖化更新數據。畢竟CAS失敗的機率很低,不斷重試也不會佔用過多CPU。(樂觀鎖與自旋鎖的理念)
JDK1.8中的核心源碼以下:
for (Node<K, V>[] tab = table; ; ) { if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K, V>(hash, key, value, null))) break; //CAS失敗則跳出循環,開始下一次循環,從新讀取頭結點 } }
ConcurrentHashMap的讀操做都是不加鎖的。能夠保證的是,讀取某一個指定key的值時能夠讀取到最近一次更新完成的結果。更標準的說法是,上一次對keyA的更新結果happens-before後續對keyA的讀取操做。
注:happens-before是jvm用來定義兩個action之間(acitonA和actionB)的偏序關係,從而明確在CPU容許重排序的狀況下,actionA發生的結果是必定要對後續發生的actionB可見的。
因爲讀操做不加鎖,讀操做可能會與其餘線程的寫操做重疊,ConcurrentHashMap可能會讀取到其餘線程寫操做的中間狀態。好比putAll在執行過程當中有併發的get操做,那麼get操做可能只會讀取到插入的部分數據,同時併發的size操做的返回結果也是不許確的,只可用於估算類業務,不可用於精準的控制流程判斷。再好比使用迭代器遍歷Map時,另一個線程正在刪除Map,那麼在讀取過程當中碰巧尚未被刪除的數據會被讀取到,而已經被刪除的數據不會被讀取到(不會拋出ConcurrentModificationException)。
所謂寫時複製,即任何要改變CopyOnWriteArrayList的操做(add、set等),其內部實現都是深拷貝一份CopyOnWriteArrayList的底層數組,而後在深拷貝的副本上進行數據的修改。修改完成後,再用新的副本與替換原來的CopyOnWriteArrayList底層數組。
JDK1.8中的核心代碼以下:
public boolean add(E e) { final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); int len = elements.length; Object[] newElements = Arrays.copyOf(elements, len + 1); //深拷貝底層數組 newElements[len] = e; //在副本上進行修改 setArray(newElements); //修改完成後用副本替換底層數組 return true; } finally { lock.unlock(); } }
寫時複製的好處是,任何的讀操做都不用加鎖,並且保證讀取到的是讀那一刻List完整的快照數據。好比當CopyOnWriteArrayList的迭代器建立後,不管List自己如何變化,迭代器能感知到的都是它在被建立那一刻時List的狀態,任何其餘線程對List的改變,對本迭代器都不可見。不會出現ConcurrentHashMap的迭代器可能讀取到其餘線程修改過程當中容器的中間狀態的狀況。因爲CopyOnWriteArrayList讀操做沒法感知最新正在變化的數據,因此CopyOnWriteArrayList也是弱一致性的。
CopyOnWriteArrayList能夠保證的是,讀操做能夠讀取到最近一次更新完成的結果。
寫時複製技術由於每次修改都須要完整拷貝一次底層數組,因此有額外的性能開銷,可是特別適用於讀多寫少的數據訪問場景。
一、ConcurrentHashMap和CopyOnWriteArrayList都是無鎖化的讀取,因此讀操做發生時沒法確保目前全部其餘線程的寫操做已經完成,不可用於要求數據強一致性的場景。
二、ConcurrentHashMap和CopyOnWriteArrayList均可以保證讀取時能夠感知到已經完成的寫操做。
三、ConcurrentHashMap讀操做可能會感知到同一時刻其餘線程對容器寫操做的中間狀態。CopyOnWriteArrayList永遠只會讀取到容器在讀取時刻的快照狀態。
四、ConcurrentHashMap使用鎖分段技術,縮小鎖的範圍,提升寫的併發量。CopyOnWriteArrayList使用寫時複製技術,保證併發寫入數據時,不會對已經開啓的讀操做形成干擾。
五、ConcurrentHashMap適用於高併發下對數據訪問沒有強一致性需求的場景。CopyOnWriteArrayList適用於高併發下可以容忍只讀取到歷史快照數據,且讀多寫少的場景。