看完你就應該能明白的悲觀鎖和樂觀鎖
- 2019 年 10 月 8 日
- 筆記
這是 cxuan 的第 36 篇原創文章
Java 按照鎖的實現分為樂觀鎖和悲觀鎖,樂觀鎖和悲觀鎖並不是一種真實存在的鎖,而是一種設計思想,樂觀鎖和悲觀鎖對於理解 Java 多執行緒和資料庫來說至關重要,那麼本篇文章就來詳細探討一下這兩種鎖的概念以及實現方式。
悲觀鎖
悲觀鎖
是一種悲觀思想,它總認為最壞的情況可能會出現,它認為數據很可能會被其他人所修改,所以悲觀鎖在持有數據的時候總會把資源
或者 數據
鎖住,這樣其他執行緒想要請求這個資源的時候就會阻塞,直到等到悲觀鎖把資源釋放為止。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。悲觀鎖的實現往往依靠資料庫本身的鎖功能實現。
Java 中的 Synchronized
和 ReentrantLock
等獨佔鎖(排他鎖)也是一種悲觀鎖思想的實現,因為 Synchronzied 和 ReetrantLock 不管是否持有資源,它都會嘗試去加鎖,生怕自己心愛的寶貝被別人拿走。
樂觀鎖
樂觀鎖的思想與悲觀鎖的思想相反,它總認為資源和數據不會被別人所修改,所以讀取不會上鎖,但是樂觀鎖在進行寫入操作的時候會判斷當前數據是否被修改過(具體如何判斷我們下面再說)。樂觀鎖的實現方案一般來說有兩種:版本號機制
和 CAS實現
。樂觀鎖多適用於多讀的應用類型,這樣可以提高吞吐量。
在Java中java.util.concurrent.atomic
包下面的原子變數類就是使用了樂觀鎖的一種實現方式CAS實現的。
兩種鎖的使用場景
上面介紹了兩種鎖的基本概念,並提到了兩種鎖的適用場景,一般來說,悲觀鎖不僅會對寫操作加鎖還會對讀操作加鎖,一個典型的悲觀鎖調用:
select * from student where name="cxuan" for update
這條 sql 語句從 Student 表中選取 name = "cxuan" 的記錄並對其加鎖,那麼其他寫操作再這個事務提交之前都不會對這條數據進行操作,起到了獨佔和排他的作用。
悲觀鎖因為對讀寫都加鎖,所以它的性能比較低,對於現在互聯網提倡的三高
(高性能、高可用、高並發)來說,悲觀鎖的實現用的越來越少了,但是一般多讀的情況下還是需要使用悲觀鎖的,因為雖然加鎖的性能比較低,但是也阻止了像樂觀鎖一樣,遇到寫不一致的情況下一直重試的時間。
相對而言,樂觀鎖用於讀多寫少的情況,即很少發生衝突的場景,這樣可以省去鎖的開銷,增加系統的吞吐量。
樂觀鎖的適用場景有很多,典型的比如說成本系統,櫃員要對一筆金額做修改,為了保證數據的準確性和實效性,使用悲觀鎖鎖住某個數據後,再遇到其他需要修改數據的操作,那麼此操作就無法完成金額的修改,對產品來說是災難性的一刻,使用樂觀鎖的版本號機制能夠解決這個問題,我們下面說。
樂觀鎖的實現方式
樂觀鎖一般有兩種實現方式:採用版本號機制
和 CAS(Compare-and-Swap,即比較並替換)演算法
實現。
版本號機制
版本號機制是在數據表中加上一個 version
欄位來實現的,表示數據被修改的次數,當執行寫操作並且寫入成功後,version = version + 1,當執行緒A要更新數據時,在讀取數據的同時也會讀取 version 值,在提交更新時,若剛才讀取到的 version 值為當前資料庫中的version值相等時才更新,否則重試更新操作,直到更新成功。
我們以上面的金融系統為例,來簡述一下這個過程。

- 成本系統中有一個數據表,表中有兩個欄位分別是
金額
和version
,金額的屬性是能夠實時變化,而 version 表示的是金額每次發生變化的版本,一般的策略是,當金額發生改變時,version 採用遞增的策略每次都在上一個版本號的基礎上 + 1。 - 在了解了基本情況和基本資訊之後,我們來看一下這個過程:公司收到回款後,需要把這筆錢放在金庫中,假如金庫中存有100 元錢
- 下面開啟事務一:當男櫃員執行回款寫入操作前,他會先查看(讀)一下金庫中還有多少錢,此時讀到金庫中有 100 元,可以執行寫操作,並把資料庫中的錢更新為 120 元,提交事務,金庫中的錢由 100 -> 120,version的版本號由 0 -> 1。
- 開啟事務二:女櫃員收到給員工發工資的請求後,需要先執行讀請求,查看金庫中的錢還有多少,此時的版本號是多少,然後從金庫中取出員工的工資進行發放,提交事務,成功後版本 + 1,此時版本由 1 -> 2。
上面兩種情況是最樂觀的情況,上面的兩個事務都是順序執行的,也就是事務一和事務二互不干擾,那麼事務要並行執行會如何呢?

- 事務一開啟,男櫃員先執行讀操作,取出金額和版本號,執行寫操作 begin update 表 set 金額 = 120,version = version + 1 where 金額 = 100 and version = 0 此時金額改為 120,版本號為1,事務還沒有提交 事務二開啟,女櫃員先執行讀操作,取出金額和版本號,執行寫操作 begin update 表 set 金額 = 50,version = version + 1 where 金額 = 100 and version = 0 此時金額改為 50,版本號變為 1,事務未提交 現在提交事務一,金額改為 120,版本變為1,提交事務。理想情況下應該變為 金額 = 50,版本號 = 2,但是實際上事務二 的更新是建立在金額為 100 和 版本號為 0 的基礎上的,所以事務二不會提交成功,應該重新讀取金額和版本號,再次進行寫操作。 這樣,就避免了女櫃員 用基於 version=0 的舊數據修改的結果覆蓋男操作員操作結果的可能。
CAS 演算法
先來看一道經典的並發執行 1000次遞增和遞減後的問題:
public class Counter { int count = 0; public int getCount() { return count; } public void setCount(int count) { this.count = count; } public void add(){ count += 1; } public void dec(){ count -= 1; } }
public class Consumer extends Thread{ Counter counter; public Consumer(Counter counter){ this.counter = counter; } @Override public void run() { for(int j = 0;j < Test.LOOP;j++){ counter.dec(); } } } public class Producer extends Thread{ Counter counter; public Producer(Counter counter){ this.counter = counter; } @Override public void run() { for(int i = 0;i < Test.LOOP;++i){ counter.add(); } } } public class Test { final static int LOOP = 1000; public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Producer producer = new Producer(counter); Consumer consumer = new Consumer(counter); producer.start(); consumer.start(); producer.join(); consumer.join(); System.out.println(counter.getCount()); } }
多次測試的結果都不為 0,也就是說出現了並發後數據不一致的問題,原因是 count -= 1 和 count += 1 都是非原子性操作,它們的執行步驟分為三步:
- 從記憶體中讀取 count 的值,把它放入暫存器中
- 執行 + 1 或者 – 1 操作
- 執行完成的結果再複製到記憶體中
如果要把證它們的原子性,必須進行加鎖,使用 Synchronzied
或者 ReentrantLock
,我們前面介紹它們是悲觀鎖的實現,我們現在討論的是樂觀鎖,那麼用哪種方式保證它們的原子性呢?請繼續往下看
CAS 即 compare and swap(比較與交換)
,是一種有名的無鎖演算法。即不使用鎖的情況下實現多執行緒之間的變數同步,也就是在沒有執行緒被阻塞的情況下實現變數的同步,所以也叫非阻塞同步(Non-blocking Synchronization
CAS 中涉及三個要素:
- 需要讀寫的記憶體值 V
- 進行比較的值 A
- 擬寫入的新值 B
當且僅當預期值A和記憶體值V相同時,將記憶體值V修改為B,否則什麼都不做。
JAVA對CAS的支援:在JDK1.5 中新添加 java.util.concurrent (J.U.C) 就是建立在 CAS 之上的。對於 synchronized 這種阻塞演算法,CAS是非阻塞演算法的一種實現。所以J.U.C在性能上有了很大的提升。
我們以 java.util.concurrent 中的AtomicInteger
為例,看一下在不用鎖的情況下是如何保證執行緒安全的
public class AtomicCounter { private AtomicInteger integer = new AtomicInteger(); public AtomicInteger getInteger() { return integer; } public void setInteger(AtomicInteger integer) { this.integer = integer; } public void increment(){ integer.incrementAndGet(); } public void decrement(){ integer.decrementAndGet(); } } public class AtomicProducer extends Thread{ private AtomicCounter atomicCounter; public AtomicProducer(AtomicCounter atomicCounter){ this.atomicCounter = atomicCounter; } @Override public void run() { for(int j = 0; j < AtomicTest.LOOP; j++) { System.out.println("producer : " + atomicCounter.getInteger()); atomicCounter.increment(); } } } public class AtomicConsumer extends Thread{ private AtomicCounter atomicCounter; public AtomicConsumer(AtomicCounter atomicCounter){ this.atomicCounter = atomicCounter; } @Override public void run() { for(int j = 0; j < AtomicTest.LOOP; j++) { System.out.println("consumer : " + atomicCounter.getInteger()); atomicCounter.decrement(); } } } public class AtomicTest { final static int LOOP = 10000; public static void main(String[] args) throws InterruptedException { AtomicCounter counter = new AtomicCounter(); AtomicProducer producer = new AtomicProducer(counter); AtomicConsumer consumer = new AtomicConsumer(counter); producer.start(); consumer.start(); producer.join(); consumer.join(); System.out.println(counter.getInteger()); } }
經測試可得,不管循環多少次最後的結果都是0,也就是多執行緒並行的情況下,使用 AtomicInteger 可以保證執行緒安全性。incrementAndGet 和 decrementAndGet 都是原子性操作。本篇文章暫不探討它們的實現方式。
樂觀鎖的缺點
任何事情都是有利也有弊,軟體行業沒有完美的解決方案只有最優的解決方案,所以樂觀鎖也有它的弱點和缺陷:
ABA 問題
ABA 問題說的是,如果一個變數第一次讀取的值是 A,準備好需要對 A 進行寫操作的時候,發現值還是 A,那麼這種情況下,能認為 A 的值沒有被改變過嗎?可以是由 A -> B -> A 的這種情況,但是 AtomicInteger 卻不會這麼認為,它只相信它看到的,它看到的是什麼就是什麼。
JDK 1.5 以後的 AtomicStampedReference
類就提供了此種能力,其中的 compareAndSet 方法
就是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置為給定的更新值。
也可以採用CAS的一個變種DCAS來解決這個問題。DCAS,是對於每一個V增加一個引用的表示修改次數的標記符。對於每個V,如果引用修改了一次,這個計數器就加1。然後再這個變數需要update的時候,就同時檢查變數的值和計數器的值。
循環開銷大
我們知道樂觀鎖在進行寫操作的時候會判斷是否能夠寫入成功,如果寫入不成功將觸發等待 -> 重試機制,這種情況是一個自旋鎖,簡單來說就是適用於短期內獲取不到,進行等待重試的鎖,它不適用於長期獲取不到鎖的情況,另外,自旋循環對於性能開銷比較大。
CAS與synchronized的使用情景
簡單的來說 CAS 適用於寫比較少的情況下(多讀場景,衝突一般較少),synchronized 適用於寫比較多的情況下(多寫場景,衝突一般較多)
- 對於資源競爭較少(執行緒衝突較輕)的情況,使用 synchronized 同步鎖進行執行緒阻塞和喚醒切換以及用戶態內核態間的切換操作額外浪費消耗 cpu 資源;而 CAS 基於硬體實現,不需要進入內核,不需要切換執行緒,操作自旋幾率較少,因此可以獲得更高的性能。
- 對於資源競爭嚴重(執行緒衝突嚴重)的情況,CAS 自旋的概率會比較大,從而浪費更多的 CPU 資源,效率低於 synchronized。
補充:Java並發編程這個領域中 synchronized 關鍵字一直都是元老級的角色,很久之前很多人都會稱它為 「重量級鎖」 。但是,在JavaSE 1.6之後進行了主要包括為了減少獲得鎖和釋放鎖帶來的性能消耗而引入的 偏向鎖 和 輕量級鎖 以及其它各種優化之後變得在某些情況下並不是那麼重了。synchronized 的底層實現主要依靠 Lock-Free 的隊列,基本思路是 自旋後阻塞,競爭切換後繼續競爭鎖,稍微犧牲了公平性,但獲得了高吞吐量。在執行緒衝突較少的情況下,可以獲得和 CAS 類似的性能;而執行緒衝突嚴重的情況下,性能遠高於CAS。
相關參考:
Java 多執行緒之悲觀鎖與樂觀鎖[1]
https://baike.baidu.com/item/悲觀鎖
防丟預警
你點的每個收藏,我都認真當成了喜歡