歡迎光臨
每天分享高質量文章

面試官:你瞭解樂觀鎖和悲觀鎖嗎?

(給ImportNew加星標,提高Java技能)

作者:程式設計迷思(本文來自作者投稿)

前言

 

樂觀鎖和悲觀鎖問題,是出現頻率比較高的面試題。本文將由淺入深,逐步介紹它們的基本概念、實現方式(含實體)、適用場景,以及可能遇到的面試官追問,希望能夠幫助你打動面試官。

 

一、基本概念

 

樂觀鎖和悲觀鎖是兩種思想,用於解決併發場景下的資料競爭問題。

 

  • 樂觀鎖:樂觀鎖在運算元據時非常樂觀,認為別人不會同時修改資料。因此樂觀鎖不會上鎖,只是在執行更新的時候判斷一下在此期間別人是否修改了資料:如果別人修改了資料則放棄操作,否則執行操作。
  • 悲觀鎖:悲觀鎖在運算元據時比較悲觀,認為別人會同時修改資料。因此運算元據時直接把資料鎖住,直到操作完成後才會釋放鎖;上鎖期間其他人不能修改資料。

 

二、實現方式(含實體)

 

在說明實現方式之前,需要明確:樂觀鎖和悲觀鎖是兩種思想,它們的使用是非常廣泛的,不侷限於某種程式語言或資料庫

 

悲觀鎖的實現方式是加鎖,加鎖既可以是對程式碼塊加鎖(如Java的synchronized關鍵字),也可以是對資料加鎖(如MySQL中的排它鎖)。

 

樂觀鎖的實現方式主要有兩種:CAS機制和版本號機制,下麵詳細介紹。

 

1、CAS(Compare And Swap)

 

CAS操作包括了3個運算元:

 

  • 需要讀寫的記憶體位置(V)
  • 進行比較的預期值(A)
  • 擬寫入的新值(B)

 

CAS操作邏輯如下:如果記憶體位置V的值等於預期的A值,則將該位置更新為新值B,否則不進行任何操作。許多CAS的操作是自旋的:如果操作不成功,會一直重試,直到操作成功為止。

 

這裡引出一個新的問題,既然CAS包含了Compare和Swap兩個操作,它又如何保證原子性呢?答案是:CAS是由CPU支援的原子操作,其原子性是在硬體層面進行保證的。

 

下麵以Java中的自增操作(i++)為例,看一下悲觀鎖和CAS分別是如何保證執行緒安全的。我們知道,在Java中自增操作不是原子操作,它實際上包含三個獨立的操作:(1)讀取i值;(2)加1;(3)將新值寫回i

 

因此,如果併發執行自增操作,可能導致計算結果的不準確。在下麵的程式碼示例中:value1沒有進行任何執行緒安全方面的保護,value2使用了樂觀鎖(CAS),value3使用了悲觀鎖(synchronized)。執行程式,使用1000個執行緒同時對value1、value2和value3進行自增操作,可以發現:value2和value3的值總是等於1000,而value1的值常常小於1000。

 

public class Test {
     
    //value1:執行緒不安全
    private static int value1 = 0;
    //value2:使用樂觀鎖
    private static AtomicInteger value2 = new AtomicInteger(0);
    //value3:使用悲觀鎖
    private static int value3 = 0;
    private static synchronized void increaseValue3(){
        value3++;
    }
     
    public static void main(String[] args) throws Exception {
        //開啟1000個執行緒,並執行自增操作
        for(int i = 0; i < 1000; ++i){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    value1++;
                    value2.getAndIncrement();
                    increaseValue3();
                }
            }).start();
        }
        //列印結果
        Thread.sleep(1000);
        System.out.println("執行緒不安全:" + value1);
        System.out.println("樂觀鎖(AtomicInteger):" + value2);
        System.out.println("悲觀鎖(synchronized):" + value3);
    }
}

 

首先來介紹AtomicInteger。AtomicInteger是java.util.concurrent.atomic包提供的原子類,利用CPU提供的CAS操作來保證原子性;除了AtomicInteger外,還有AtomicBoolean、AtomicLong、AtomicReference等眾多原子類。

 

下麵看一下AtomicInteger的原始碼,瞭解下它的自增操作getAndIncrement()是如何實現的(原始碼以Java7為例,Java8有所不同,但思想類似)。

 

public class AtomicInteger extends Number implements java.io.Serializable {
    //儲存整數值,volatile保證可視性
    private volatile int value;
    //Unsafe用於實現對底層資源的訪問
    private static final Unsafe unsafe = Unsafe.getUnsafe();
 
    //valueOffset是value在記憶體中的偏移量
    private static final long valueOffset;
    //透過Unsafe獲得valueOffset
    static {
        try {
            valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
 
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
 
    public final int getAndIncrement() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return current;
        }
    }
}

 

原始碼分析說明如下:

 

  1. getAndIncrement()實現的自增操作是自旋CAS操作:在迴圈中進行compareAndSet,如果執行成功則退出,否則一直執行。
  2. 其中compareAndSet是CAS操作的核心,它是利用Unsafe物件實現的。
  3. Unsafe又是何許人也呢?Unsafe是用來幫助Java訪問作業系統底層資源的類(如可以分配記憶體、釋放記憶體),透過Unsafe,Java具有了底層操作能力,可以提升執行效率;強大的底層資源操作能力也帶來了安全隱患(類的名字Unsafe也在提醒我們這一點),因此正常情況下使用者無法使用。AtomicInteger在這裡使用了Unsafe提供的CAS功能。
  4. valueOffset可以理解為value在記憶體中的偏移量,對應了CAS三個運算元(V/A/B)中的V;偏移量的獲得也是透過Unsafe實現的。
  5. value域的volatile修飾符:Java併發程式設計要保證執行緒安全,需要保證原子性、可視性和有序性;CAS操作可以保證原子性,而volatile可以保證可視性和一定程度的有序性;在AtomicInteger中,volatile和CAS一起保證了執行緒安全性。關於volatile作用原理的說明涉及到Java記憶體模型(JMM),這裡不詳細展開。

 

說完了AtomicInteger,再說synchronized。synchronized透過對程式碼塊加鎖來保證執行緒安全:在同一時刻,只能有一個執行緒可以執行程式碼塊中的程式碼。synchronized是一個重量級的操作,不僅是因為加鎖需要消耗額外的資源,還因為執行緒狀態的切換會涉及作業系統核心態和使用者態的轉換;不過隨著JVM對鎖進行的一系列最佳化(如自旋鎖、輕量級鎖、鎖粗化等),synchronized的效能表現已經越來越好。

 

2、版本號機制

 

除了CAS,版本號機制也可以用來實現樂觀鎖。版本號機制的基本思路是在資料中增加一個欄位version,表示該資料的版本號,每當資料被修改,版本號加1。當某個執行緒查詢資料時,將該資料的版本號一起查出來;當該執行緒更新資料時,判斷當前版本號與之前讀取的版本號是否一致,如果一致才進行操作。

 

需要註意的是,這裡使用了版本號作為判斷資料變化的標記,實際上可以根據實際情況選用其他能夠標記資料版本的欄位,如時間戳等。

 

下麵以“更新玩家金幣數”為例(資料庫為MySQL,其他資料庫同理),看看悲觀鎖和版本號機制是如何應對併發問題的。

 

考慮這樣一種場景:遊戲系統需要更新玩家的金幣數,更新後的金幣數依賴於當前狀態(如金幣數、等級等),因此更新前需要先查詢玩家當前狀態。

 

下麵的實現方式,沒有進行任何執行緒安全方面的保護。如果有其他執行緒在query和update之間更新了玩家的資訊,會導致玩家金幣數的不準確。

 

@Transactional
public void updateCoins(Integer playerId){
    //根據player_id查詢玩家資訊
    Player player = query("select coins, level from player where player_id = {0}", playerId);
    //根據玩家當前資訊及其他資訊,計算新的金幣數
    Long newCoins = ……;
    //更新金幣數
    update("update player set coins = {0} where player_id = {1}", newCoins, playerId);
}

 

為了避免這個問題,悲觀鎖透過加鎖解決這個問題,程式碼如下所示。在查詢玩家資訊時,使用select …… for update進行查詢;該查詢陳述句會為該玩家資料加上排它鎖,直到事務提交或回滾時才會釋放排它鎖;在此期間,如果其他執行緒試圖更新該玩家資訊或者執行select for update,會被阻塞。

 

@Transactional
public void updateCoins(Integer playerId){
    //根據player_id查詢玩家資訊(加排它鎖)
    Player player = queryForUpdate("select coins, level from player where player_id = {0} for update", playerId);
    //根據玩家當前資訊及其他資訊,計算新的金幣數
    Long newCoins = ……;
    //更新金幣數
    update("update player set coins = {0} where player_id = {1}", newCoins, playerId);
}

 

版本號機制則是另一種思路,它為玩家資訊增加一個欄位:version。在初次查詢玩家資訊時,同時查詢出version資訊;在執行update操作時,校驗version是否發生了變化,如果version變化,則不進行更新。

 

@Transactional
public void updateCoins(Integer playerId){
    //根據player_id查詢玩家資訊,包含version資訊
    Player player = query("select coins, level, version from player where player_id = {0}", playerId);
    //根據玩家當前資訊及其他資訊,計算新的金幣數
    Long newCoins = ……;
    //更新金幣數,條件中增加對version的校驗
    update("update player set coins = {0} where player_id = {1} and version = {2}", newCoins, playerId, player.version);
}

 

三、優缺點和適用場景

 

樂觀鎖和悲觀鎖並沒有優劣之分,它們有各自適合的場景;下麵從兩個方面進行說明。

 

1、功能限制

 

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

 

例如,CAS只能保證單個變數操作的原子性,當涉及到多個變數時,CAS是無能為力的,而synchronized則可以透過對整個程式碼塊加鎖來處理。再比如版本號機制,如果query的時候是針對錶1,而update的時候是針對錶2,也很難透過簡單的版本號來實現樂觀鎖。

 

2、競爭激烈程度

 

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

 

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

 

四、面試官追問:樂觀鎖加鎖嗎?

 

筆者在面試時,曾遇到面試官如此追問。下麵是我對這個問題的理解:

 

  • 樂觀鎖本身是不加鎖的,只是在更新時判斷一下資料是否被其他執行緒更新了;AtomicInteger便是一個例子。
  • 有時樂觀鎖可能與加鎖操作合作,例如,在前述updateCoins()的例子中,MySQL在執行update時會加排它鎖。但這隻是樂觀鎖與加鎖操作合作的例子,不能改變“樂觀鎖本身不加鎖”這一事實。

 

五、面試官追問:CAS有哪些缺點?

 

面試到這裡,面試官可能已經中意你了。不過面試官準備對你發起最後的進攻:你知道CAS這種實現方式有什麼缺點嗎?

 

下麵是CAS一些不那麼完美的地方:

 

1、ABA問題

 

假設有兩個執行緒——執行緒1和執行緒2,兩個執行緒按照順序進行以下操作:

 

  1. 執行緒1讀取記憶體中資料為A;
  2. 執行緒2將該資料修改為B;
  3. 執行緒2將該資料修改為A;
  4. 執行緒1對資料進行CAS操作

 

在第(4)步中,由於記憶體中資料仍然為A,因此CAS操作成功,但實際上該資料已經被執行緒2修改過了。這就是ABA問題。

 

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

 

對於ABA問題,比較有效的方案是引入版本號,記憶體中的值每發生一次變化,版本號都+1;在進行CAS操作時,不僅比較記憶體中的值,也會比較版本號,只有當二者都沒有變化時,CAS才能執行成功。Java中的AtomicStampedReference類便是使用版本號來解決ABA問題的。

 

2、高競爭下的開銷問題

 

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

 

3、功能限制

 

CAS的功能是比較受限的,例如CAS只能保證單個變數(或者說單個記憶體值)操作的原子性,這意味著:(1)原子性不一定能保證執行緒安全,例如在Java中需要與volatile配合來保證執行緒安全;(2)當涉及到多個變數(記憶體值)時,CAS也無能為力。

 

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

 

六、總結

 

本文介紹了樂觀鎖和悲觀鎖的基本概念、實現方式(含實體)、適用場景,以及可能遇到的面試官追問,希望能夠對你面試有幫助。最後,祝大家都拿到心儀的offer!

    已同步到看一看
    贊(0)

    分享創造快樂