你所不知道的鎖

在Java中,我們會接觸到各種各樣的鎖,包括但不限於CAS鎖,synchronized可變換鎖,可重入鎖,分佈式鎖等等,由於其功能不同,適應場景各異,所以使用起來就需要根據具體的場景進行甄別,避免因爲不合時宜的使用導致線上業務問題。

這裏爲了方便說明,我們把鎖分爲兩種類型,一種是單機鎖,另一種是分佈式鎖。

先說下單機鎖吧。

synchronized鎖機制

Synchronized是Java提供的一種內置的同步機制,它通過對“共享資源”加上鎖,來實現對資源的互斥訪問,防止多個線程同時對共享資源進行修改,從而確保數據的一致性和安全性。 Synchronized的工作原理是基於每個對象都對應一個監視器鎖(monitor),當監視器鎖被佔用,其他線程就必須等待,獲得鎖的線程退出同步代碼塊後,或進入等待狀態,釋放監視器鎖,這時等待線程則可以獲得鎖。

synchronized的用法主要有三種:

1. 同步實例方法:

這種用法會讓整個方法變成同步方法,即在同一個時間只能有一個線程執行這個方法。 例如:

 public synchronized void method(){}

2. 同步代碼塊:

這種用法會對指定的對象加鎖,其它線程要訪問這段代碼,必須先獲得指定對象的鎖。 例如:

synchronized(object){}

3. 同步靜態方法:

這種用法與同步實例方法類似,只是生效範圍爲整個類的靜態方法。 例如:

 public static synchronized void method(){}

這裏特別需要注意的地方就是鎖該往哪裏加的問題,發現很多朋友在寫代碼的時候經常會弄錯,導致的結果可能就是併發情況下發生嚴重的阻塞甚至會導致影響應用主流程的情況。

如果多個實例競爭公共的資源,則鎖應該加在靜態類或者方法上。

如果每個實例內部,併發請求爭奪實例內部的公共資源,則鎖應該加在當前實例上。每個實例內部的併發請求爭奪實例外部的公共資源,則鎖此時就應該加在靜態類或者方法上了。

比如我們經常會使用雙檢鎖去初始化一些對象,假如初始化的對象是當前實例擁有的對象,諸如xxServiceStarter, 則synchronized應該鎖在當前實例上。但是如果初始化的對象是一些公共資源,比如redis client等,則synchronized應該鎖在靜態類上。

synchronized鎖根據線程的競爭情況,可以分爲偏向鎖、輕量級鎖、重量級鎖。

1)偏向鎖:從名字上我們就可以看出這種鎖會傾向於第一次獲得它的線程,如果在接下來的運行過程中,該鎖沒有被其他的線程所訪問,那麼持有偏向鎖的線程將永遠不需要觸發同步。偏向鎖適用於只有一個線程訪問同步塊的情況。

2)輕量級鎖:當有多個線程競爭同步鎖時,偏向鎖就會升級爲輕量級鎖。輕量級鎖依賴於CAS操作 markword 來達到鎖的目的。當有線程嘗試獲取鎖時,JVM先通過CAS操作在對象頭和棧幀中建立一個鎖記錄,然後把對象頭複製到鎖記錄裏,這樣在後面這個線程退出同步塊釋放鎖的時候就可以再通過一個CAS操作把對象頭換回來,表示這個鎖已經釋放了。

3)重量級鎖:當線程爭用嚴重且CAS操作無法成功時,鎖就會膨脹爲重量級鎖。在這種情況下,鎖的獲取和釋放都需要通過操作系統的內核來完成,涉及線程上下文切換等操作,所以性能消耗 relatively 大。

偏向鎖->輕量級鎖的轉化過程: 當一個已經獲取了偏向鎖的同步代碼塊被另一個線程訪問的時候,那麼偏向鎖就會升級爲輕量級鎖。升級過程爲先撤銷偏向鎖,然後在自旋中嘗試使用CAS獲取鎖,如果成功,則使用輕量級鎖,否則,進入阻塞狀態,使用重量級鎖。

輕量級鎖->重量級鎖的轉化過程: 當某一個線程試圖獲取一個已經被另一個線程持有的鎖時,它會進行自旋操作嘗試獲取這個鎖。如果自旋次數超過一定的閾值或者一個線程在自旋過程中發現有新的線程試圖獲取該鎖,那麼輕量級鎖就會膨脹爲重量級鎖。

以上轉換過程並不一定按照偏向鎖->輕量級鎖->重量級鎖的順序,當啓動時沒有通過-XX:+UseBiasedLocking參數開啓偏向鎖的時候,初始級別爲輕量級鎖。

CAS鎖機制

CAS,全稱Compare And Swap,即比較並交換。它是一種用於解決併發問題的無鎖算法,主要運用在多線程編程中實現無鎖(即不使用鎖)的數據結構。

CAS鎖機制的基本思路是三個步驟,分別是:獲取內存值、比較內存值與預期值是否相等、如果相等則設置爲新的值。所以,在JAVA中對應的CAS操作通常可以表示爲: compareAndSet(expectedValue, newValue) 。如果內存中的值與預期值(expectedValue)相等,則設置爲新值(newValue),操作成功返回true;否則操作失敗返回false。

例如,原子整型(AtomicInteger)類中的 incrementAndGet 方法就是使用 CAS 機制實現的,原子性地自增並獲取:

public final int incrementAndGet() { 
       for (;;) { 
           int current = get(); 
           int next = current + 1;
           if (compareAndSet(current, next)) 
                   return next; 
       } 
}

在這裏,首先獲取了當前的值,然後+1得到期望的新值,接着使用 CAS 操作嘗試更新,如果成功則返回新值,否則重試,直到成功爲止。

CAS機制相對於synchronized來說,避免了線程切換和阻塞的額外消耗,因此在併發量比較高的情況下,CAS機制大大提高了性能和效率。但它也有自身的問題,比如ABA問題,循環時間長開銷大,只能保證一個共享變量的原子操作等,需要根據具體場景選擇合適的併發控制機制。

上面只是CAS的一些原理講解和簡單的用法,比較簡單。

在項目中,該怎麼用呢?

比如有如下的一個場景,緩存穿透後,需要請求線程回源數據庫,反刷數據到緩存。如果我們使用普通的方式回源數據庫,則在一瞬間一般會穿透十多個或者幾十個請求線程回源數據庫,如果數據庫撐不住,就直接回源掛了。此時我們其實就可以利用CAS鎖,只允許一個線程回源到數據庫進行數據反刷,避免回源導致的數據庫穿透問題。

在寫框架的時候,又該怎麼用呢?

在寫框架的時候,我們經常會寫一些xxxSpringStarter類,這些類實際上就是初始化你的框架實例,實際上在初始化的時候,我們就可以利用CAS類控制框架實例的生成,避免一次性產生多個實例而導致框架出問題,比如:

    /**
    * 初始的cas鎖
    */
   private static AtomicBoolean startLocker = new AtomicBoolean();


   /**
    * spring拉起檢測
    *
    * @param ev
    */
   @Override
   public void onApplicationEvent(ContextRefreshedEvent ev) {
       if (startLocker.compareAndSet(false, true)) {
           providersManager = new ProvidersLifeCycleManager();
           Map<String, ChaosProviderBeanDetail> stringChaosProviderBeanDetailMap = buildProviderBeanDetails();
           providersManager.start(stringChaosProviderBeanDetailMap);
       }
   }

AQS鎖機制

AQS,全稱是AbstractQueuedSynchronizer,抽象隊列同步器。它是JDK中提供的一個用於構建鎖和其他同步組件的框架。

AQS的主要使用方式是繼承,它定義了一套多線程訪問共享資源的同步器框架,許多同步類實現都依賴於它,如ReentrantLock、Semaphore、CountDownLatch等。

在AQS中,通過內置的FIFO隊列來完成獲取資源線程的排隊工作,每一個申請獲取資源的線程都是一個Node節點,所有的Node節點構成一個不定長的鏈表。同時,AQS使用了一個int類型的成員變量state來表示資源,通過這種方式,AQS既能支持排他性也能支持共享性的獲取資源方式。

在進行資源獲取時,如果獲取失敗(獲取排他鎖狀態的值失敗或可獲取資源數不足),AQS會將當前線程及等待狀態等信息包裝成一個Node節點,加入到隊列中。獲取資源成功的線程會負責通知後繼節點。

具體來說,AQS提供模板方法如tryAcquire/canBeCancelled/tryRelease等,具體的同步組件根據自己的同步機制去實現這些模板方法。

例如,ReentrantLock就是通過實現AQS的模板方法完成對於具體Lock的實現:當一個線程調用ReentrantLock的lock方法獲取鎖時,如果此鎖未被其他線程佔用(state=0),那麼當前線程就可以獲取到這把鎖,並將state設置爲1;如果此鎖已被佔用,那麼當前線程就會被封裝成Node節點,加入到AQS的等待隊列中等待獲取鎖。這就是ReentrantLock基於AQS框架的實現方式。

總結起來,AQS通過內置的FIFO隊列和資源獲取失敗的線程加入隊列等待的機制,爲許多同步類提供了一個關於如何進行資源管理和線程排隊等待的有效的解決方案,大大簡化了同步類的實現過程。

這裏來比較一下AQS鎖機制和synchronized鎖機制:

AQS的優點:

1. AQS提供了一個基於FIFO隊列,可以實現公平鎖和非公平鎖的框架。

2. AQS支持共享鎖和排他鎖,也可以支持多個條件變量。

3. AQS有着良好的擴展性,通過覆寫AQS的方法,可以實現各種包含獨佔鎖和共享鎖在內的同步結構,比如ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等都是基於AQS實現的。

4. 使用AQS可以完全不必關心同步狀態的更新和同步隊列的維護,只需要去實現資源的獲取和釋放方法即可。

synchronized的優點:

1. Java語言內置,代碼簡單明瞭。寫法更簡潔。

2. 無需處理可能產生的異常。

3. 自動釋放鎖,無須手動操作。

AQS的缺點:

1. 使用起來複雜,需要定義更多的方法和處理更多的異常。

2. 對於簡單的鎖操作,AQS的實現可能會相對重量級。

synchronized的缺點:

1. 不公平,不保證等待的線程會獲取到鎖。當競爭激烈時可能導致線程飢餓。

2. 不支持獲取鎖時的中斷操作,也就是說調用synchronized申請鎖的線程,在等待過程中無法被中斷。

3. 只支持非公平的排他鎖,不能根據需要選擇公平鎖和非公平鎖,也不支持共享鎖。

4. 不支持超時獲取鎖的操作,即線程在指定的時間內沒有獲取到鎖就返回等待或者做其他操作。

所以,對於簡單的併發操作,synchronized的使用會更加方便;但對於復材的併發操作,比如需要公平性、可中斷性、多條件和共享鎖等更靈活的場合,AQS的使用會更勝一籌。

基於AQS鎖框架寫一個自定義鎖

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

// 靜態內部下面是一個基於AQS框架實現的簡化版本的獨佔鎖,MyLock。我們主要用到AQS的模板方法tryAcquire和tryRelease。
public class MyLock {

    private Sync sync;

    public MyLock() {
        sync = new Sync();
    }

    // 加鎖操作
    public void lock() {
        sync.acquire(1);
    }

    // 釋放鎖操作
    public void unlock() {
        sync.release(1);
    }

    // 自定義同步器
    private class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int arg) {
            if (compareAndSetState(0, 1)) 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章