當前位置: 妍妍網 > 碼農

樂觀鎖與悲觀鎖,你真的了解嗎?

2024-03-01碼農

引言:當你在簡歷上寫了多緒安全之類的知識,那麽樂觀鎖和悲觀鎖便是幾乎必問的一個點,如果能系統地回答好這個問題,將是很大的一個加分項,本文將用圖文結合並將實作過程,可能產生的問題,註意事項等等,進行詳細解釋。

題目

深入理解樂觀鎖與悲觀鎖,你真的了解嗎?

推薦解析

什麽是悲觀鎖?

悲觀鎖總是假設出現最壞的鏡框,認為共享的資源在競態的存取時會出現問題,導致共享數據被修改,因此悲觀鎖會在每次獲取資源操作的時候,都會上鎖,以保證臨界區的安全性。當其他執行緒想要拿到共享資源,那麽就必須等待上一個拿取物件鎖的持有者釋放鎖,只有獲取到鎖後才能去執行臨界區內的程式碼,也就是序列執行,每次只能有一個執行緒使用,其他執行緒 BLOCKED(阻塞)。

用 Java 語言去實作獨占鎖的話,有兩個選擇。

1)Synchronized 關鍵字,JVM 級別,有鎖粗化、鎖消除、鎖自旋、偏向鎖、輕量級鎖等最佳化,每一次 JVM 的最佳化直接影響 Synchronized 關鍵字,因此可以在不改動程式碼的情況下可以提升 這個介面的 QPS,實作是基於監視器 Monitor

2)ReentrantLock 可重入鎖,相比於 Synchronized 是 API 級別,最佳化空間比較少,但支持公平鎖(一般不使用公平鎖,效能較低),需要手動去 Lock 和 UnLock,可以設定超時時間,可以判斷鎖是否被其他執行緒所持有,並且支持 Condition 類去實作特定的執行緒等待和通知,可以對某類執行緒進行分類等待,實作是基於 AQS 抽象佇列同步器。

publicvoidtest(){
synchronized (this) {
// 同步程式碼
}
}
Lock lock = new ReentrantLock();
lock.lock();
try {
// 同步程式碼
finally {
lock.unlock();
}

在高並行情況下,鎖競爭可能造成執行緒阻塞,大量阻塞執行緒導致頻繁執行緒的上下文切換,CPU 利用率被極大降低,並且要考慮死結問題,從而要了解死結預防,死結避免,死結檢測,死結解決,銀行家演算法等知識。

什麽是樂觀鎖?

樂觀鎖總是假設最好的情況,認為共享資源在存取的時候不會出現問題,執行緒可以一直執行,不需要加鎖,只需要用版本號機制或者 CAS 演算法去檢測對應的資源有沒有被其他執行緒修改了即可。

典型的 Java 實作

JUC 包下面的原子變量類比如 AtomicInteger,AtomicLong 等就是用樂觀鎖的 CAS 方式去實作的。

classTAtomicTestDecrementimplementsRunnable{
AtomicInteger atomicInteger = new AtomicInteger(20000);
@Override
publicvoidrun(){
for(int i = 0;i < 10000 ;i++){
System.out.println(atomicInteger.getAndDecrement());
}
}
publicstaticvoidmain(String[] args){
TAtomicTestDecrement tAtomicTest = new TAtomicTestDecrement();
Thread t1 = new Thread(tAtomicTest);
Thread t2 = new Thread(tAtomicTest);
t1.start();
t2.start();
}
}





在高並行場景下,樂觀鎖不存在鎖競爭,也不會出現死結問題,效能更加優越,但如果競爭激烈,寫多讀少,還是建議用 Synchronized 等獨占鎖,因為樂觀鎖的自旋和重試會導致 CPU 的升高,同樣也會影響效能。

如何實作樂觀鎖?

版本號機制

在數據表中加上一個數據版本號 Version 欄位,表示數據被修改的次數,當數據被修改時,Version 值會加一,當數據要更新時,會讀取 Version 值,在送出更新時,如果 Version 值和原來讀取到的相等才會更新,否則會自旋重試更新操作,直到成功。

CAS 演算法

CAS (Compare And Swap)比較並且交換,將預期值和要更新的變量值進行比較,只有兩者相等才會進行更新。

CAS 操作是原子命令,不能被打斷。

Java 的 Atomic 原子類別的 CAS 底層呼叫的是 Unsafe 類的 Compare And Swapxxx,有四個參數,this 指的是當前物件,valueOffSet 是偏移量,Expect 是預期值,Update 是更新值。

this + valueOffset 構成物件的記憶體地址。

CAS 相關的實作是透過 C++ 行內組譯的形式實作的,要透過 JNT 去呼叫,具體實作和作業系統和 CPU 有關。

樂觀鎖存在的問題和解決方法

1)ABA 問題,當一個執行緒想要更新一個共享變量,初次讀取這個值是 A,再次準備賦值時這個值依然是 A,但不能表明這個值沒有被其他執行緒修改過,因為它可能在這段時間經過從 A 到 B 然後又被修改為 A 的兩次修改操作,這就是 ABA 問題。

解決方法

在變量前面加上版本號或者時間戳,JDK 1.5 以後出現的 AtomicStampedReference 就是用來解決 ABA 問題,既要比較當前參照/值是否等於預期參照/值,並且當前的 Stamp 版本是否和預期的 Stamp 版本相等,如果兩個都相等,才會用 CAS 進行更新操作。

2)迴圈時間長導致開銷大

CAS 會用自旋操作進行重試,如果長時間不成功,那麽 CPU 利用率會很低。

解決方法:JVM 使用處理器提供的 Pause 指令,延遲流水線執行命令,避免在結束迴圈時因記憶體順序而引起 CPU 流水線被清空。

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

解決方法:JDK 1.5 提供了 AtomicReference 保證參照物件的原子性,可以將多個共享變量放入一個物件來進行 CAS 。

總結

1)如果鎖競爭比較激烈,建議采用 Synchronized 關鍵字這種悲觀鎖。

2)鎖競爭比較輕,而且競爭造成的等待時間較短的情況下,CAS 可以透過自旋操作去提升效能,因此選擇樂觀鎖。

3)使用樂觀鎖要註意 ABA 問題、迴圈時間長開銷大、只能保證一個共享變量的原子操作。

其他補充

魚聰明 AI 的回答:

魚聰明 AI 地址:https://www.yucongming.com/

樂觀鎖是一種並行控制的方法,它假設在大多數情況下,沖突是不常見的。因此,它允許多個事務並行地存取數據,只在最後更新時檢查沖突。通常使用版本號(Version Number)或時間戳(Timestamp)來實作。

悲觀鎖則是一種較為保守的並行控制方法,它在整個事務期間釘選數據,以防止其他事務的幹擾。在悲觀鎖的情況下,一個事務獲取鎖後,其他事務必須等待釋放鎖後才能存取數據。

如何實作樂觀鎖

樂觀鎖的實作通常依賴於版本號或時間戳。在資料庫中,可以透過以下步驟實作樂觀鎖:

  1. 添加版本號欄位: 在數據表中添加一個用於儲存版本號的欄位,通常命名為 version

  2. 讀取數據時獲取版本號: 當讀取數據時,獲取當前數據的版本號。

  3. 更新數據時檢查版本號: 在更新數據時,比較資料庫中的版本號和讀取時獲取的版本號是否一致。如果一致,則允許更新;否則,表示數據已經被其他事務修改,需要處理沖突。

樂觀鎖存在的問題和解決方法

問題
  • 並行更新可能導致沖突: 如果多個事務同時嘗試更新同一數據,可能會導致沖突。

  • 解決方法
  • 重試機制: 當發現版本號不一致時,可以選擇重新讀取數據、重新計算,並再次嘗試更新,直至成功。

  • 沖突處理: 可以采用一些沖突解決策略,如合並變更、手動解決沖突等。

  • 總結

  • 樂觀鎖: 適用於並行沖突較少的情況,透過版本號或時間戳實作,允許多個事務並行存取。

  • 悲觀鎖: 適用於並行沖突較多的情況,透過在事務期間釘選數據來確保一次只有一個事務能夠存取。

  • 樂觀鎖適用於高並行、沖突較少的場景,而悲觀鎖適用於對數據一致性要求較高、沖突較多的場景。選擇鎖的型別要根據具體業務場景和效能需求來決定。

  • 推薦文章和書籍

    文章:https://zhuanlan.zhihu.com/p/71156910

    書籍:【Java 並行編程核心 78 講】

    歡迎交流

    在閱讀完本文之後,你應該對樂觀鎖和悲觀鎖的概念和如何實作以及應該註意的事項有了一定的了解,相信掌握了這些知識的你一定能和面試官進行一番交流,在文末我將留下三個問題,歡迎小夥伴在評論區交流見解,一起進步!

    1)如果多個事務同時讀取相同的數據並且都嘗試進行更新,如何出來由於版本號沖突而導致的並行問題?

    2)在高並行環境下,如何避免過多的鎖競爭,以提高系統的並行效能?

    3)在實際套用中,如何選擇樂觀鎖或悲觀鎖,以滿足特定業務場景的需求?有什麽因素需要考慮,例如數據一致性、並行度、系統效能等?

    往期推薦