天天看點

面試官:可以談談樂觀鎖和悲觀鎖嗎

什麼是悲觀鎖和樂觀鎖

樂觀鎖和悲觀鎖是兩種思想,用于解決并發場景下的資料競争問題。**它們的使用是非常廣泛的,不局限于某種程式設計語言或資料庫。**樂觀鎖對應于生活中樂觀的人總是想着事情往好的方向發展,悲觀鎖對應于生活中悲觀的人總是想着事情往壞的方向發展。這兩種人各有優缺點,不能不以場景而定說一種人好于另外一種人。

悲觀鎖

悲觀鎖顧名思義是從悲觀的角度去思考問題,解決問題。它總是會假設目前情況是最壞的情況,在每次去拿資料的時候,都會認為資料會被别人改變,是以在每次進行拿資料操作的時候都會加鎖,如此一來,如果此時有别人也來拿這個資料的時候就會阻塞知道它拿到鎖。在Java中,Synchronized和ReentrantLock等獨占鎖的實作機制就是基于悲觀鎖思想。在資料庫中也經常用到這種鎖機制,如行鎖,表鎖,讀寫鎖等,都是在操作之前先上鎖,保證共享資源隻能給一個操作(一個線程)使用。

由于悲觀鎖的頻繁加鎖,是以導緻了一些問題的出現:比如在多線程競争下,頻繁加鎖、釋放鎖導緻頻繁的上下文切換和排程延時,一個線程持有鎖會導緻其他線程進入阻塞狀态,進而引起性能問題。

樂觀鎖

總是假設最好的情況,每次去拿資料的時候都認為别人不會修改,是以不會上鎖,但是在更新的時候會判斷一下在此期間别人有沒有去更新這個資料,可以使用版本号機制和CAS算法實作。樂觀鎖适用于多讀的應用類型,這樣可以提高吞吐量,像資料庫提供的類似于write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實作方式CAS實作的。

兩種鎖的使用場景

與悲觀鎖相比,樂觀鎖适用的場景受到了更多的限制,無論是CAS還是版本号機制。

在并發沖突機率大的高競争環境下,如果CAS一直失敗,會一直重試,CPU開銷較大。針對這個問題的一個思路是引入退出機制,如重試次數超過一定門檻值後失敗退出。當然,更重要的是避免在高競争環境下使用樂觀鎖。

CAS的功能是比較受限的,例如CAS隻能保證單個變量(或者說單個記憶體值)操作的原子性,這意味着:

  • 原子性不一定能保證線程安全,例如在Java中需要與volatile配合來保證線程安全;
  • 當涉及到多個變量(記憶體值)時,CAS也無能為力。

除此之外,CAS的實作需要硬體層面處理器的支援,在Java中普通使用者無法直接使用,隻能借助atomic包下的原子類使用,靈活性受到限制。

如果悲觀鎖和樂觀鎖都可以使用,那麼選擇就要考慮競争的激烈程度:

  • 當競争不激烈 (出現并發沖突的機率小)時,樂觀鎖更有優勢,因為悲觀鎖會鎖住代碼塊或資料,其他線程無法同時通路,影響并發,而且加鎖和釋放鎖都需要消耗額外的資源。
  • 當競争激烈(出現并發沖突的機率大)時,悲觀鎖更有優勢,因為樂觀鎖在執行更新時頻繁失敗,需要不斷重試,浪費CPU資源。

兩種鎖各有優缺點,不可認為一種好于另一種,像樂觀鎖适用于寫比較少的情況下(多讀場景),即沖突真的很少發生的時候,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。但如果是多寫的情況,一般會經常産生沖突,這就會導緻上層應用會不斷的進行retry,這樣反倒是降低了性能,是以一般多寫的場景下用悲觀鎖就比較合适。

樂觀鎖主要兩種實作方式

樂觀鎖一般會使用版本号機制或CAS算法實作。

版本号機制

一般是在資料表中加上一個資料版本号version字段,表示資料被修改的次數,當資料被修改時,version值會加一。當線程A要更新資料值時,在讀取資料的同時也會讀取version值,在送出更新時,若剛才讀取到的version值為目前資料庫中的version值相等時才更新,否則重試更新操作,直到更新成功。

舉一個簡單的例子🌰:

假設資料庫中帳戶資訊表中有一個 version 字段,目前值為 1 ;而目前帳戶餘額字段( balance )為 $1000 。

  1. 線程 A 此時将其讀出( version=1 ),并從其帳戶餘額中扣除 $200( $1000-$200 )。
  2. 線上程 A 操作的過程中,線程B 也讀入此使用者資訊( version=1 ),并從其帳戶餘額中增加 $300 ( $1000+$300 )。
  3. 線程 A 完成了修改工作,将資料版本号加一( version=2 ),連同帳戶扣除後餘額( balance=$800 ),送出至資料庫更新,此時由于送出資料版本大于資料庫記錄目前版本,資料被更新,資料庫記錄 version 更新為 2 。
  4. 線程 B 完成了操作,也将版本号加一( version=2 )試圖向資料庫送出資料( balance=$1300 ),但此時比對資料庫記錄版本時發現,線程 B 送出的資料版本号為 2 ,資料庫記錄目前版本也為 2 ,不滿足 “ 送出版本必須大于記錄目前版本才能執行更新 “ 的樂觀鎖政策,是以,線程 B 的送出被駁回。

這樣,就避免了線程 B 用基于 version=1 的舊資料修改的結果覆寫線程A 的操作結果的可能。

CAS算法

即compare and swap(比較與交換),是一種有名的無鎖算法。無鎖程式設計,即不使用鎖的情況下實作多線程之間的變量同步,也就是在沒有線程被阻塞的情況下實作變量的同步,是以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三個操作數

  • 需要讀寫的記憶體值 V
  • 進行比較的值 A
  • 拟寫入的新值 B

當且僅當 V 的值等于 A時,CAS通過原子方式用新值B來更新V的值,否則不會執行任何操作(比較和替換是一個原子操作)。一般情況下是一個自旋操作,即不斷的重試。

樂觀鎖的缺點

ABA問題

在AtomicInteger中,ABA似乎沒有什麼危害。但是在某些場景下,ABA卻會帶來隐患,例如棧頂問題:一個棧的棧頂經過兩次(或多次)變化又恢複了原值,但是棧可能已發生了變化。

對于ABA問題,比較有效的方案是引入版本号,記憶體中的值每發生一次變化,版本号都+1;在進行CAS操作時,不僅比較記憶體中的值,也會比較版本号,隻有當二者都沒有變化時,CAS才能執行成功。JDK 1.5 以後的AtomicStampedReference類便是使用版本号來解決ABA問題的,其中的 compareAndSet 方法就是首先檢查目前引用是否等于預期引用,并且目前标志是否等于預期标志,如果全部相等,則以原子方式将該引用和該标志的值設定為給定的更新值。

循環時間長開銷大

對于資源競争嚴重(線程沖突嚴重)的情況,CAS自旋的機率會比較大,進而浪費更多的CPU資源,效率低于synchronized這種悲觀鎖。

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

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

補充: Java并發程式設計這個領域中synchronized關鍵字一直都是元老級的角色,很久之前很多人都會稱它為 “重量級鎖” 。但是,在JavaSE 1.6之後進行了主要包括為了減少獲得鎖和釋放鎖帶來的性能消耗而引入的 偏向鎖 和 輕量級鎖 以及其它各種優化之後變得在某些情況下并不是那麼重了。synchronized的底層實作主要依靠 Lock-Free 的隊列,基本思路是 自旋後阻塞,競争切換後繼續競争鎖,稍微犧牲了公平性,但獲得了高吞吐量。線上程沖突較少的情況下,可以獲得和CAS類似的性能;而線程沖突嚴重的情況下,性能遠高于CAS。