併發讀寫數據一致性保證(一)Java併發容器

業務開發過程,其實就是用戶業務數據的處理過程,於是開發的核心任務就是維護數據一致不出錯。現實場景中,多個用戶會併發讀寫同一份數據(如秒殺),不加控制會翻車、加了控制則下降併發度,影響性能和用戶體驗。java

如何優雅的進行併發數據控制呢?本質上須要解決兩個問題:數組

  • 讀-寫衝突
  • 寫-寫衝突

讓咱們看下Java經典的併發容器CopyOnWriteList以及ConcurrentHashMap是如何協調這兩個問題的數據結構

CopyOnWriteList

流程示意圖

讀寫策略

CopyOnWrite顧名思義即寫時複製策略多線程

針對寫處理,首先加ReentrantLock鎖,而後複製出一份數據副本,對副本進行更改以後,再將數據引用替換爲副本數據,完成後釋放鎖併發

針對讀處理,依賴volatile提供的語義保證,每次讀都能讀到最新的數組引用app

讀-寫衝突

顯然,CopyOnWriteList採用讀寫分離的思想解決併發讀寫的衝突高併發

當讀操做與寫操做同時發生時:post

  • 若是寫操做未完成引用替換,這時讀操做處理的是原數組而寫操做處理的數組副本,互不干擾
  • 若是寫操做已完成引用替換,這時讀操做與寫操做處理的都是同一個數組引用

可見在讀寫分離的設計下,併發讀寫過程當中,讀不必定能實時看到最新的數據,也就是所謂的弱一致性。性能

也正是因爲犧牲了強一致性,可讓讀操做無鎖化,支撐高併發讀學習

寫-寫衝突

當多個寫操做的同時發生時,先拿到鎖的先執行,其餘線程只能阻塞等到鎖的釋放

簡單粗暴又行之有效,但併發性能相對較差

ConcurrentHashMap(JDK7)

讀寫策略

主要採用分段鎖的思想,下降同時操做一份數據的機率

針對讀操做:

  • 先在數組中定位Segment並利用UNSAFE.getObjectVolatile原子讀語義獲取Segment
  • 接着在數組中定位HashEntry並利用UNSAFE.getObjectVolatile原子讀語義獲取HashEntry
  • 而後依賴final不變的next指針遍歷鏈表
  • 找到對應的volatile

針對寫操做:

  • 先在數組中定位Segment並利用UNSAFE.getObjectVolatile原子讀語義獲取Segment
  • 而後嘗試加鎖ReentrantLock
  • 接着在數組中定位HashEntry並利用UNSAFE.getObjectVolatile原子讀語義獲取HashEntry鏈表頭節點
  • 遍歷鏈表,若找到已存在的key,則利用UNSAFE.putOrderedObject原子寫新值,若找不到,則建立一個新的節點,插入到鏈表頭,同時利用UNSAFE.putOrderedObject原子更新鏈表頭
  • 完成操做後釋放鎖

讀-寫衝突

若併發讀寫的數據不位於同一個Segment,操做是相互獨立的

若位於同一個Segment,ConcurrentHashMap利用了不少Java特性來解決讀寫衝突,使得不少讀操做都無鎖化

當讀操做與寫操做同時發生時:

  • 若PUT的KEY已存在,直接更新原有的value,此時讀操做在volatile的保證下能夠讀到最新值,無需加鎖
  • 若PUT的key不存在增長一個節點,或刪除一個節點時,會改變原有的鏈表結構,注意到HashEntry的每一個next指針都是final的,所以得複製鏈表,在更新HashEntry數組元素(即鏈表頭節點)的時候又是經過UNSAFE提供的語義保證來完成更新的,若新鏈表更新前發生讀操做,此時仍是獲取原有的鏈表,無需加鎖,可是數據不是最新的

可見,支持無鎖併發讀操做仍是弱一致的

寫-寫衝突

若併發寫操做的數據不位於同一個Segment,操做是相互獨立的

若位於同一個Segment,多個線程仍是因爲加ReentrantLock鎖致使阻塞等待

ConcurrentHashMap(JDK8)

讀寫策略

與JDK7相比,少了Segment分段鎖這一層,直接操做Node數組(鏈表頭數組),後面稱爲桶

針對讀操做,經過UNSAFE.getObjectVolatile原子讀語義獲取最新的value

針對寫操做,因爲採用懶惰加載的方式,剛初始化時只肯定桶的數量,並無初始默認值。當須要put值的時候先定位下標,而後該下標下桶的值是否爲null,若是是,則經過UNSAFE.comepareAndSwapObject(CAS)賦值,若是不爲null,則加Synchronized鎖,找到對應的鏈表/紅黑樹的節點value進行更改,後釋放鎖

讀-寫衝突

若併發讀寫的數據不位於同一個桶,則相互獨立互不干擾

若位於同一個桶,與JDK7的版本相比,簡單了許多,但仍是基於Java的特性使得許多讀操做無鎖化

當讀操做與寫操做同時發生時:

  • 若PUT的key已經存在,則直接更新值,此時讀操做在volatile的保證下能夠獲取最新值
  • 若PUT的key不存在,會新建一個節點 或 刪除一個節點的時候,會改變對原有的結構,這時next指針是volatile的,直接插入到鏈表尾(超過必定長度變成紅黑樹)等對結構的修改,此時讀操做也是能夠獲取到最新的next

所以只要寫操做happens-before讀操做,volatile語義就能夠保證讀的數據是最新的,能夠說JDK8版本的ConcurrentHashMap是強一致的(此處只關注基本讀寫(GET/PUT),可能會有弱一致的場景遺漏,例如擴容操做,不過應該是全局加鎖的,若有錯誤煩請指出,共同窗習

寫-寫衝突

若併發讀寫的數據不位於同一個桶,則相互獨立互不干擾

若位於同一個桶,注意到寫操做在不一樣的場景下采起不一樣的策略,CAS或Synchronized

當多個寫操做同時發生時,若桶爲null,則CAS應對併發寫,當第一個寫操做賦值成功後,後面的寫線程CAS失敗,轉爲競爭Synchronized鎖,阻塞等待

小結

爲何這麼設計(我的觀點)

對數據進行存儲必然涉及數據結構的設計,任何對數據的操做都得基於數據結構

常規思路是對整個數據結構加鎖,可是鎖的存在會大大影響性能,因此接下來的任務,就是找到哪些能夠無鎖化的操做

操做主要分爲兩大類,讀和寫。

先看寫,由於涉及到原有數據的改動,不加控制確定會翻車,怎麼控制呢?

寫操做也分兩種,一種會改變結構,一種不會

對於會改變結構的寫,無論底層是數組仍是鏈表,因爲改動得基於原有的結構,必然得加鎖串行化保證原子操做,優化的點就是鎖層面的優化了,例如最開始HashTable等synchronized鎖到ConcurrentHashMap1.7版本的ReentrantLock鎖,再到1.8版本的Synchronized改良鎖 。或者數據分散化,concurrnethashmap等基於hash的數據結構比CopyOnWriteList的數據結構就多了桶分散的優點

對於不會改變結構的寫,或者改動的頻率不大(桶擴容頻率低),因爲鎖的開銷實在是太大了,CAS是個不錯的思路。爲何CopyOnWriteList不用CAS來控制併發寫,我我的以爲主要緣由仍是由於結構變化頻繁,能夠看下ActomicReferenceArray等基於CAS的數組容器,都是建立後就不容許結構發生變化的。

確保數據不會改錯以後,讀相對就好辦了

主要考慮是否是要實時讀最新的數據(等待寫操做完成),也就是強一致仍是弱一致的問題

強一致的話,讀就得等寫完成,讀寫競爭同一把鎖,這就相互影響了讀寫的效率。

大多數場景下,讀的數據一致性要求沒有寫的要求高,能夠讀錯,可是堅定不能夠寫錯。要是在讀的這一刻,數據還沒改完,讀到舊數據也不要緊,只要最後寫完對讀可見便可

還好JMM(Java內存模型)有個volatile可見性的語義,能夠保證不加鎖的狀況下,讀也能看到寫更改的數據。此外還有UNSAFE包的各類內存直接操做,也可相對高性能的完成可見性語義

對讀操做而言,最好的數據,就是不變的數據,不用擔憂被修改引起的各類問題。惟一的不變是變化,一些數據仍是有變化的可能,若是要支持這種不變性,或者說盡可能減小變化的頻率,變化的部分就得在別的地方處理,也就是所謂的讀寫分離

以上純我的理解,受限於水平,想法不必定正確,歡迎討論指點

推薦閱讀

併發容器之CopyOnWriteArrayList

ConcurrentHashMap實現原理和源碼解讀

Java進階(六)從ConcurrentHashMap的演進看Java多線程核心技術

併發容器之ConcurrentHashMap(JDK 1.8版本)

完全理解volatile

相關文章
相關標籤/搜索