悲觀鎖與樂觀鎖(CAS實現)

CAS樂觀鎖-悲觀鎖

悲觀鎖與樂觀鎖

  • 悲觀鎖:總是假設最壞的情況,每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會 阻塞 直到它拿到鎖。傳統的關係型數據庫裏邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。再比如Java裏面的同步原語 synchronized 關鍵字的實現也是悲觀鎖。
  • 樂觀鎖:顧名思義,就是很樂觀,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會 判斷一下在此期間別人有沒有去更新這個數據 ,可以使用版本號等機制。樂觀鎖適用於 多讀 的應用類型,這樣可以提高吞吐量,像數據庫提供的類似於 write_condition 機制,其實都是提供的樂觀鎖。在 Java 中 java.util.concurrent.atomic 包下面的原子變量類就是使用了樂觀鎖的一種實現方式 CAS 實現的。

鎖存在的問題

​ Java在 JDK1.5 之前都是靠 synchronized 關鍵字保證同步的,這種通過使用一致的鎖定協議來協調對共享狀態的訪問,可以確保無論哪個線程持有共享變量的鎖,都採用獨佔的方式來訪問這些變量。這就是一種獨佔鎖,獨佔鎖其實就是一種悲觀鎖,所以可以說 synchronized 是悲觀鎖。

存在問題:

  1. 在多線程競爭下,加鎖、釋放鎖會導致比較多的上下文切換和調度延時,引起性能問題。
  2. 一個線程持有鎖會導致其它所有需要此鎖的線程掛起。
  3. 如果一個優先級高的線程等待一個優先級低的線程釋放鎖會導致優先級倒置,引起性能風險。

樂觀鎖

​ 樂觀鎖( Optimistic Locking )其實就是一種思想。相對悲觀鎖而言,樂觀鎖假設認爲數據一般情況下不會產生併發衝突,所以在數據進行提交更新的時候,纔會正式對數據是否產生併發衝突進行檢測,如果發現併發衝突了,則讓返回用戶錯誤的信息,讓用戶決定如何去做。
  上面提到的樂觀鎖的概念中其實已經闡述了它的具體實現細節:主要就是兩個步驟:**衝突檢測和數據更新。**其實現方式有一種比較典型的就是 CAS。

CAS - CompareAndSwap

比較設置,方法 CompareAndSet(num,num+1);

比較如果是 num ,就設置成 num+1;

​ 以 java.util.concurrent 中的 AtomicInteger 爲例,看一下在不使用鎖的情況下是如何保證線程安全的。主要理解 getAndIncrement 方法,該方法的作用相當於 ++i 操作。

public class AtomicInteger extends Number implements java.io.Serializable {  
    private volatile int value; 

    public final int get() {  
        return value;  
    }  

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

    public final boolean compareAndSet(int expect, int update) {  
        // 利用JNI(Java Native Interface)來完成CPU指令的操作
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);  
    }  
}

CAS的缺點:

1.CPU開銷較大

​ 自旋CAS(不成功,就一直循環執行,直到成功)如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支持處理器提供的 pause 指令那麼效率會有一定的提升,pause 指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環的時候因內存順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。

2.不能保證代碼塊的原子性

​ CAS機制所保證的只是一個變量的原子性操作,而不能保證整個代碼塊的原子性。比如需要保證3個變量共同進行原子性的更新,就不得不使用Synchronized了。

什麼是CAS機制

CAS是英文單詞Compare And Swap的縮寫,翻譯過來就是比較並替換。

CAS機制當中使用了3個基本操作數:內存地址V,舊的預期值A,要修改的新值B。

更新一個變量的時候,只有當變量的預期值A和內存地址V當中的實際值相同時,纔會將內存地址V對應的值修改爲B。

CAS是英文單詞Compare And Swap的縮寫,翻譯過來就是比較並替換。

CAS機制當中使用了3個基本操作數:內存地址V,舊的預期值A,要修改的新值B。

更新一個變量的時候,只有當變量的預期值A和內存地址V當中的實際值相同時,纔會將內存地址V對應的值修改爲B。

這樣說或許有些抽象,我們來看一個例子:

1.在內存地址V當中,存儲着值爲10的變量。

img

2.此時線程1想要把變量的值增加1。對線程1來說,舊的預期值A=10,要修改的新值B=11。

img

3.在線程1要提交更新之前,另一個線程2搶先一步,把內存地址V中的變量值率先更新成了11。

img

4.線程1開始提交更新,首先進行A和地址V的實際值比較(Compare),發現A不等於V的實際值,提交失敗。

img

5.線程1重新獲取內存地址V的當前值,並重新計算想要修改的新值。此時對線程1來說,A=11,B=12。這個重新嘗試的過程被稱爲自旋。

img

6.這一次比較幸運,沒有其他線程改變地址V的值。線程1進行Compare,發現A和地址V的實際值是相等的。

img

7.線程1進行SWAP,把地址V的值替換爲B,也就是12。

img

CAS 存在的問題

ABA問題

​  比如說一個線程one從內存位置V中取出A,這時候另一個線程two也從內存中取出A,並且two進行了一些操作變成了B,然後two又將V位置的數據變成A,這時候線程one進行CAS操作發現內存中仍然是A,然後one操作成功。儘管線程one的CAS操作成功,但可能存在潛藏的問題。如下所示:

img

​ 現有一個用單向鏈表實現的堆棧,棧頂爲A,這時線程T1已經知道A.next爲B,然後希望用CAS將棧頂替換爲B:
          head.compareAndSet(A,B);
  在T1執行上面這條指令之前,線程T2介入,將A、B出棧,再pushD、C、A,此時堆棧結構如下圖,而對象B此時處於遊離狀態:

img

​ 此時輪到線程T1執行CAS操作,檢測發現棧頂仍爲A,所以CAS成功,棧頂變爲B,但實際上B.next爲null,所以此時的情況變爲:

img

​ 其中堆棧中只有B一個元素,C和D組成的鏈表不再存在於堆棧中,平白無故就把C、D丟掉了。

CAS與Synchronized

​ 1、對於資源競爭較少(線程衝突較輕)的情況,使用synchronized同步鎖進行線程阻塞和喚醒切換以及用戶態內核態間的切換操作額外浪費消耗cpu資源;而CAS基於硬件實現,不需要進入內核,不需要切換線程,操作自旋機率較少,因此可以獲得更高的性能。

2、對於資源競爭嚴重(線程衝突嚴重)的情況,CAS自旋的概率會比較大,從而浪費更多的CPU資源,效率低於synchronized。

補充: synchronized在jdk1.6之後,已經改進優化。synchronized的底層實現主要依靠Lock-Free的隊列,基本思路是自旋後阻塞,競爭切換後繼續競爭鎖,稍微犧牲了公平性,但獲得了高吞吐量。在線程衝突較少的情況下,可以獲得和CAS類似的性能;而線程衝突嚴重的情況下,性能遠高於CAS。

Concurrent包的實現

由於java的CAS同時具有 volatile 讀和volatile寫的內存語義,因此Java線程之間的通信現在有了下面四種方式:

1. A線程寫volatile變量,隨後B線程讀這個volatile變量。

2. A線程寫volatile變量,隨後B線程用CAS更新這個volatile變量。

3. A線程用CAS更新一個volatile變量,隨後B線程用CAS更新這個volatile變量。

4. A線程用CAS更新一個volatile變量,隨後B線程讀這個volatile變量。

​ Java的CAS會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以原子方式對內存執行讀-改-寫操作,這是在多處理器中實現同步的關鍵(從本質上來說,能夠支持原子性讀-改-寫指令的計算機器,是順序計算圖靈機的異步等價機器,因此任何現代的多處理器都會去支持某種能對內存執行原子性讀-改-寫操作的原子指令)。同時,volatile變量的讀/寫和CAS可以實現線程之間的通信。把這些特性整合在一起,就形成了整個concurrent包得以實現的基石。如果我們仔細分析concurrent包的源代碼實現,會發現一個通用化的實現模式:

1. 首先,聲明共享變量爲volatile;

2. 然後,使用CAS的原子條件更新來實現線程之間的同步;

3. 同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的內存語義來實現線程之間的通信。

AQS,非阻塞數據結構和原子變量類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實現的,而concurrent包中的高層類又是依賴於這些基礎類來實現的。從整體來看,concurrent包的實現示意圖如下:

img

JVM中的CAS(堆中對象的分配):

​ Java調用new object()會創建一個對象,這個對象會被分配到JVM的堆中。那麼這個對象到底是怎麼在堆中保存的呢?

​ 首先,new object()執行的時候,這個對象需要多大的空間,其實是已經確定的,因爲java中的各種數據類型,佔用多大的空間都是固定的(對其原理不清楚的請自行Google)。那麼接下來的工作就是在堆中找出那麼一塊空間用於存放這個對象。
  在單線程的情況下,一般有兩種分配策略:

1. 指針碰撞:這種一般適用於內存是絕對規整的(內存是否規整取決於內存回收策略),分配空間的工作只是將指針像空閒內存一側移動對象大小的距離即可。

2. 空閒列表:這種適用於內存非規整的情況,這種情況下JVM會維護一個內存列表,記錄哪些內存區域是空閒的,大小是多少。給對象分配空間的時候去空閒列表裏查詢到合適的區域然後進行分配即可。

但是JVM不可能一直在單線程狀態下運行,那樣效率太差了。由於再給一個對象分配內存的時候不是原子性的操作,至少需要以下幾步:查找空閒列表、分配內存、修改空閒列表等等,這是不安全的。解決併發時的安全問題也有兩種策略:

1. CAS:實際上虛擬機採用CAS配合上失敗重試的方式保證更新操作的原子性,原理和上面講的一樣
  2. TLAB:如果使用CAS其實對性能還是會有影響的,所以JVM又提出了一種更高級的優化策略:每個線程在Java堆中預先分配一小塊內存,稱爲本地線程分配緩衝區(TLAB),線程內部需要分配內存時直接在TLAB上分配就行,避免了線程衝突。只有當緩衝區的內存用光需要重新分配內存的時候纔會進行CAS操作分配更大的內存空間。
機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來進行配置(jdk5及以後的版本默認是啓用TLAB的)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章