多執行緒筆記---鎖(Synchronized)的優化和種類

verzqlis發表於2019-05-10

通過上一篇文章大致瞭解了鎖 (Synchronized),他最大的特徵是在同一時間只有一個執行緒能夠獲得物件的監視器(monitor),從而進入到同步程式碼塊中執行,其他執行緒需要在外面等待,表現出一種互斥性。但是這樣有一個很明顯的的問題,效率低下,那麼多執行緒都在外面等你執行,這時候就需要對鎖進行優化,既然一次只能通過一個執行緒的形式不能改變,那麼我們可以對鎖進行優化,縮短獲取鎖的時間。

1.樂觀鎖和悲觀鎖

這個問題是面試常客了,”請你簡要談談樂觀鎖和悲觀鎖“相信面試過的人都基本被問過。那麼這裡就看看這兩種鎖分別是什麼,首先這兩種不是具體的鎖,而是一種策略。

  • 悲觀鎖

    顧名思義,就是很悲觀,每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會block直到它拿到鎖。具有強烈的獨佔性和排他性。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。Java的synchronizedReentrantLock就是悲觀鎖 Synchronized是依賴於JVM實現的,而ReenTrantLock是JDK實現的,有什麼區別,說白了就類似於作業系統來控制實現和使用者自己敲程式碼實現的區別。前者的實現是比較難見到的,後者有直接的原始碼可供閱讀。 ReenTrantLock可以指定是公平鎖還是非公平鎖。而Synchronized只能是非公平鎖。所謂的公平鎖就是先等待的執行緒先獲得鎖。
  • 樂觀鎖

    顧名思義,就是很樂觀,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號等機制。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量,像資料庫如果提供類似於write_condition機制的其實都是提供的樂觀鎖。Java在java.util.concurrent.atomic包下面的原子變數類就是使用了樂觀鎖的一種實現方式CAS實現的。
使用場景

樂觀鎖適用於寫比較少的情況下(多讀場景),減少衝突,降低鎖的開銷,加大系統吞吐量。一旦產生衝突,會導致上層應用不斷retry,這樣對效能損耗更大,因而樂觀鎖適用於寫比較多的情況下(多寫場景)

多執行緒筆記---鎖(Synchronized)的優化和種類

1.1樂觀鎖的實現方式

樂觀鎖一般由版本號機制或CAS演算法實現

  • 版本號機制
    一般是在資料表中加上一個資料版本號version欄位,表示資料被修改的次數,當資料被修改時,version值會加一。當執行緒A要更新資料值時,在讀取資料的同時也會讀取version值,在提交更新時,若剛才讀取到的version值為當前資料庫中的version值相等時才更新,否則重試更新操作,直到更新成功。
  • CAS(compare and swap:變換與比較)演算法

CAS是一種有名的無鎖演算法。無鎖程式設計,即不使用鎖的情況下實現多執行緒之間的變數同步也就是在沒有執行緒被阻塞的情況下實現變數的同步,所以也叫非阻塞同步(Non-blocking Synchronization)

CAS 操作中包含三個運算元

  • 需要讀寫的記憶體值 V

  • 進行比較的值 A

  • 擬寫入的新值 B CAS指令執行時,當且僅當記憶體地址V的值與預期值A相等時,將記憶體地址V的值修改為B,否則就什麼都不做。整個比較並替換的操作是一個原子操作。(自增不是原子操作)

    使用場景

    在JDK1.5 中新增 java.util.concurrent (J.U.C)就是建立在CAS之上的。相對於對於 synchronized 這種阻塞演算法,CAS是非阻塞演算法的一種常見實現。例如atomic包中的實現類也幾乎都是用CAS實現,下面以AtomicInteger 為例子看看,看一下在不使用鎖的情況下是如何保證執行緒安全的

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) {  
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);  
    }  
}

複製程式碼
  1. 在沒有鎖的機制下,欄位value要藉助volatile原語,保證執行緒間的資料是可見性。這樣在獲取變數的值的時候才能直接讀取。然後來看看 ++i 是怎麼做到的。

2.getAndIncrement 採用了CAS操作,每次從記憶體中讀取資料然後將此資料和 +1 後的結果進行CAS操作,如果成功就返回結果,否則重試直到成功為止。

3.而 compareAndSet 利用JNI(Java Native Interface)來完成CPU指令的操作,下面應該是彙編程式碼(這是我自己找的,不確定是不是)。因為看不懂就不講解了,知道什麼意思的可以留言講解一下。

template<>
template<typename T>
inline T Atomic::PlatformXchg<4>::operator()(T exchange_value,
                                             T volatile* dest,
                                             atomic_memory_order order) const {
  STATIC_ASSERT(4 == sizeof(T));
  // alternative for InterlockedExchange
  __asm {
    mov eax, exchange_value;
    mov ecx, dest;
    xchg eax, dword ptr [ecx];
  }
}
複製程式碼
CAS的缺點:

1.ABA問題: 如果記憶體地址V初次讀取的值是A,並且在準備賦值的時候檢查到它的值仍然為A,那我們就能說它的值沒有被其他執行緒改變過了嗎? 如果在這段期間它的值曾經被改成了B,後來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過,這個漏洞稱為CAS操作的“ABA”問題。不過從Java1.5開始JDK的atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。

public boolean compareAndSet(
               V      expectedReference,//預期引用

               V      newReference,//更新後的引用

              int    expectedStamp, //預期標誌

              int    newStamp //更新後的標誌
)
複製程式碼

2.迴圈時間長開銷大: 前面可以看到getAndIncrement方法中有一個for(;;),如果CAS失敗,會一直進行嘗試。如果CAS長時間一直不成功,可能會給CPU帶來很大的開銷。 3.只能保證一個共享變數的原子操作: 當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性.不過,萬能的JDK1.5之後提供了AtomicReference類來保證引用物件之間的原子性,你可以把多個變數放在一個物件裡來進行CAS操作。

2.鎖的優化

眾所周知,一旦在併發中用到鎖,就是為了進行阻塞,效能自然而然就會降低,因為鎖的優化就是在阻塞的情況下去提高效能,讓鎖造成的障礙降到最低,但並不是就能解決鎖阻塞造成的效能問題,這是不可能的。一般效能優化問題都是從減少耗時和降低自身複雜度做起,因而鎖優化的方法由前人總結目前有以下幾點:

  • 減少鎖持有時間
  • 減小鎖粒度
  • 鎖分離
  • 鎖粗化
  • 鎖消除

2.1 減少鎖的持有時間

使用鎖會造成其他執行緒進行等待,因為降低持有鎖的時間和減少鎖的範圍,其他執行緒獲取鎖的速度也會加快,儘可能減少衝突時間,舉個最熟悉的單例例子

 public synchronized static SingleDoggetInstance() {
    if(singleDog== null){
        singleDog= new SingleDog();
    }
    return singleDog;
 }

複製程式碼

上面這樣如果每個進來的執行緒都加鎖後再判斷例項是否已經存在,然而這樣加了不必要的鎖,所以為了減少不必要的加鎖次數,進行下面優化。

 public static Singleton getInstance() {
    if (singleDog== null) {
        // 在判斷例項是否存在的時候,再加鎖
        synchronized (SingleDog.class) {
            if (singleDog== null) {
                singleDog= new SingleDog();
            }
        }
    }
    return singleDog;
 }
複製程式碼

上面當例項已經存在的時候就直接返回例項,就不需要增加不必要的鎖

2.1 減少鎖粒度

將大物件(這個物件可能會被很多執行緒訪問),拆成小物件,大大增加並行度,降低鎖競爭。降低了鎖的競爭,偏向鎖,輕量級鎖成功率才會提高。最典型的例子就是ConcurrentHashMap(雖然寫android的我從來沒用過),反例就是HashTable,每一個方法都上鎖,不過最近FaceBook好像推出了一個改良的HashTable F14,還沒看,想了解的朋友可以看一下,要翻牆

這裡主要用ConcurrentHashMap來介紹下減少鎖粒度優化,因為jdk1.7和1.8的實現方式不一樣,這裡只介紹1.8 jdk1.7主要使用分段鎖的方式,最大的併發性與分段的段數相等。jdk 1.8為進一步提高併發性,摒棄了分段鎖的方案,而是直接使用一個大的陣列。同時為了提高雜湊碰撞下的定址效能,Java 8在連結串列長度超過一定閾值(8)時將連結串列(定址時間複雜度為O(N))轉換為紅黑樹(定址時間複雜度為O(log(N)))並且將每個segment的分段鎖ReentrantLock改為CAS+Synchronized ConcurrentHashMap從原理上看和hashmap很類似,差距值是ConcurrentHashMap做了一個連結串列轉紅黑樹功能和執行緒安全機制

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        //1. 計算key的hash值
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //2.當前tab中索引為i位置的元素為null,直接使用CAS將值插入
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
             //3.擴容
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //4.存入對應tab的連結串列中
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
複製程式碼

上面就是ConcurrentHashMap的put寫入程式碼,從上面可以看到它將鎖的粒度減少,最後上鎖的地方是在存入對應陣列的連結串列(或者紅黑樹)的時候。 舉個簡單例子,假如使用hashtable,裡面存了100個資料,因為put方法就被synchronized,那麼多執行緒去寫入資料時都要等待前一個執行緒往100個資料的列表中寫入資料,使用ConcurrentHashMap後假如資料平均分為10個tab,每個tab10個資料,那麼判斷資料應該存在哪個tab這部分時不加鎖的,加鎖的只是在往一個10個資料的連結串列中寫入資料,這一下,鎖的粒度就降到1/10,效能自然也大幅度上升。

2.3 鎖分離

根據同步操作的性質,把鎖劃分為的讀鎖和寫鎖,讀鎖之間不互斥,提高了併發性。這個比較簡單,如下表

 - 讀鎖 寫鎖
讀鎖 可訪問 不可訪問
寫鎖 不可訪問 不可訪問

讀寫分離思想可以延伸,只要操作互不影響,鎖就可以分離。典型的示例LinkedBlockingQueue佇列,在它內部, take和put操作本身是隔離的, 從尾部寫入資料,從頭部取出資料,分別持有一把獨立的鎖.

LinkedBlockingQueue佇列

2.4 鎖粗化

為了保證多執行緒之間的併發,每個執行緒持有鎖的持續時間應該儘量縮短,在執行完程式碼塊之後,應該立刻釋放鎖,這樣後續的執行緒才能儘快獲得資源去繼續執行下去,但是一個鎖的請求、同步和釋放,其本身也是消耗系統的資源的,反而對效能優化不利,有點類似於Java不要頻繁建立物件,要對鎖的內容進行封裝。

for (int i = 0; i < 100; i++) {
            synchronized (this){ 
            }
        }
替換為
 synchronized (this) {
            for (int i = 0; i < 100; i++) {
            }
        }


   synchronized (this){ 
        A()
    }
   synchronized (this){ 
      B()
    }
替換為
 synchronized (this){ 
      A()
      B()
    }
複製程式碼

2.5 鎖消除

鎖消除是編譯器做的事,即時編譯時如果發現不可能被共享的物件,則可以消除這些物件的鎖操作。 有時候對完全不可能加鎖的程式碼執行了鎖操作,因為些鎖並不是我們加的,是JDK的類引用進來的,當我們使用的時候,會自動引進來,所以我們會在不可能出現在多執行緒需要同步的情況就執行了鎖操作。在某些條件成熟下,系統會消除這些鎖。

例如StringBuffer就是執行緒安全的操作字串類

 public static String createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }
複製程式碼

假如你寫了如上一個方法,因為返回的是一個字串,在外面呼叫這個方法時並不會用到裡面的同步操作,所以這其中的鎖是無意義的,除非這樣

 public static StringBuffer createStringBuffer(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb
    }
複製程式碼

返回給外界一個StringBuffer,這樣外界可能有多執行緒多這個返回值append操作,這時候就需要鎖的存在。 解決辦法:(我是寫android,並不存在這種需求,所以這裡我就直接複製了一篇文章的解決方案) 開啟鎖消除是在JVM引數上設定的,當然需要在server模式下:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
複製程式碼

3.虛擬機器內的鎖優化策略

鎖機制升級流程:無鎖-->偏向鎖-->輕量級鎖-->重量級鎖

3.1無鎖

無鎖沒有對資源進行鎖定,所有的執行緒都能訪問並修改同一個資源,但同時只有一個執行緒能修改成功。

無鎖的實現原理就是前面文章提到的CAS,執行緒會不斷的去嘗試修改共享資源,如果沒有衝突就修改成功推出,如果有衝突就for(;;)迴圈去繼續修改,多個執行緒去修改資源,一定有一個最快的執行緒先修改成功,後面的執行緒修改失敗後就會繼續迴圈重試直到全部修改成功

3.2偏向鎖

偏向鎖類似於一個同步程式碼塊一直被一個執行緒持有訪問時,該執行緒會自動獲得鎖,這樣降低了鎖的效能消耗,就像常常去光顧飯店的老顧客,你不開口,老闆就知道給你上“老樣子”

從前面可以知道物件頭的Mark Word中有一個25Bit的儲存位置,其中當目前是偏向鎖時會分配23Bit空間來儲存作為當前的常客執行緒Id,線上程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測Mark Word裡是否儲存著指向當前執行緒的偏向鎖。

偏向鎖的目的是為了在沒有多執行緒競爭的情況下減少不需要的輕量級鎖的執行,輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在替換ThreadID的時候進行一次CAS原子指令即可。

如果ThreadID一旦不一致則意味著發生了多執行緒競爭,那麼鎖就不能偏向於一個執行緒了,這時候鎖就會膨脹成輕量級鎖,才能保證執行緒間公平競爭。

偏向鎖在JDK 6及以後的JVM裡是預設啟用的。可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之後程式預設會進入輕量級鎖狀態。

3.3輕量級鎖

當偏向鎖被多執行緒競爭訪問時就會升級成輕量級鎖,其他執行緒會通過自旋的形式去嘗試獲取鎖,不會造成阻塞,從而提高效能。

當執行緒進入同步程式碼塊的時候,如果當前同步物件為無鎖狀態(標誌位為“01”,是否為偏向鎖為“0”),虛擬機器會在當前執行緒的棧幀中建立一個Lock Record(鎖記錄)空間來儲存鎖物件目前的Mark Worder的拷貝,將物件頭中Mark Worder的資料複製到記錄中

複製成功後,虛擬機器使用CAS原子指令操作將物件的Mark Worder更新為指向Lock Record的指標,同時將Lock Record裡面的_owner指向物件的Mark Worder

如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位設定為“00”,表示此物件處於輕量級鎖定狀態。 如果輕量級鎖的更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行,否則說明多個執行緒競爭鎖。

若當前只有一個等待執行緒,則該執行緒通過自旋進行等待。但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級為重量級鎖。

3.4自旋鎖

執行緒在競爭時沒有獲取到鎖後,不立刻掛起,而是做幾個自旋後再嘗試去獲取。

阻塞或喚醒一個Java執行緒需要作業系統切換CPU狀態來完成,這種狀態轉換需要耗費處理器時間。如果同步程式碼塊中的內容過於簡單,狀態轉換消耗的時間有可能比使用者程式碼執行的時間還要長。

所以假如線上程競爭時,競爭失敗的鎖不去立即掛起,而是做幾個自旋(空操作),然後再去嘗試獲取鎖,假如此時上個執行緒已經執行完畢了,你就可以拿到鎖,這樣就節省了執行緒掛起切換時間,提升了系統效能。

但是假如鎖競爭程度很高,多次自旋仍然拿不到鎖,那麼自旋鎖會逐漸膨脹成重量所,提高系統的整體效能。

  • JDK1.6中-XX:+UseSpinning開啟 1.6可關閉和開啟操作,
  • JDK1.7中,去掉此引數,改為內建實現 1.7則把他改為內建開啟
  • 如果同步塊很長,自旋失敗,會降低系統效能
  • 如果同步塊很短,自旋成功,節省執行緒掛起切換時間,提升系統效能

3.5重量級鎖

重量級鎖是鎖升級的終點,標誌位為“10”,此時Mark Word中儲存的是指向重量級鎖的指標,此時等待鎖的執行緒都會進入阻塞狀態,這也就是Synchronize.

所以鎖升級的整體流程為 1.如果偏向鎖可用可以嘗試使用偏向鎖 2.偏向鎖陷入競爭就升級為輕量級鎖 3.輕量級鎖競爭失敗會嘗試自旋 3.自旋嘗試失敗後升級為重量級鎖,在作業系統層掛起

下面用美團文章的一張圖片總結一下

鎖的種類

總結

本文是在學習鎖的時候參考了多篇文章後總結出來的一份筆記,畢竟各人寫的文章都有各人的風格,自己總結一下以後複習也更為熟悉和方便,限於篇幅以及個人水平,如有錯誤,還望指出。

java cas演算法實現樂觀鎖 (Compare and Swap 比較並交換) 面試必問的CAS,你懂了嗎? Java併發問題--樂觀鎖與悲觀鎖以及樂觀鎖的一種實現方式-CAS java高併發實戰(九)——鎖的優化和注意事項 Java高效併發(四) 不可不說的Java“鎖”事

相關文章