Java併發編程(三)之悲觀鎖與樂觀鎖

何謂悲觀鎖?何謂樂觀鎖?

樂觀鎖就像生活中那些樂觀的人,對於事情的發展總是往好的方向去想。悲觀鎖就像生活中那些悲觀的人,對於事情的發展總是往壞的方向去想。這兩種鎖各有各的優缺點,不能不以場景而定某一種鎖就比另一種鎖好。

悲觀鎖

總是假設最壞的情況,每次去拿數據的時候都會認爲會有其他線程修改該數據,所以在每次拿數據的時候都會對該數據上鎖,這樣其他想要操作該數據的線程就會阻塞直到獲取到該數據的鎖(共享資源在同一時刻只能給一個線程操作,其他線程會被阻塞,等待一個線程操作完之後纔會給其他線程操作)。傳統關係型數據庫中就用到了很多這樣的鎖機制。比如行鎖、表鎖、讀鎖、寫鎖等,都是在操作共享資源之前先對共享資源上鎖。Java中的synchronized和Reentrantlock等獨佔鎖就屬於悲觀鎖機制。

樂觀鎖

總是假設最好的情況,每次去拿數據的時候都會認爲不會有其他線程修改該數據,所以在每次拿數據的時候都不會對數據上鎖,但是在更新數據的時候會判斷一下在它從讀取到操作數據到更新操作數的期間有沒有其他線程操作過該數據,這個判斷可以使用版本號機制和CAS算法實現。樂觀鎖適用於讀多寫少的場景,可以提高吞吐量。關係型數據庫中也有類似的機制,比如write_condition機制,在 Java 中 java.util.concurrent.atomic 包下面的原子變量類就是使用了樂觀鎖的一種實現方式 CAS 實現的。

兩種鎖的使用場景

我們知道悲觀鎖和樂觀鎖都有各自的優缺點,不能片面的認爲一種鎖比另一種鎖好。樂觀鎖適用於讀多寫少的場景,也就是很少會發生線程衝突的場景,這樣可以省去獲取鎖和釋放鎖的開銷,從而大大提高程序的吞吐量,如果是寫操作比較多的場景,一般線程衝突也比較多,使用樂觀鎖機制會導致很多線程做一些無用並且不斷的自旋,反而降低了程序的性能,所以這種情況適合用悲觀鎖,

樂觀鎖的兩種實現機制

版本號機制

一般會在數據上面加一個版本號version字段,表示數據被修改時當前的版本號,也可以理解爲數據被修改的次數,每次數據被修改的時候version都會加1。當線程A要修改數據的時候,在讀取數據的同時也會讀取數據當時的version值,修改完成之後再提交更新數據的時候,會讀取當前數據的version值和修改之前讀取的version值進行對比,如果相等纔會更新,否則重新讀取當前數據以及當前數據的version值再進行修改,直到更新成功。
舉一個簡答的例子:
你去ATM機取錢,假設當前銀行卡餘額balance爲100元,當前balance對應的version值爲1,此時你要取出50元。

  1. 操作A,先讀取當前版本號(version=1)和餘額(balance=100),然後開始執行餘額扣除操作也就是100-50。
  2. 操作A中的餘額扣除操作還沒有執行完的時候,有人給你轉賬100,此時操作B讀取當前版本號(version=1)和餘額(balance=100),然後開始執行餘額增加操作也就是100+100。
  3. 操作A餘額扣除操作完成,將版本號加1(version+1),此時銀行卡賬號中餘額(balance=50),版本號(version=2)。
  4. 操作B完成了餘額增加操作,將版本號加1(version+1)餘額加100(balence+100),然後提交更新銀行卡賬號中的version和balance。此時先讀取到當前銀行卡賬號中的版本號(version=2),與提交之前操作B讀取到的版本號(version=1)不相等,所以操作B的提交失敗。
    這樣利用版本號機制就可以避免操作B修改基於version=1的舊版本數據的結果覆蓋操作A的結果。

CAS算法

CAS是compare and swap(比較與交換)的縮寫,是一種典型的無鎖算法,無鎖編程,也就是在不使用鎖的情況下實現多線程之間(沒有線程會被阻塞)共享數據的同步,所以可以叫做非阻塞式同步(No-Block Synchronization),CAS算法有三個核心操作數:要更新的內存值(V)、要修改的值也叫作預期內存值(A)、修改後需要替換內存值的值(B)。
當且僅當V=A的時候,CAS纔會通過原子操作將B賦給V,否則不會做任何操作,比較和替換是一個原子操作,一般情況下會以自旋的方式不斷的重試去更新內存值(V)
舉一個簡單的例子,假設內存值爲10,線程1和線程2都要對內存值進行加1操作:

  1. 線程1先獲取到內存值10,對內存值進行加1操作,V=10
    線程1:A=10、B=11
  2. 線程2在此時也獲取到內存值10,對內存值進行加1操作,V=10
    線程2:A=10、B=11
  3. 線程1先將內存值進行了更新,V=11
    線程1:A=10、B=11
    線程2:A=10、B=11
  4. 線程2去更新內存值的時候,發現A!= V,所以更新內存值失敗,此時線程2重新獲取內存值V=11並且重新計算需要替換內存值的新值(B),這個重新嘗試的過程就叫做自旋
    線程2:A=11、B=12
  5. 線程2操作完成更新內存值,發現A=V,沒有其他線程修改V的值,所以將B的值賦值給V,更新內存值,此時內存值V=12

樂觀鎖存在的問題

ABA問題

CAS操作會導致一個很經典的“ABA”問題,如果內存值V初次讀取的時候是A,一個線程準備更新內存值的時候發現此時內存值依然是A,內存值沒有發生變化,就會把內存值由A變成B,但是我們是不確定內存值是否被其他線程改變過,如果內存值由最開始的A變爲了B,又從B變成了A,那麼CAS操作就會認爲內存值從來沒有改變過,這就是“ABA”問題,這個問題僅僅是這樣從表面來看好像沒什麼問題,因爲線程本來就是將內存值A變爲B,那麼下面舉一個實際生活中的例子體會一下“ABA”問題:

1.你的銀行卡餘額(balance)爲100元,此時你去ATM機取款50元,但是由於ATM取款機出現問題,導致你的一個取款操作同時發起了兩次請求,從而開啓兩個線程去執行扣款操作,在理想的情況下,線程A的扣款操作成功之後線程B的扣款操作就會失敗,這樣只會扣款一次:

線程A:讀取內存值100,需要扣除50,期望內存值變爲50
線程B:讀取內存值100,需要扣除50,期望內存值變爲50

2.但是出於各種不可抗因素導致此時線程B阻塞了,同時又有人向你轉賬50元,開啓線程C進行轉賬操作:

線程A:讀取內存值100,成功將內存值更新爲50
線程B:讀取內存值100,需要扣除50,期望內存值變爲50(此時它阻塞了)
線程C:讀取內存值50,需要增加50,期望內存值變爲100

3.此時線程C執行完畢,成功將內存值更新爲100:

線程A:讀取內存值100,成功將內存值更新爲50(已成功返回,線程已結束)
線程B:讀取內存值100,需要扣除50,期望內存值變爲50(此時線程B還在阻塞中)
線程C:讀取內存值50,成功將內存值更新爲100

4.此時線程B恢復正常,因爲線程B讀取的內存值爲100,與當前內存值相等,所以通過CAS可以成功將內存值進行更新:

線程A:讀取內存值100,成功將內存值更新爲50(已成功返回,線程已結束)
線程B:讀取內存值100,成功將內存值更新爲50
線程C:讀取內存值50,成功將內存值更新爲100(已成功返回,線程已結束)

發現問題所在了嗎?平白無故少了50元,原本線程B去更新內存值的時候應該是失敗的,但是由於“ABA”問題導致更新內存值成功,CAS操作配合上面所說的版本號機制可以解決“ABA”問題。在JDK1.5之後的AtomicStampedReference類就提供了這種解決方式,其中CAS操作不僅會檢查當前內存值(V)是否等於預期內存值(A),還會檢查當前版本號是否等於預期版本號,如果全部相等,就會以原子方式將當前內存值和當前版本號一起更新爲給定的更新值(B)。

循環時間長開銷大

自旋 CAS(也就是不成功就一直循環執行直到成功)如果長時間不成功,會給CPU 帶來非常大的執行開銷。 如果 JVM 能支持處理器提供的 pause 指令那麼效率會有一定的提升,pause 指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使 CPU 不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環的時候因內存順序衝突(memory order violation)而引起 CPU 流水線被清空(CPU pipeline flush),從而提高 CPU 的執行效率。

只能保證一個共享變量的原子操作

CAS 只對單個共享變量有效,當操作涉及跨多個共享變量時 CAS 無效。但是從 JDK 1.5 開始,提供了 AtomicReference 類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裏來進行 CAS 操作.所以我們可以使用鎖或者利用 AtomicReference 類把多個共享變量合併成一個共享變量來操作。

CAS 與 synchronized 的使用情景

CAS是樂觀鎖的代表,synchronized是悲觀鎖的代表,CAS適用於讀多寫少衝突少的場景,synchronized適用於寫多讀少衝突多的場景。
1.對於資源競爭較少(線程衝突較輕)的情況,使用 synchronized 同步鎖進行線程阻塞和喚醒切換以及用戶態內核態間的切換操作額外浪費消耗CPU資源;而 CAS 基於硬件實現,不需要進入內核,不需要切換線程,操作自旋機率較少,因此可以獲得更高的性能。
2.對於資源競爭嚴重(線程衝突嚴重)的情況,CAS 自旋的概率會比較大,可能會導致長時間自旋又獲取不到資源的情況,從而浪費更多的 CPU 資源,效率低於 synchronized。

對於synchronized不是很熟悉的小夥伴可以點這裏Java併發編程(二)之synchronized

練習、用心、持續------致每一位追夢人!加油!!!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章