阿里二面:Java中鎖的分類有哪些?你能說全嗎?

码农Academy發表於2024-03-25

引言

在多執行緒併發程式設計場景中,鎖作為一種至關重要的同步工具,承擔著協調多個執行緒對共享資源訪問秩序的任務。其核心作用在於確保在特定時間段內,僅有一個執行緒能夠對資源進行訪問或修改操作,從而有效地保護資料的完整性和一致性。鎖作為一種底層的安全構件,有力地防止了競態條件和資料不一致性的問題,尤其在涉及多執行緒或多程序共享資料的複雜場景中顯得尤為關鍵。

而瞭解鎖的分類,能幫助我們何種業務場景下使用選擇哪種鎖。

Java中鎖分類.jpg

基於鎖的獲取與釋放方式分類

計劃於所得獲取與釋放方式進行分類,Java中的鎖可以分為:顯式鎖和隱式鎖。

隱式鎖

Java中的隱式鎖(也稱為內建鎖或自動鎖)是透過使用synchronized關鍵字實現的一種執行緒同步機制。當一個執行緒進入被synchronized修飾的方法或程式碼塊時,它會自動獲得物件級別的鎖,退出該方法或程式碼塊時則會自動釋放這把鎖。

在Java中,隱式鎖的實現機制主要包括以下兩種型別:

  1. 互斥鎖(Mutex): 雖然Java標準庫並未直接暴露作業系統的互斥鎖提供使用,但在Java虛擬機器對synchronized關鍵字處理的底層實現中,當鎖競爭激烈且必須升級為重量級鎖時,會利用作業系統的互斥量機制來確保在同一時刻僅允許一個執行緒持有鎖,從而實現嚴格的執行緒互斥控制。

  2. 內部鎖(Intrinsic Lock)或監視器鎖(Monitor Lock): Java語言為每個物件內建了一個監視器鎖,這是一個更高階別的抽象。我們可以透過使用synchronized關鍵字即可便捷地管理和操作這些鎖。當一個執行緒訪問被synchronized修飾的方法或程式碼塊時,會自動獲取相應物件的監視器鎖,並在執行完畢後自動釋放,這一過程對使用者透明,故被稱為隱式鎖。每個Java物件均與一個監視器鎖關聯,以此來協調對該物件狀態訪問的併發控制。

優點:

  1. 簡潔易用:程式設計師無需手動管理鎖的獲取和釋放過程,降低了程式設計複雜性。
  2. 安全性:隱式鎖確保了執行緒安全,避免了競態條件,因為一次只有一個執行緒能持有鎖並執行同步程式碼塊。
  3. 異常處理下的自動釋放:即使在同步程式碼塊中丟擲異常,隱式鎖也會在異常退出時被釋放,防止死鎖。

缺點:

  1. 鎖定粒度:隱式鎖的粒度通常是物件級別,這意味著如果一個大型物件的不同部分實際上可以獨立地被不同執行緒訪問,但由於整個物件被鎖定,可能導致不必要的阻塞和較低的併發效能。
  2. 不靈活:相對於顯示鎖(如java.util.concurrent.locks.Lock介面的實現類),隱式鎖的功能較有限,無法提供更細粒度的控制,如嘗試獲取鎖、定時等待、可中斷的獲取鎖等高階特性。
  3. 鎖競爭影響:在高併發環境下,若多個執行緒競爭同一把鎖,可能會引發“鎖爭用”,導致效能下降,特別是在出現鎖鏈和死鎖的情況下。

適用場景: 隱式鎖適用於相對簡單的多執行緒同步需求,尤其是在只需要保護某個物件狀態完整性,且無需過多關注鎖策略靈活性的場合。對於要求更高併發性和更復雜鎖管理邏輯的應用場景,顯示鎖通常是一個更好的選擇。

顯式鎖

顯式鎖是由java.util.concurrent.locks.Lock介面及其諸多實現類提供的同步機制,相較於透過synchronized關鍵字實現的隱式鎖機制,顯式鎖賦予開發者更為精細和靈活的控制能力,使其能夠在多執行緒環境中精準掌控同步動作。顯式鎖的核心作用在於確保在任何時刻僅有一個執行緒能夠訪問關鍵程式碼段或共享資料,從而有效防止資料不一致性問題和競態條件。

相較於隱式鎖,顯式鎖提供了更為多樣化的鎖操作選項,包括但不限於支援執行緒在等待鎖時可被中斷、根據先後順序分配鎖資源的公平鎖與非公平鎖機制,以及能夠設定鎖獲取等待時間的定時鎖功能。這些特性共同增強了顯式鎖在面對複雜併發場景時的適應性和可調控性,使之成為解決高度定製化同步需求的理想工具。

日常開發中,常見的顯式鎖分類有如下幾種:

  1. ReentrantLock:可重入鎖,繼承自Lock介面,支援可中斷鎖、公平鎖和非公平鎖的選擇。可重入意味著同一個執行緒可以多次獲取同一執行緒持有的鎖。
  2. ReentrantReadWriteLock:讀寫鎖,提供了兩個鎖,一個是讀鎖,允許多個執行緒同時讀取;另一個是寫鎖,同一時間內只允許一個執行緒寫入,寫鎖會排斥所有讀鎖和寫鎖。
  3. StampedLock:帶版本戳的鎖,提供了樂觀讀、悲觀讀寫模式,適合於讀多寫少的場景,可以提升系統效能。

優點:

  1. 靈活控制:顯式鎖提供了多種獲取和釋放鎖的方式,可以根據實際需求進行選擇,比如中斷等待鎖的執行緒,設定超時獲取鎖等。
  2. 效能最佳化:在某些特定場景下,顯式鎖可以提供比隱式鎖更好的效能表現,尤其是當需要避免死鎖或最佳化讀多寫少的情況時。
  3. 公平性選擇:顯式鎖允許建立公平鎖,按照執行緒請求鎖的順序給予響應,保證所有執行緒在等待鎖時有一定的公平性。

缺點:

  1. 使用複雜:相較於隱式鎖,顯式鎖需要手動呼叫lock()unlock()方法,增加了程式設計複雜性,如果不正確地使用(如忘記釋放鎖或未捕獲異常導致鎖未釋放),容易造成死鎖或其他併發問題。
  2. 效能開銷:在某些簡單場景下,顯式鎖的額外API呼叫和鎖狀態管理可能帶來額外的效能開銷,尤其當公平鎖啟用時,由於需要維護執行緒佇列和執行緒排程,可能會影響整體效能。
  3. 錯誤可能性:由於顯式鎖的操作更加細緻,因此更容易出錯,開發者需要具備較高的併發程式設計意識和技能才能妥善使用。

基於對資源的訪問許可權

按照執行緒對資源的訪問許可權來分類,可以將鎖分為:獨佔鎖(Exclusive Lock)和共享鎖(Shared Lock)。

獨佔鎖

獨佔鎖(Exclusive Lock),又稱排他鎖或寫鎖,是一種同步機制,它確保在任一時刻,最多隻有一個執行緒可以獲得鎖並對受保護的資源進行訪問或修改。一旦執行緒獲得了獨佔鎖,其他所有試圖獲取同一鎖的執行緒將被阻塞,直到擁有鎖的執行緒釋放鎖為止。獨佔鎖主要用於保護那些在併發環境下會被多個執行緒修改的共享資源,確保在修改期間不會有其他執行緒干擾,從而維護資料的一致性和完整性。

對於獨佔鎖就像圖書館裡的某本書,這本書只有唯一的一本。當一個讀者想要借閱這本書時,他會去圖書管理員那裡登記並拿到一個“借書憑證”(相當於獨佔鎖)。此時,這本書就被鎖定了,其他讀者無法借閱這本書,直至第一個讀者歸還書本並交回“借書憑證”。這就像是執行緒獲得了獨佔鎖,只有擁有鎖的執行緒可以修改或操作資源(書本),其他執行緒必須等待鎖的釋放才能執行相應的操作。

而獨佔鎖的實現方式,主要有如下兩種:

  1. synchronized關鍵字:透過synchronized關鍵字實現的隱式鎖,它是獨佔鎖的一種常見形式,任何時刻只有一個執行緒可以進入被synchronized修飾的方法或程式碼塊。
  2. ReentrantLock:可重入的獨佔鎖,提供了更多的控制方式,包括可中斷鎖、公平鎖和非公平鎖等。

優點:

  1. 簡單易用:對於synchronized關鍵字,語法簡單直觀,易於理解和使用。
  2. 執行緒安全:確保了對共享資源的獨佔訪問,避免了併發環境下的資料競爭問題。
  3. 可重入性:像ReentrantLock這樣的鎖,支援同一個執行緒重複獲取同一把鎖,提高了執行緒間協作的便利性。

缺點:

  1. 粒度固定:對於synchronized,鎖的粒度是固定的,無法動態調整,可能導致不必要的阻塞。
  2. 缺乏靈活性:隱式鎖不能主動中斷等待鎖的執行緒,也無法設定超時等待。
  3. 效能瓶頸:在高度競爭的環境中,synchronized可能會造成上下文切換頻繁,效率低下;而顯式鎖雖提供了更靈活的控制,但如果使用不當也可能導致額外的效能損失。

共享鎖

共享鎖(Shared Lock)也稱為讀鎖(Read Lock),是一種多執行緒或多程序併發控制的同步機制,它允許多個執行緒同時讀取共享資源,但不允許任何執行緒修改資源。在資料庫系統和併發程式設計中廣泛使用,確保在併發讀取場景下資料的一致性。

共享鎖就像圖書館裡有一套多人閱讀的雜誌合訂本,這套合訂本可以被多個讀者同時翻閱,但是任何人都不能帶走或在上面做標記。當一個讀者要閱讀時,他會向圖書管理員申請“閱讀憑證”(相當於共享鎖)。如果有多個讀者想閱讀,圖書管理員會給他們每人一份閱讀憑證,這樣大家都可以坐在閱覽室裡一起閱讀這套合訂本,但是都不能單獨佔有或改變它。在併發程式設計中,多個執行緒可以同時獲取共享鎖進行讀取操作,但都不能修改資料,這就像是多個執行緒同時持有共享鎖讀取資源,但不允許在此期間進行寫操作。

實現共享鎖的關鍵機制是讀寫鎖(ReadWriteLock),這是一種特殊型別的共享鎖機制,它巧妙地將對共享資源的訪問許可權劃分為了讀取許可權和寫入許可權兩類。在讀寫鎖的控制下,多個執行緒可以同時進行對共享資料的讀取操作,形成併發讀取,而對資料的寫入操作則採取獨佔式處理,確保同一時間段內僅有一個執行緒執行寫入操作。在寫入操作正在進行時,無論是其他的讀取操作還是寫入操作都會被暫時阻塞,直至寫操作結束。

讀寫鎖包含兩種鎖模式:讀鎖(ReadLock)寫鎖(WriteLock)。當多個執行緒需要訪問同一份共享資料時,只要這些執行緒都是進行讀取操作,則都能成功獲取並持有讀鎖,從而實現並行讀取。然而,一旦有執行緒嘗試進行寫入操作,那麼不論是其他正在執行讀取的執行緒還是準備進行寫入的執行緒,都無法繼續獲取讀鎖或寫鎖,直至當前寫操作全部完成並釋放寫鎖。這樣,讀寫鎖有效地平衡了讀取密集型任務的併發性和寫入操作的原子性要求。

優點:

  1. 提高併發性:對於讀多寫少的場景,共享鎖可以使多個讀取操作並行執行,顯著提高系統的併發效能。
  2. 資料保護:在讀取階段避免了資料被意外修改,確保讀取到的是穩定的資料狀態。

缺點:

  1. 寫操作阻塞:只要有共享鎖存在,其他事務就不能對資料加排他鎖(Exclusive Lock)進行寫操作,這可能導致寫操作長時間等待,降低系統的寫入效能。
  2. 可能導致死鎖:在複雜的事務互動中,如果沒有合適的鎖管理策略,共享鎖可能會參與到死鎖迴圈中,導致事務無法正常完成。
  3. 資料一致性問題:雖然共享鎖能保護讀取過程中資料不被修改,但並不能阻止資料在讀取操作之後立即被其他事務修改,對於要求強一致性的應用可能不夠。

如以下使用共享鎖示例:

public class SharedResource {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
    private int data;

    public void modifyData(int newData) {
        // 獲取寫鎖(獨佔鎖),在同一時刻只有一個執行緒可以獲取寫鎖
        writeLock.lock();
        System.out.println(Thread.currentThread().getName() + " Modify Data");
        try {
            // 修改資料
            this.data = newData;
            // 資料修改相關操作...
        } finally {
            // 無論如何都要確保解鎖
            writeLock.unlock();
        }
    }

    public int readData() {
        // 獲取讀鎖(共享鎖),允許多個執行緒同時獲取讀鎖進行讀取操作
        readLock.lock();
        System.out.println(Thread.currentThread().getName() + " Read Data");
        try {
            // 讀取資料,此時其他讀取執行緒也可以同時讀取,但不允許寫入
            return this.data;
        }finally {
            // 釋放讀鎖
            readLock.unlock();
        }
    }

    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread reader1 = new Thread(() -> System.out.println("Reader 1 reads: " + resource.readData()), "Reader1");
        Thread reader2 = new Thread(() -> System.out.println("Reader 2 reads: " + resource.readData()), "Reader1");

        Thread writer = new Thread(() -> resource.modifyData(42), "Writer1");

        reader1.start();
        reader2.start();
        writer.start();

        // 等待所有執行緒執行完成
        try {
            reader1.join();
            reader2.join();
            writer.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

列印結果:

image.png

在這個示例中,使用了 ReentrantReadWriteLock 來控制對 data 的讀寫操作。readData() 方法使用讀鎖,允許多個執行緒同時讀取資料,而 modifyData() 方法使用寫鎖,確保同一時間只有一個執行緒可以修改資料。這樣就可以在併發場景下既保證資料讀取的併發性,又避免了資料因併發寫入而造成的不一致性問題。

基於鎖的佔有權是否可重入

按照鎖的佔有權是否可以重入,可以把鎖分為:可重入鎖以及不可重入鎖。

可重入鎖

可重入鎖(Reentrant Lock)作為一種執行緒同步機制,具備獨特的重入特性,即當執行緒已經獲取了鎖後,它可以再次請求併成功獲得同一把鎖,從而避免了在遞迴呼叫或巢狀同步塊中產生的死鎖風險。這意味著在執行鎖保護的程式碼區域時,即便呼叫了其他同樣被該鎖保護的方法或程式碼片段,持有鎖的執行緒也能順利完成操作。

在多執行緒環境下,可重入鎖扮演著至關重要的角色,它嚴格限制了同一時間只能有一個執行緒訪問特定的臨界區,有效防止了併發訪問引發的資料不一致和競態條件問題。此外,透過允許執行緒在持有鎖的狀態下重新獲取該鎖,可重入鎖巧妙地解決了同類鎖之間由於互相等待而形成的潛在死鎖狀況,從而提升了多執行緒同步的安全性和可靠性。

可重入鎖主要可以透過以下三種方式實現:

  1. synchronized關鍵字:synchronized關鍵字實現的隱式鎖就是一種可重入鎖。
  2. ReentrantLockjava.util.concurrent.locks.ReentrantLock類實現了Lock介面,提供了顯式的可重入鎖功能,它允許更細粒度的控制,例如支援公平鎖、非公平鎖,以及可中斷鎖、限時鎖等。
  3. ReentrantReadWriteLockReentrantReadWriteLock 是一種特殊的可重入鎖,它透過讀寫鎖的設計,既實現了可重入特性的執行緒安全,又能高效地處理讀多寫少的併發場景。

優點:

  1. 執行緒安全性:確保了在多執行緒環境下的資料一致性。
  2. 可重入性:簡化了程式碼編寫,特別是在遞迴呼叫或巢狀同步塊的場景中。
  3. 靈活性:顯式可重入鎖(如ReentrantLock)提供了更多控制選項,如嘗試獲取鎖、設定鎖的公平性、中斷等待執行緒等。

缺點:

  1. 使用複雜性:相比於隱式鎖(synchronized),顯式鎖需要手動管理鎖的獲取和釋放,增加了程式設計複雜性和出錯機率。
  2. 效能開銷:在某些情況下,顯式鎖可能因為額外的API呼叫和狀態管理而帶來一定的效能開銷。
  3. 死鎖風險:如果開發者不謹慎地管理鎖的獲取和釋放順序,或者濫用鎖的特性,可能會導致死鎖的發生。尤其是對於顯式鎖,如果未正確釋放,可能會導致資源無法回收。

以下為可重入鎖使用示例:

public class ReentrantLockExample {

    private final Lock lock = new ReentrantLock();

    // 假設這是一個需要同步訪問的共享資源
    private int sharedResource;

    public void increment() {
        // 獲取鎖
        lock.lock();

        try {
            // 在鎖保護下執行操作
            sharedResource++;

            // 這裡假設有個內部方法也需要同步訪問sharedResource
            doSomeOtherWork();
        } finally {
            // 無論發生什麼情況,最後都要釋放鎖
            lock.unlock();
        }
    }

    // 可重入的內部方法
    private void doSomeOtherWork() {
        // 因為當前執行緒已經持有鎖,所以可以再次獲取
        lock.lock();

        try {
            // 執行依賴於sharedResource的操作
            sharedResource -= 1;
            System.out.println("Inner method executed with sharedResource: " + sharedResource);
        } finally {
            // 釋放鎖
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample example = new ReentrantLockExample();

        Thread thread1 = new Thread(example::increment);
        Thread thread2 = new Thread(example::increment);

        thread1.start();
        thread2.start();

        // 等待兩個執行緒執行完畢
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 輸出最終的sharedResource值
        System.out.println("Final sharedResource value: " + example.sharedResource);
    }
}

image.png

示例中,increment()方法和內部的doSomeOtherWork()方法都需要在獲取鎖的情況下執行。由於ReentrantLock是可重入的,所以在increment()方法內部呼叫doSomeOtherWork()時,執行緒仍然可以成功獲取鎖,並繼續執行操作。當所有操作完成時,透過finally塊確保了鎖的釋放。這樣可以避免死鎖,並確保在多執行緒環境下對共享資源的訪問是執行緒安全的。

不可重入鎖

不可重入鎖(Non-reentrant Lock)是一種執行緒同步機制,它的核心特徵在於禁止同一個執行緒在已經持有鎖的前提下再度獲取相同的鎖。若一個執行緒已取得不可重入鎖,在其執行路徑中遇到需要再次獲取該鎖的場景時,該執行緒將會被迫等待,直至原先獲取的鎖被釋放,其他執行緒才有可能獲取並執行相關臨界區程式碼。

此類鎖機制同樣服務於多執行緒環境下的資源共享保護,旨在確保同一時間內僅有單一執行緒能夠訪問臨界資源,從而有效規避資料不一致性和競態條件等問題。相較於可重入鎖,不可重入鎖在遞迴呼叫或涉及鎖巢狀的複雜同步場景下表現出侷限性,因其可能導致執行緒阻塞和潛在的死鎖風險,降低了執行緒同步的靈活性和安全性。在實際開發中,除非有特殊的需求或場景約束,否則更建議採用可重入鎖以實現更為穩健高效的執行緒同步控制。

在Java標準庫中並沒有直接提供名為“不可重入鎖”的內建鎖,通常我們會透過對比ReentrantLock(可重入鎖)來理解不可重入鎖的概念。理論上,任何不具備可重入特性的鎖都可以認為是不可重入鎖。但在實際應用中,Java的synchronized關鍵字修飾的方法或程式碼塊在早期版本中曾經存在過類似不可重入的行為,但在目前Java的所有版本中,synchronized關鍵字所實現的鎖實際上是可重入的。

優點:

  1. 簡單性:從實現角度來看,不可重入鎖可能在設計和實現上相對簡單,因為它不需要處理遞迴鎖定的複雜性。

缺點:

  1. 容易引發死鎖:如果在一個執行緒已持有不可重入鎖的情況下,它又試圖再次獲取同一把鎖,那麼就可能導致死鎖。因為執行緒自身無法進一步推進,也無法釋放已持有的鎖,其他執行緒也無法獲取鎖,從而形成死鎖狀態。
  2. 限制性較強:不可重入鎖極大地限制了執行緒的自由度,特別是在遞迴呼叫或含有巢狀鎖的複雜同步結構中,往往無法滿足需求。
  3. 執行緒棧跟蹤複雜:對於程式設計者而言,需要更加小心地管理鎖的層次結構,以防止無意間陷入死鎖或資源浪費的情況。

基於鎖的獲取公平性

按照獲取鎖的公平性,也即請求順序,將鎖分為公平鎖盒非公平鎖。

公平鎖

公平鎖是一種執行緒排程策略,在多執行緒環境下,當多個執行緒嘗試獲取鎖時,鎖的分配遵循“先請求先服務”(First-Come, First-Served, FCFS)原則,即按照執行緒請求鎖的順序來分配鎖資源。這意味著等待時間最長的執行緒將優先獲得鎖。公平鎖可以有效避免某個執行緒長期得不到鎖而導致的飢餓現象,所有執行緒都有平等獲取鎖的機會。它確保了執行緒的排程更加有序,減少了不公平競爭導致的不確定性。

公平鎖的實現,可以透過java.util.concurrent.locks.ReentrantLock的建構函式傳入true引數,可以建立一個公平的ReentrantLock例項。

ReentrantLock fairLock = new ReentrantLock(true); //建立一個公平鎖

優點:

  1. 公平性:所有執行緒都遵循先來後到的原則,不會出現新來的執行緒總是搶佔鎖的現象,提高了系統的公平性和穩定性。
  2. 避免執行緒飢餓:減少或消除了由於鎖的不公平分配而導致的執行緒長時間等待鎖的情況。

缺點:

  1. 效能開銷:公平鎖在每次釋放鎖後,都需要檢查是否有等待時間更長的執行緒,這通常涉及到執行緒排程的額外開銷,可能會降低系統的整體併發效能。
  2. 執行緒上下文切換頻繁:為了實現公平性,可能需要頻繁地進行執行緒上下文切換,而這本身就是一種相對昂貴的操作。
  3. 可能導致“convoy effect”:即大量執行緒因等待前面執行緒釋放鎖而形成佇列,即使後來的執行緒只需要很短時間處理,也會不得不等待整個佇列中的執行緒依次完成,從而降低了系統的吞吐量。

以下使用公平鎖示例:

public class FairLockExample {

    private final ReentrantLock fairLock = new ReentrantLock(true); // 使用true引數建立公平鎖

    public void criticalSection() {
        fairLock.lock(); // 獲取公平鎖

        try {
            // 在此區域內的程式碼是臨界區,同一時間只有一個執行緒可以執行
            System.out.println(Thread.currentThread().getName() + " entered the critical section at " + LocalDateTime.now());
            // 模擬耗時操作
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            fairLock.unlock(); // 釋放公平鎖
        }
    }

    public static void main(String[] args) {
        final FairLockExample example = new FairLockExample();

        Runnable task = () -> {
            example.criticalSection();
        };

        // 建立並啟動多個執行緒
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(task);
            t.start();
        }
    }
}

image.png
在這個示例中,我們建立一個公平鎖,我們建立了多個執行緒,每個執行緒都在執行criticalSection方法,該方法內部的程式碼塊受到公平鎖的保護,因此在任何時候只有一個執行緒能在臨界區內執行。當多個執行緒嘗試獲取鎖時,它們會按照請求鎖的順序來獲取鎖,確保執行緒排程的公平性。

非公平鎖

非公平鎖是一種執行緒排程策略,在多執行緒環境下,當多個執行緒嘗試獲取鎖時,鎖的分配不遵循“先請求先服務”(First-Come, First-Served, FCFS)原則,而是允許任何等待鎖的執行緒在鎖被釋放時嘗試獲取,即使其他執行緒已經在等待佇列中等待更長時間。非公平鎖在某些場景下可以提高系統的併發效能,因為它允許剛釋放鎖的執行緒或者其他新到達的執行緒立刻獲取鎖,而不是強制排隊等待。

實現方式也同公平鎖,也是透過java.util.concurrent.locks.ReentrantLock的建構函式,但是我們要傳入false引數,可以建立一個非公平的ReentrantLock例項。

ReentrantLock fairLock = new ReentrantLock(false); //建立一個非公平鎖

優點:

  1. 效能最佳化:非公平鎖在某些條件下可能會提供更高的系統吞吐量,因為它允許執行緒更快地獲取鎖,減少執行緒上下文切換次數,尤其在鎖競爭不激烈的場景下,這種效果更為明顯。

缺點:

  1. 執行緒飢餓:非公平鎖可能導致某些執行緒長時間無法獲取鎖,即存線上程飢餓的風險,因為新到達的執行緒可能連續多次獲取鎖,而早前就已經在等待的執行緒始終得不到執行機會。
  2. 難以預測的執行緒排程:非公平鎖會導致執行緒排程的不確定性增大,不利於系統的穩定性和效能分析。
  3. 潛在的連鎖反應:非公平鎖可能導致執行緒之間的依賴關係變得複雜,可能會引發連鎖反應,影響整體系統的效能和穩定性。

基於對共享資源的訪問方式

我們常說或者常用的悲觀鎖以及樂觀鎖就是以對共享資源的訪問方式來區分的。

悲觀鎖

悲觀鎖(Pessimistic Lock)是一種併發控制策略,它假設在併發環境下,多個執行緒對共享資源的訪問極有可能發生衝突,因此在訪問資源之前,先嚐試獲取並鎖定資源,直到該執行緒完成對資源的訪問並釋放鎖,其他執行緒才能繼續訪問。悲觀鎖的主要作用是在多執行緒環境中防止資料被併發修改,確保資料的一致性和完整性。當一個執行緒獲取了悲觀鎖後,其他執行緒必須等到鎖釋放後才能訪問相應資源,從而避免了資料競態條件和髒讀等問題。悲觀鎖適合寫操作較多且讀操作較少的併發場景。

而悲觀鎖的實現可以透過synchronized關鍵字實現的物件鎖或類鎖。或者透過java.util.concurrent.locks.Lock介面的實現類,如ReentrantLock

悲觀鎖雖然在併發場景下資料的一致性和完整性。但是他卻有一些缺點,例如:

  1. 效能開銷:頻繁的加鎖和解鎖操作可能帶來較大的效能消耗,尤其是在高併發場景下,可能導致執行緒頻繁上下文切換。
  2. 可能導致死鎖:如果多個執行緒間的鎖獲取順序不當,容易造成死鎖。
  3. 資源利用率低:在讀多寫少的場景下,悲觀鎖可能導致大量的讀取操作等待,降低系統的併發能力和響應速度。

以下我們使用顯式鎖ReentrantLock實現一個悲觀鎖的示例:

import java.util.concurrent.locks.ReentrantLock;

public class Account {
    private final ReentrantLock lock = new ReentrantLock();
    private double balance;

    public void deposit(double amount) {
        lock.lock();
        try {
            // 持有鎖進行存款操作
            balance += amount;
            // 更新賬戶餘額的其他邏輯...
        } finally {
            lock.unlock(); // 保證鎖一定會被釋放
        }
    }

    public void withdraw(double amount) {
        lock.lock();
        try {
            // 持有鎖進行取款操作
            if (balance >= amount) {
                balance -= amount;
                // 更新賬戶餘額的其他邏輯...
            }
        } finally {
            lock.unlock();
        }
    }
}

樂觀鎖

樂觀鎖並不是Java本身提供的某種內建鎖機制,而是指一種併發控制策略,它基於樂觀假設:即在併發訪問環境下,認為資料競爭不太可能發生,所以在讀取資料時並不會立即加鎖。樂觀鎖適用於讀多寫少的場景或者併發較少的場景。

Java中的樂觀鎖透過CAS(Compare and Swap / Compare and Set)演算法實現,而資料庫層面我們常使用版本號或者時間戳等進行控制。

CAS(Compare and Swap / Compare and Set): Java提供了java.util.concurrent.atomic包中的原子類,如AtomicIntegerAtomicLong等,它們透過CAS操作來實現樂觀鎖。CAS操作是一個原子指令,它只會修改資料,當且僅當該資料的當前值等於預期值時才進行修改。例如,AtomicInteger中的compareAndSet方法就是在樂觀鎖思想下實現的一種無鎖化更新操作。

import java.util.concurrent.atomic.AtomicInteger;

AtomicInteger counter = new AtomicInteger(0);

// 樂觀鎖更新示例
public void incrementCounter() {
    while (true) {
        int expected = counter.get();
        int updated = expected + 1;
        if (counter.compareAndSet(expected, updated)) {
            // 更新成功,退出迴圈
            break;
        }
        // 更新失敗,意味著有其他執行緒在此期間改變了值,繼續嘗試
    }
}

優點:

  • 更高的併發效能:因為在讀取階段不加鎖,所以理論上可以支援更多的併發讀取操作。
  • 降低死鎖可能性:因為不存在長時間的加鎖過程,從而減少了死鎖的發生機會。

缺點:

  • 衝突處理成本:如果併發更新較為頻繁,樂觀鎖會導致大量事務因併發衝突而重試甚至失敗,這在某些情況下反而會增加系統開銷。
  • 迴圈依賴問題:在遇到連續的併發更新時,樂觀鎖可能導致事務不斷重試,形成“ABA”問題(即某個值被改回原值後再次更改)。

基於鎖的升級以及最佳化

在Java中,JVM為了解決多執行緒環境下的同步問題,對鎖機制進行了最佳化,將其分為偏向鎖、輕量級鎖和重量級鎖三種狀態。

偏向鎖

偏向鎖是一種Java虛擬機器(JVM)在多執行緒環境下最佳化同步效能的鎖機制,它適用於大多數時間只有一個執行緒訪問同步程式碼塊的場景。當一個執行緒訪問同步程式碼塊時,JVM會把鎖偏向於這個執行緒,後續該執行緒在進入和退出同步程式碼塊時,無需再做任何同步操作,從而大大降低了獲取鎖和釋放鎖的開銷。偏向鎖是Java記憶體模型中鎖的三種狀態之一,位於輕量級鎖和重量級鎖之前。

優點
對於沒有或很少發生鎖競爭的場景,偏向鎖可以顯著減少鎖的獲取和釋放所帶來的效能損耗。

缺點

  • 額外儲存空間:偏向鎖會在物件頭中儲存一個偏向執行緒ID等相關資訊,這部分額外的空間開銷雖然較小,但在大規模併發場景下,累積起來也可能成為可觀的成本。

  • 鎖升級開銷:當一個偏向鎖的物件被其他執行緒訪問時,需要進行撤銷(revoke)操作,將偏向鎖升級為輕量級鎖,甚至在更高競爭情況下升級為重量級鎖。這個升級過程涉及到CAS操作以及可能的執行緒掛起和喚醒,會帶來一定的效能開銷。

  • 適用場景有限:偏向鎖最適合於絕大部分時間只有一個執行緒訪問物件的場景,這樣的情況下,偏向鎖的開銷可以降到最低,有利於提高程式效能。但如果併發程度較高,或者執行緒切換頻繁,偏向鎖就可能不如輕量級鎖或重量級鎖高效。

輕量級鎖

輕量級鎖是一種在Java虛擬機器(JVM)中實現的同步機制,主要用於提高多執行緒環境下鎖的效能。它不像傳統的重量級鎖那樣,每次獲取或釋放鎖都需要作業系統級別的互斥操作,而是儘量在使用者態完成鎖的獲取與釋放,避免了頻繁的執行緒阻塞和喚醒帶來的開銷。輕量級鎖的作用主要是減少執行緒上下文切換的開銷,透過自旋(spin-wait)的方式讓執行緒在一段時間內等待鎖的釋放,而不是立即掛起執行緒,這樣在鎖競爭不是很激烈的情況下,能夠快速獲得鎖,提高程式的響應速度和併發效能。

在Java中,輕量級鎖主要作為JVM鎖狀態的一種,它介於偏向鎖和重量級鎖之間。當JVM發現偏向鎖不再適用(即鎖的競爭不再侷限於單個執行緒)時,會將鎖升級為輕量級鎖。

輕量級鎖適用於同步程式碼塊執行速度快、執行緒持有鎖的時間較短且鎖競爭不激烈的場景,如短期內只有一個或少數幾個執行緒競爭同一執行緒資源的情況。

在Java中,輕量級鎖的具體實現體現在java.util.concurrent.locks包中的Lock介面的一個具體實現:java.util.concurrent.locks.ReentrantLock,它支援可配置為公平或非公平模式的輕量級鎖機制,當使用預設建構函式時,預設是非公平鎖(類似於輕量級鎖的非公平性質)。不過,JVM的內建synchronized關鍵字在JDK 1.6之後引入了鎖升級機制,也包含了偏向鎖和輕量級鎖的最佳化。

優點

  • 低開銷:輕量級鎖透過CAS操作嘗試獲取鎖,避免了重量級鎖中涉及的執行緒掛起和恢復等高昂開銷。
  • 快速響應:在無鎖競爭或者鎖競爭不激烈的情況下,輕量級鎖使得執行緒可以迅速獲取鎖並執行同步程式碼塊。

缺點

  • 自旋消耗:當鎖競爭激烈時,執行緒可能會長時間自旋等待鎖,這會消耗CPU資源,導致效能下降。
  • 升級開銷:如果自旋等待超過一定閾值或者鎖競爭加劇,輕量級鎖會升級為重量級鎖,這個升級過程本身也有一定的開銷。

重量級鎖

重量級鎖是指在多執行緒程式設計中,為了保護共享資源而採取的一種較為傳統的互斥同步機制,通常涉及到作業系統的互斥量(Mutex)或者監視器鎖(Monitor)。在Java中,透過synchronized關鍵字實現的鎖機制在預設情況下就是重量級鎖。確保任何時刻只有一個執行緒能夠訪問被鎖定的資源或程式碼塊,防止資料競爭和不一致。保證了執行緒間的協同工作,確保在併發環境下執行的執行緒按照預定的順序或條件進行操作。

在Java中,重量級鎖主要指的是由synchronized關鍵字實現的鎖,它在JVM內部由Monitor實現,屬於內建的鎖機制。另外,java.util.concurrent.locks包下的ReentrantLock等類也可實現重量級鎖,這些鎖可以根據需要調整為公平鎖或非公平鎖。

優點

  • 強一致性:重量級鎖提供了最強的執行緒安全性,確保在多執行緒環境下資料的完整性和一致性。
  • 簡單易用synchronized關鍵字的使用簡潔明瞭,不易出錯。

缺點

  • 效能開銷大:獲取和釋放重量級鎖時需要作業系統介入,可能涉及執行緒的掛起和喚醒,造成上下文切換,這對於頻繁鎖競爭的場景來說效能代價較高。
  • 延遲較高:執行緒獲取不到鎖時會被阻塞,導致等待時間增加,進而影響系統響應速度。

重量級鎖適用於

  • 高併發且鎖競爭激烈的場景,因為在這種情況下,保證資料的正確性遠比微小的效能損失重要。
  • 對於需要長時間持有鎖的操作,因為短暫的上下文切換成本相對於長時間的操作來說是可以接受的。
  • 當同步程式碼塊中涉及到IO操作、資料庫訪問等耗時較長的任務時,重量級鎖能夠較好地防止其它執行緒餓死。

在Java中,偏向鎖、輕量級鎖和重量級鎖之間的轉換是Java虛擬機器(JVM)為了最佳化多執行緒同步效能而設計的一種動態調整機制。轉換條件如下:

  1. 偏向鎖到輕量級鎖的轉換
    當有第二個執行緒嘗試獲取已經被偏向的鎖時,偏向鎖就會失效並升級為輕量級鎖。這是因為偏向鎖假定的是隻有一個執行緒反覆獲取鎖,如果有新的執行緒參與競爭,就需要進行鎖的升級以保證執行緒間的互斥。

  2. 輕量級鎖到重量級鎖的轉換
    當輕量級鎖嘗試獲取失敗(CAS操作失敗),即出現了鎖競爭時,JVM會認為當前鎖的持有者無法很快釋放鎖,因此為了避免後續執行緒無休止地自旋等待,會將輕量級鎖升級為重量級鎖。這個轉換過程通常發生在自旋嘗試獲取鎖達到一定次數(自旋次數是可配置的)或者系統處於高負載狀態時。

  3. 偏向鎖到重量級鎖的轉換
    如果當前執行緒不是偏向鎖指向的執行緒,那麼首先會撤銷偏向鎖(解除偏向狀態),然後升級為輕量級鎖,之後再根據輕量級鎖的規則判斷是否需要進一步升級為重量級鎖。

鎖狀態的轉換是為了在不同的併發環境下,既能保證資料的正確性,又能儘可能地提高系統效能。JVM會根據實際情況自動調整鎖的狀態,無需我們手動干預。

分段鎖

分段鎖(Segmented Lock 或 Partitions Lock)是一種將資料或資源劃分為多個段(segments),並對每個段分配單獨鎖的鎖機制。這樣做的目的是將鎖的粒度細化,以便在高併發場景下提高系統的併發效能和可擴充套件性,特別是針對大型資料結構如雜湊表時非常有效。透過減少鎖的粒度,可以使得在多執行緒環境下,不同執行緒可以同時訪問不同段的資料,減小了鎖爭搶,提高了系統的並行處理能力。在大規模資料結構中,如果只有一個全域性鎖,可能會因為熱點區域引發大量的鎖競爭,分段鎖則能有效地分散鎖的壓力。

Java中,分段鎖在實現上可以基於雜湊表的分段鎖,例如Java中的ConcurrentHashMap,將整個雜湊表分割為多個段(Segment),每個段有自己的鎖,這樣多個執行緒可以同時對不同段進行操作。例外也可以基於陣列或連結串列的分段鎖,根據資料索引將資料分佈到不同的段,每段對應一個獨立的鎖。

分段鎖可以提高併發效能,減少鎖競爭,增加系統的並行處理能力。其優點:

  1. 減小鎖的粒度:透過將一個大的鎖分解為多個小鎖,確實可以提高併發程度,降低鎖的粒度,減少單點瓶頸,提高系統效能。
  2. 減少鎖衝突:確實可以降低不同執行緒間對鎖資源的競爭,減少執行緒等待時間,從而提升併發度。
  3. 提高系統的可伸縮性:透過分段,可以更好地支援分散式和叢集環境下的系統擴充套件,增強系統的併發處理能力和可擴充套件性。

分段鎖也有一些缺點:

  1. 增加了鎖的管理複雜度:確實需要額外的記憶體和複雜度來管理和維護多個鎖,確保鎖的正確使用和釋放,以及在不同分段間的一致性和可靠性。
  2. 可能導致執行緒飢餓:分段不合理或者熱點分段可能導致某些執行緒長時間等待鎖資源,出現執行緒飢餓問題。
  3. 可能會降低併發度:如果分段策略設計不當,可能會增加鎖競爭,降低併發效能。設計合理的分段策略和鎖協調機制對於分段鎖的效能至關重要,同時也增加了開發和維護的複雜度。
  4. 記憶體佔用:每個分段所需的鎖資訊和相關資料會佔用額外的記憶體空間,對系統記憶體有一定的消耗。

分段鎖適用於大資料結構的併發訪問,如高併發環境下對雜湊表的操作。以及分散式系統中,某些分散式快取或資料庫系統也採用類似的分片鎖策略來提高併發效能。

自旋鎖

自旋鎖(Spin Lock)是一種簡單的鎖機制,用於多執行緒環境中的同步控制,它的工作原理是當一個執行緒試圖獲取已經被另一個執行緒持有的鎖時,該執行緒不會立即進入睡眠狀態(阻塞),而是不斷地迴圈檢查鎖是否已經被釋放,直到獲取到鎖為止。這種“迴圈等待”的行為被稱為“自旋”。自旋鎖主要用於保證同一時刻只有一個執行緒訪問臨界區資源,防止資料競爭。相比傳統阻塞式鎖,自旋鎖在持有鎖的執行緒很快釋放鎖的情況下,可以減少執行緒的上下文切換開銷。

我們使用AtomicInteger實現一個簡單的自旋鎖:

import java.util.concurrent.atomic.AtomicInteger;

class SimpleSpinLock {
    private AtomicInteger locked = new AtomicInteger(0);

    public void lock() {
        while (locked.getAndSet(1) == 1) {
            // 自旋等待
        }
        // 已經獲取鎖,執行臨界區程式碼
    }

    public void unlock() {
        locked.set(0);
    }
}

自旋鎖優點

  • 對於持有鎖時間很短的場景,自旋鎖能有效減少執行緒上下文切換,提高系統效能。
  • 自旋鎖適用於多處理器或多核心繫統,因為在這種環境下,執行緒可以在等待鎖釋放時繼續佔用CPU時間。

自旋鎖缺點

  • 如果持有鎖的執行緒需要很長時間才能釋放鎖,自旋鎖會導致等待鎖的執行緒持續消耗CPU資源,浪費CPU週期。
  • 在單處理器系統中,自旋鎖的效率不高,因為等待鎖的執行緒無法執行任何有用的工作,只是空轉。

死鎖

說到各種鎖,就會想到死鎖問題,對於死鎖有興趣的可以參考這篇文章:
這裡就不過多贅述。

總結

本文介紹了多種Java中的鎖機制,包括可重入鎖(Reentrant Lock)、公平鎖、非公平鎖、悲觀鎖、樂觀鎖、偏向鎖、輕量級鎖、重量級鎖、分段鎖以及自旋鎖。這些鎖各有優缺點和適用場景,如可重入鎖支援遞迴鎖定,悲觀鎖確保資料一致性但可能引起效能開銷,樂觀鎖在讀多寫少場景下表現優異,偏向鎖和輕量級鎖用於最佳化單執行緒重複訪問,重量級鎖提供嚴格的互斥性,分段鎖透過減小鎖粒度提高併發效能,而自旋鎖則在短時間內獲取鎖的場景中能減少執行緒上下文切換。根據不同的併發需求和效能考量,開發者可以選擇合適的鎖機制。

本文已收錄於我的個人部落格:碼農Academy的部落格,專注分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中介軟體、架構設計、面試題、程式設計師攻略等

相關文章