JUC鎖種類總結

萌新J發表於2020-11-21

在併發程式設計中有各種各樣的鎖,有的鎖物件一個就身兼多種鎖身份,所以初學者常常對這些鎖造成混淆,所以這裡來總結一下這些鎖的特點和實現。

樂觀鎖、悲觀鎖

悲觀鎖

  悲觀鎖是最常見的鎖,我們常說的加鎖指的也就是悲觀鎖。顧名思義,每次修改都抱著一種 "悲觀" 的態度,每次修改前都會認為有人會和他一樣執行同一段程式碼,所以每次修改時都會加鎖,而這個鎖就是悲觀鎖,加上悲觀鎖,其他執行緒不能執行這段程式碼,除非當前執行緒主動釋放鎖資源或者執行完成正常釋放鎖資源。常見的悲觀鎖有 synchronized、ReentrantLock(常見物件時不加引數或者是false)。

  適用場景:悲觀鎖適用於單個執行緒執行時間長或者併發量高的場景,執行時間長意味著上下文切換消耗的時間相當於執行緒執行的時間就不算長,而併發量高則說明上下文切換的時間相當於多執行緒在佇列中等待消耗的時間也微不足道。

 

樂觀鎖

  樂觀鎖,顧名思義,就是以一種樂觀的心態看待多執行緒對共享資料修改問題。認為當前執行緒在修改更新到主記憶體中過程中(jmm,如果不熟悉可以移步多執行緒基礎總結)沒有其他執行緒進行修改。所以在修改時並不會加鎖來限制其他執行緒,這樣的好處就是在其他執行緒沒有修改時效率更高,因為新增悲觀鎖就涉及到上下文切換,這樣在高併發場景就降低了程式的執行效率。那麼樂觀鎖又是如何實現的呢? 就是通過三個值來實現的,分別是記憶體地址儲存的實際值、預期值和更新值,當實際值與預期值相等時,就判定該值在修改過程中沒有發生改變,此時將值修改為更新值,而如果預期值和實際值不相等,則表示在更新期間有執行緒進行過修改,那麼就修改失敗。CAS 就是樂觀鎖的一種實現,因為用得比較多所以一般就用 CAS 來代指樂觀鎖,CAS 是 compareAndSet 方法名的縮寫,它是位於 JUC下 atomic 包下的類中的實現方法。在不同的類中實現方法不同,首先是普通的包裝類(AtomicInteger、AtomicBoolean等)和引用類(AtomicReference),他們的實現比較相似,都是兩個引數。下面就以 AtomicInteger 原始碼為例,關於 atomic 相關介紹可以檢視 atomic .

public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

這裡是呼叫 unsafe 物件的 compareAndSwapInt 方法,這裡要知道 unSafe 類,unsafe 是 CAS 實現的核心類,在其內部定義了很多 CAS 的實現方法。

   可以看到內部有許多 native 方法,native 方法是本地方法,其實現是使用 C、C++語言實現的,由於 C、C++語言與系統的相容性更好,所以一些需要偏作業系統層面的操作還是使用 C與C++實現。而 unsafe 就是 CAS 實現的關鍵類。回到原始碼,這裡的 compareAndSet 方法是兩個引數,預期值和更新值,但是內部實現邏輯還是樂觀鎖的原理。

ABA 問題:上面這種實現是可能存在問題的,因為在比較時只會比較預期值和實際值,而可能這個值開始是A,這時開始進行 CAS 修改,所以預期值就是A,先修改進工作記憶體,但是在這之間 實際值由 A 變成了 B,在 CAS 執行緒執行 CAS 前又由 B 變回了 A,此時執行 CAS 就會以為這個 A 是沒有變化的,所以正常更新為更新值,但其實它是改變過的。這就是樂觀鎖的 ABA 問題。這種問題在只需要比較開始和結束的業務中不需要管理,但是在需要資料一直保持不變的業務場景中就會變得很致命。

  為了解決 ABA 問題,又提出版本號的概念,原理就是在修改前後比較的值從要修改的值變成版本號,每次資料變化時都會改變版本號,最終如果預期版本號和實際版本號相等就正常修改,如果不同就放棄修改。在 atomic包下這種思想的實現類就是 AtomicStampedReference,其 compareAndSet 原始碼如下

public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }

四個引數分別為期望值,更新值,期望版本號,更新版本號。這裡的實現是比較期望值和版本號兩個值。只有全部符合才會修改成功。

  適用場景:樂觀鎖適用於執行時間短且併發量小的場景,對於一段程式碼只有幾個甚至只有一個執行緒同時執行,那麼樂觀鎖就可以起到大作用,因為它沒有加鎖解鎖的操作,執行緒不需要進行阻塞和喚醒,沒有上下文切換的時間損耗,同時如果併發量高的話樂觀鎖的效率也會遠低於悲觀鎖,因為樂觀鎖往往是與自旋鎖搭配使用的,而自旋鎖意味著會一直在執行,一致佔用 CPU,所以樂觀鎖只適用於執行時間短且併發量小的場景。

 

 

自旋鎖

  自旋鎖是不斷嘗試的鎖,一般與樂觀鎖搭配使用,因為悲觀鎖需要加鎖解鎖操作,這樣導致執行緒阻塞,經過一次上下文切換後才能繼續執行,這樣在併發量小且執行時間短的場景中所消耗的時間就相對來說較長,所以在這種場景可以使用樂觀鎖+自旋鎖來實現。典型的這種實現就是 Atomic 包下的一些包裝類的方法實現。下面就以 AtomicInteger 的 getAndIncrement 方法原始碼來解讀

public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

發現底層使用的還是 unsafe 類呼叫的方法。再點進這個方法

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

可以看到這裡是使用了 while 迴圈,在不斷嘗試呼叫 native 的 compareAndSwapInt 方法。這個 compareAndSwapInt 就是一個樂觀鎖方法,當執行成功後就會跳出迴圈,否則會一直嘗試執行。

  適用場景:因為一般是和樂觀鎖搭配使用的,所以和樂觀鎖適用場景一致。也就是執行緒執行時間短且併發量低的場景。

 

 

共享鎖、獨佔鎖

獨佔鎖

  獨佔鎖又稱 “排它鎖”、"寫鎖"、“互斥鎖”,意為當前執行緒獲取到鎖之後其他執行緒就不能再獲取到鎖,我們常用的鎖如 synchronized、ReentrantLock 鎖都是獨佔鎖。

 

共享鎖

  共享鎖又稱 “讀鎖”,是指當前執行緒獲取到鎖之後其他執行緒也能獲取到當前鎖並執行鎖中的程式碼。可能有人會覺得如果這樣的話,那麼共享鎖和無鎖有什麼區別?事實上共享鎖一般是與獨佔鎖搭配使用的,共享鎖與獨佔鎖是互斥的,常用的共享鎖與獨佔鎖實現類是 ReentrantReadWriteLock,關於這個鎖的使用可以檢視 ReentrantReadWriteLock使用 。

  適用場景:一般與獨佔鎖搭配使用,用於某段程式碼可以多個執行緒同時執行但是與另外一段程式碼互斥,比如A程式碼同一時間可以有多個執行緒執行,但是B程式碼與A 程式碼同一時間只能有一個執行緒執行。

 

 

公平鎖、非公平鎖

非公平鎖

  非公平鎖是指一段程式碼塊被多個執行緒嘗試執行,那麼會有一個執行緒獲取到鎖資源,其他執行緒就會進入等待佇列等待,而新來的執行緒並不會直接進入阻塞佇列尾部,而是先嚐試獲取鎖資源,如果這時候之前佔用鎖資源的執行緒剛好釋放鎖那麼這個新來的執行緒就會直接獲取到鎖,而如果佔用資源的執行緒沒有釋放鎖,那麼新來的執行緒就獲取失敗,乖乖進入等待佇列尾部等待。synchronized 是非公平鎖,而 Lock 實現類的鎖會有兩種形式,公平鎖和非公平鎖,在建立物件時沒有指定引數預設就是非公平鎖。

  適用場景:非公平鎖的優勢是效率高,如果位於頭部的執行緒出現問題阻塞住了,也沒有獲取鎖資源,那麼後面的執行緒就需要一直等待,直到其執行完成,這樣就非常耗時,而非公平鎖則可以讓新來的執行緒直接獲取到鎖跳過其阻塞時間。缺點是這樣就會導致位於等待佇列尾部的執行緒獲取到鎖資源的機會很小,可能一直都沒有辦法獲取到鎖,造成 “飢餓”。適應場景是要求執行效率高,響應時間短,同時每個執行緒的執行時間較短的場景。

 

公平鎖

  公平鎖是非公平鎖的對立面,也就是新來的執行緒直接進入阻塞佇列的尾部,而不會先嚐試獲取鎖資源。synchronized 只能是非公平鎖,而 Lock 系列的鎖在建立鎖物件時指定引數為 false 時就是公平鎖。

  適用場景:公平鎖的優勢是每個執行緒能按順序有序執行,不會發生 “飢餓” 的情況,缺點是整個系統的執行效率會低一些。適用於執行緒執行時間較長,執行緒數較少,同時對執行緒執行順序有要求的場景。

 

 

可重入鎖

  可重入鎖指的是當前已經獲取資源的執行緒在執行內部程式碼時又遇到一個相同的鎖,比如下面這種場景。

public synchronized void test(){
        System.out.println("11");
        synchronized (this){
            System.out.println("2");
        }
    }

這裡方法上的 synchronized 對應的物件和方法內的 synchronized 對應的物件是同一個物件,當某個物件進入方法後又遇到一個相同物件的鎖,那麼如果這個鎖是可重入鎖,當前執行緒就會將當前鎖層次加1,相當於 AQS 機制中的 state,如果對 AQS 不熟悉可以看一下 AQS全解析 。每加一把鎖就會加1,釋放鎖就會減1。平時常用的鎖都是可重入鎖。

 

 

分段鎖

  分段鎖是 ConcurrentHashMap 在1.7中的概念,因為在1.7中 ConcurrentHashMap 中使用的是分段鎖,也就是對陣列的每一個元素進行加鎖,這樣就可以同時有16個執行緒(預設陣列容量)一起操作。具體實現是在1.7中有一個內部類 Segment,這個類內部儲存的資料屬性就是 ConcurrentHashMap 陣列某個下標對應的連結串列所儲存的所有資料,在需要同步的地方直接通過下標數獲取對應的 Segment 物件然後進行加鎖,這樣將整個陣列和其連結串列儲存的資料分段來加鎖就是分段鎖。關於 ConcurrentHashMap 和 HashMap 解析可以檢視 HashMap 、ConcurrentHashMap知識點全解析 。

 

相關文章