再談synchronized鎖升級

碼農參上發表於2021-04-12

在圖文詳解Java物件記憶體佈局這篇文章中,在研究物件頭時我們瞭解了synchronized鎖升級的過程,由於篇幅有限,對鎖升級的過程介紹的比較簡略,本文在上一篇的基礎上,來詳細研究一下鎖升級的過程以及各個狀態下鎖的原理。本文結構如下:

1 無鎖

在上一篇文章中,我們提到過 jvm會有4秒的偏向鎖開啟的延遲時間,在這個偏向延遲內物件處於為無鎖態。如果關閉偏向鎖啟動延遲、或是經過4秒且沒有執行緒競爭物件的鎖,那麼物件會進入無鎖可偏向狀態。

準確來說,無鎖可偏向狀態應該叫做匿名偏向(Anonymously biased)狀態,因為這時物件的mark word中後三位已經是101,但是threadId指標部分仍然全部為0,它還沒有向任何執行緒偏向。綜上所述,物件在剛被建立時,根據jvm的配置物件可能會處於 無鎖匿名偏向 兩個狀態。

此外,如果在jvm的引數中關閉偏向鎖,那麼直到有執行緒獲取這個鎖物件之前,會一直處於無鎖不可偏向狀態。修改jvm啟動引數:

-XX:-UseBiasedLocking

延遲5s後列印物件記憶體佈局:

public static void main(String[] args) throws InterruptedException {
    User user=new User();
    TimeUnit.SECONDS.sleep(5);
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

可以看到,即使經過一定的啟動延時,物件一直處於001無鎖不可偏向狀態。大家可能會有疑問,在無鎖狀態下,為什麼要存在一個不可偏向狀態呢?通過查閱資料得到的解釋是:

JVM內部的程式碼有很多地方也用到了synchronized,明確在這些地方存線上程的競爭,如果還需要從偏向狀態再逐步升級,會帶來額外的效能損耗,所以JVM設定了一個偏向鎖的啟動延遲,來降低效能損耗

也就是說,在無鎖不可偏向狀態下,如果有執行緒試圖獲取鎖,那麼將跳過升級偏向鎖的過程,直接使用輕量級鎖。使用程式碼進行驗證:

//-XX:-UseBiasedLocking
public static void main(String[] args) throws InterruptedException {
    User user=new User();
    synchronized (user){
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
}

檢視結果可以看到,在關閉偏向鎖情況下使用synchronized,鎖會直接升級為輕量級鎖(00狀態):

在目前的基礎上,可以用流程圖概括上面的過程:

額外注意一點就是匿名偏向狀態下,如果呼叫系統的hashCode()方法,會使物件回到無鎖態,並在markword中寫入hashCode。並且在這個狀態下,如果有執行緒嘗試獲取鎖,會直接從無鎖升級到輕量級鎖,不會再升級為偏向鎖。

2 偏向鎖

2.1 偏向鎖原理

匿名偏向狀態是偏向鎖的初始狀態,在這個狀態下第一個試圖獲取該物件的鎖的執行緒,會使用CAS操作(彙編命令CMPXCHG)嘗試將自己的threadID寫入物件頭的mark word中,使匿名偏向狀態升級為已偏向(Biased)的偏向鎖狀態。在已偏向狀態下,執行緒指標threadID非空,且偏向鎖的時間戳epoch為有效值。

如果之後有執行緒再次嘗試獲取鎖時,需要檢查mark word中儲存的threadID是否與自己相同即可,如果相同那麼表示當前執行緒已經獲得了物件的鎖,不需要再使用CAS操作來進行加鎖。

如果mark word中儲存的threadID與當前執行緒不同,那麼將執行CAS操作,試圖將當前執行緒的ID替換mark word中的threadID。只有當物件處於下面兩種狀態中時,才可以執行成功:

  • 物件處於匿名偏向狀態
  • 物件處於可重偏向(Rebiasable)狀態,新執行緒可使用CAS將threadID指向自己

如果物件不處於上面兩個狀態,說明鎖存線上程競爭,在CAS替換失敗後會執行偏向鎖撤銷操作。偏向鎖的撤銷需要等待全域性安全點Safe Point(安全點是 jvm為了保證在垃圾回收的過程中引用關係不會發生變化設定的安全狀態,在這個狀態上會暫停所有執行緒工作),在這個安全點會掛起獲得偏向鎖的執行緒。

在暫停執行緒後,會通過遍歷當前jvm的所有執行緒的方式,檢查持有偏向鎖的執行緒狀態是否存活:

  • 如果執行緒還存活,且執行緒正在執行同步程式碼塊中的程式碼,則升級為輕量級鎖
  • 如果持有偏向鎖的執行緒未存活,或者持有偏向鎖的執行緒未在執行同步程式碼塊中的程式碼,則進行校驗是否允許重偏向:
    • 不允許重偏向,則撤銷偏向鎖,將mark word升級為輕量級鎖,進行CAS競爭鎖

    • 允許重偏向,設定為匿名偏向鎖狀態,CAS將偏向鎖重新指向新執行緒

完成上面的操作後,喚醒暫停的執行緒,從安全點繼續執行程式碼。可以使用流程圖總結上面的過程:

2.2 偏向鎖升級

在上面的過程中,我們已經知道了匿名偏向狀態可以變為無鎖態或升級為偏向鎖,接下來看一下偏向鎖的其他狀態的改變

  • 偏向鎖升級為輕量級鎖
public static void main(String[] args) throws InterruptedException {
    User user=new User();
    synchronized (user){
        System.out.println(ClassLayout.parseInstance(user).toPrintable());
    }
    Thread thread = new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD--:"+ClassLayout.parseInstance(user).toPrintable());
        }
    });
    thread.start();
    thread.join();
    System.out.println("--END--:"+ClassLayout.parseInstance(user).toPrintable());
}

檢視記憶體佈局,偏向鎖升級為輕量級鎖,在執行完成同步程式碼後釋放鎖,變為無鎖不可偏向狀態:

  • 偏向鎖升級為重量級鎖
public static void main(String[] args) throws InterruptedException {
    User user=new User();
    Thread thread = new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD1--:" + ClassLayout.parseInstance(user).toPrintable());
            try {
                user.wait(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("--THREAD END--:" + ClassLayout.parseInstance(user).toPrintable());
        }
    });
    thread.start();
    thread.join();
    TimeUnit.SECONDS.sleep(3);
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

檢視記憶體佈局,可以看到在呼叫了物件的wait()方法後,直接從偏向鎖升級成了重量級鎖,並在鎖釋放後變為無鎖態:

這裡是因為wait()方法呼叫過程中依賴於重量級鎖中與物件關聯的monitor,在呼叫wait()方法後monitor會把執行緒變為WAITING狀態,所以才會強制升級為重量級鎖。除此之外,呼叫hashCode方法時也會使偏向鎖直接升級為重量級鎖。

在上面分析的基礎上,再加上我們上一篇中講到的輕量級鎖升級到重量級鎖的知識,就可以對上面的流程圖進行完善了:

2.3 批量重偏向

在未禁用偏向鎖的情況下,當一個執行緒建立了大量物件,並且對它們執行完同步操作解鎖後,所有物件處於偏向鎖狀態,此時若再來另一個執行緒也嘗試獲取這些物件的鎖,就會導偏向鎖的批量重偏向(Bulk Rebias)。當觸發批量重偏向後,第一個執行緒結束同步操作後的鎖物件當再被同步訪問時會被重置為可重偏向狀態,以便允許快速重偏向,這樣能夠減少撤銷偏向鎖再升級為輕量級鎖的效能消耗。

首先看一下和偏向鎖有關的引數,修改jvm啟動引數,使用下面的命令可以在專案啟動時列印jvm的預設引數值:

-XX:+PrintFlagsFinal

需要關注的屬性有下面3個:

  • BiasedLockingBulkRebiasThreshold:偏向鎖批量重偏向閾值,預設為20次
  • BiasedLockingBulkRevokeThreshold:偏向鎖批量撤銷閾值,預設為40次
  • BiasedLockingDecayTime:重置計數的延遲時間,預設值為25000毫秒(即25秒)

批量重偏向是以class而不是物件為單位的,每個class會維護一個偏向鎖的撤銷計數器,每當該class的物件發生偏向鎖的撤銷時,該計數器會加一,當這個值達到預設閾值20時,jvm就會認為這個鎖物件不再適合原執行緒,因此進行批量重偏向。而距離上次批量重偏向的25秒內,如果撤銷計數達到40,就會發生批量撤銷,如果超過25秒,那麼就會重置在[20, 40)內的計數。

上面這段理論是不是聽上去有些難理解,沒關係,我們先用程式碼驗證批量重偏向的過程:

private static Thread t1,t2;
public static void main(String[] args) throws InterruptedException {      
    TimeUnit.SECONDS.sleep(5);
    List<Object> list = new ArrayList<>();
    for (int i = 0; i < 40; i++) {
        list.add(new Object());
    }

    t1 = new Thread(() -> {
        for (int i = 0; i < list.size(); i++) {
            synchronized (list.get(i)) {
            }
        }
        LockSupport.unpark(t2);
    });
    t2 = new Thread(() -> {
        LockSupport.park();
        for (int i = 0; i < 30; i++) {
            Object o = list.get(i);
            synchronized (o) {
                if (i == 18 || i == 19) {
                    System.out.println("THREAD-2 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
                }
            }
        }
    });
    t1.start();
    t2.start();
    t2.join();

    TimeUnit.SECONDS.sleep(3);
    System.out.println("Object19:"+ClassLayout.parseInstance(list.get(18)).toPrintable());
    System.out.println("Object20:"+ClassLayout.parseInstance(list.get(19)).toPrintable());
    System.out.println("Object30:"+ClassLayout.parseInstance(list.get(29)).toPrintable());
    System.out.println("Object31:"+ClassLayout.parseInstance(list.get(30)).toPrintable());
}

分析上面的程式碼,當執行緒t1執行結束後,陣列中所有物件的鎖都偏向t1,然後t1喚醒被掛起的執行緒t2,執行緒t2嘗試獲取前30個物件的鎖。我們列印執行緒t2獲取到的第19和第20個物件的鎖狀態:

執行緒t2在訪問前19個物件時物件的偏向鎖會升級到輕量級鎖,在訪問後11個物件(下標19-29)時,因為偏向鎖撤銷次數達到了20,會觸發批量重偏向,將鎖的狀態變為偏向執行緒t2。在全部執行緒結束後,再次檢視第19、20、30、31個物件鎖的狀態:

執行緒t2結束後,第1-19的物件釋放輕量級鎖變為無鎖不可偏向狀態,第20-30的物件狀態為偏向鎖、但從偏向t1改為偏向t2,第31-40的物件因為沒有被執行緒t2訪問所以保持偏向執行緒t1不變。

2.4 批量撤銷

在多執行緒競爭激烈的狀況下,使用偏向鎖將會導致效能降低,因此產生了批量撤銷機制,接下來使用程式碼進行測試:

private static Thread t1, t2, t3;
public static void main(String[] args) throws InterruptedException {
    TimeUnit.SECONDS.sleep(5);

    List<Object> list = new ArrayList<>();
    for (int i = 0; i < 40; i++) {
        list.add(new Object());
    }

    t1 = new Thread(() -> {
        for (int i = 0; i < list.size(); i++) {
            synchronized (list.get(i)) {
            }
        }
        LockSupport.unpark(t2);
    });
    t2 = new Thread(() -> {
        LockSupport.park();
        for (int i = 0; i < list.size(); i++) {
            Object o = list.get(i);
            synchronized (o) {
                if (i == 18 || i == 19) {
                    System.out.println("THREAD-2 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
                }
            }
        }
        LockSupport.unpark(t3);
    });
    t3 = new Thread(() -> {
        LockSupport.park();
        for (int i = 0; i < list.size(); i++) {
            Object o = list.get(i);
            synchronized (o) {
                System.out.println("THREAD-3 Object"+(i+1)+":"+ClassLayout.parseInstance(o).toPrintable());
            }
        }
    });

    t1.start();
    t2.start();
    t3.start();
    t3.join();
    System.out.println("New: "+ClassLayout.parseInstance(new Object()).toPrintable());
}

對上面的執行流程進行分析:

  • 執行緒t1中,第1-40的鎖物件狀態變為偏向鎖
  • 執行緒t2中,第1-19的鎖物件撤銷偏向鎖升級為輕量級鎖,然後對第20-40的物件進行批量重偏向
  • 執行緒t3中,首先直接對第1-19個物件競爭輕量級鎖,而從第20個物件開始往後的物件不會再次進行批量重偏向,因此第20-39的物件進行偏向鎖撤銷升級為輕量級鎖,這時t2t3執行緒一共執行了40次的鎖撤銷,觸發鎖的批量撤銷機制,對偏向鎖進行撤銷置為輕量級鎖

看一下在3個執行緒都結束後建立的新物件:

可以看到,建立的新物件為無鎖不可偏向狀態001,說明當類觸發了批量撤銷機制後,jvm會禁用該類建立物件時的可偏向性,該類新建立的物件全部為無鎖不可偏向狀態。

2.5 總結

偏向鎖通過消除資源無競爭情況下的同步原語,提高了程式在單執行緒下訪問同步資源的執行效能,但是當出現多個執行緒競爭時,就會撤銷偏向鎖、升級為輕量級鎖。

如果我們的應用系統是高併發、並且程式碼中同步資源一直是被多執行緒訪問的,那麼撤銷偏向鎖這一步就顯得多餘,偏向鎖撤銷時進入Safe Point產生STW的現象應該是被極力避免的,這時應該通過禁用偏向鎖來減少效能上的損耗。

3 輕量級鎖

3.1 輕量級鎖原理

1、在程式碼訪問同步資源時,如果鎖物件處於無鎖不可偏向狀態,jvm首先將在當前執行緒的棧幀中建立一條鎖記錄(lock record),用於存放:

  • displaced mark word(置換標記字):存放鎖物件當前的mark word的拷貝
  • owner指標:指向當前的鎖物件的指標,在拷貝mark word階段暫時不會處理它

2、在拷貝mark word完成後,首先會掛起執行緒,jvm使用CAS操作嘗試將物件的 mark word 中的 lock record 指標指向棧幀中的鎖記錄,並將鎖記錄中的owner指標指向鎖物件的mark word

  • 如果CAS替換成功,表示競爭鎖物件成功,則將鎖標誌位設定成 00,表示物件處於輕量級鎖狀態,執行同步程式碼中的操作

  • 如果CAS替換失敗,則判斷當前物件的mark word是否指向當前執行緒的棧幀:
    • 如果是則表示當前執行緒已經持有物件的鎖,執行的是synchronized的鎖重入過程,可以直接執行同步程式碼塊
    • 否則說明該其他執行緒已經持有了該物件的鎖,如果在自旋一定次數後仍未獲得鎖,那麼輕量級鎖需要升級為重量級鎖,將鎖標誌位變成10,後面等待的執行緒將會進入阻塞狀態

4、輕量級鎖的釋放同樣使用了CAS操作,嘗試將displaced mark word 替換回mark word,這時需要檢查鎖物件的mark wordlock record指標是否指向當前執行緒的鎖記錄:

  • 如果替換成功,則表示沒有競爭發生,整個同步過程就完成了
  • 如果替換失敗,則表示當前鎖資源存在競爭,有可能其他執行緒在這段時間裡嘗試過獲取鎖失敗,導致自身被掛起,並修改了鎖物件的mark word升級為重量級鎖,最後在執行重量級鎖的解鎖流程後喚醒被掛起的執行緒

用流程圖對上面的過程進行描述:

3.2 輕量級鎖重入

我們知道,synchronized是可以鎖重入的,在輕量級鎖的情況下重入也是依賴於棧上的lock record完成的。以下面的程式碼中3次鎖重入為例:

synchronized (user){
    synchronized (user){
        synchronized (user){
            //TODO
        }
    }
}

輕量級鎖的每次重入,都會在棧中生成一個lock record,但是儲存的資料不同:

  • 首次分配的lock recorddisplaced mark word複製了鎖物件的mark wordowner指標指向鎖物件
  • 之後重入時在棧中分配的lock record中的displaced mark wordnull,只儲存了指向物件的owner指標

輕量級鎖中,重入的次數等於該鎖物件在棧幀中lock record的數量,這個數量隱式地充當了鎖重入機制的計數器。這裡需要計數的原因是每次解鎖都需要對應一次加鎖,只有最後解鎖次數等於加鎖次數時,鎖物件才會被真正釋放。在釋放鎖的過程中,如果是重入則刪除棧中的lock record,直到沒有重入時則使用CAS替換鎖物件的mark word

3.3 輕量級鎖升級

在jdk1.6以前,預設輕量級鎖自旋次數是10次,如果超過這個次數或自旋執行緒數超過CPU核數的一半,就會升級為重量級鎖。這時因為如果自旋次數過多,或過多執行緒進入自旋,會導致消耗過多cpu資源,重量級鎖情況下執行緒進入等待佇列可以降低cpu資源的消耗。自旋次數的值也可以通過jvm引數進行修改:

-XX:PreBlockSpin

jdk1.6以後加入了自適應自旋鎖Adapative Self Spinning),自旋的次數不再固定,由jvm自己控制,由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定:

  • 對於某個鎖物件,如果自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也是很有可能再次成功,進而允許自旋等待持續相對更長時間
  • 對於某個鎖物件,如果自旋很少成功獲得過鎖,那在以後嘗試獲取這個鎖時將可能省略掉自旋過程,直接阻塞執行緒,避免浪費處理器資源。

下面通過程式碼驗證輕量級鎖升級為重量級鎖的過程:

public static void main(String[] args) throws InterruptedException {
    User user = new User();
    System.out.println("--MAIN--:" + ClassLayout.parseInstance(user).toPrintable());
    Thread thread1 = new Thread(() -> {
        synchronized (user) {
            System.out.println("--THREAD1--:" + ClassLayout.parseInstance(user).toPrintable());
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    Thread thread2 = new Thread(() -> {
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (user) {
            System.out.println("--THREAD2--:" + ClassLayout.parseInstance(user).toPrintable());
        }
    });

    thread1.start();
    thread2.start();
    thread1.join();
    thread2.join();
    TimeUnit.SECONDS.sleep(3);
    System.out.println(ClassLayout.parseInstance(user).toPrintable());
}

在上面的程式碼中,執行緒2在啟動後休眠兩秒後再嘗試獲取鎖,確保執行緒1能夠先得到鎖,在此基礎上造成鎖物件的資源競爭。檢視物件鎖狀態變化:

線上程1持有輕量級鎖的情況下,執行緒2嘗試獲取鎖,導致資源競爭,使輕量級鎖升級到重量級鎖。在兩個執行緒都執行結束後,可以看到物件的狀態恢復為了無鎖不可偏向狀態,在下一次執行緒嘗試獲取鎖時,會直接從輕量級鎖狀態開始。

上面在最後一次列印前將主執行緒休眠3秒的原因是鎖的釋放過程需要一定的時間,如果線上程執行完成後直接列印物件記憶體佈局,物件可能仍處於重量級鎖狀態。

3.4 總結

輕量級鎖與偏向鎖類似,都是jdk對於多執行緒的優化,不同的是輕量級鎖是通過CAS來避免開銷較大的互斥操作,而偏向鎖是在無資源競爭的情況下完全消除同步。

輕量級鎖的“輕量”是相對於重量級鎖而言的,它的效能會稍好一些。輕量級鎖嘗試利用CAS,在升級為重量級鎖之前進行補救,目的是為了減少多執行緒進入互斥,當多個執行緒交替執行同步塊時,jvm使用輕量級鎖來保證同步,避免執行緒切換的開銷,不會造成使用者態與核心態的切換。但是如果過度自旋,會引起cpu資源的浪費,這種情況下輕量級鎖消耗的資源可能反而會更多。

4 重量級鎖

4.1 Monitor

重量級鎖是依賴物件內部的monitor(監視器/管程)來實現的 ,而monitor 又依賴於作業系統底層的Mutex Lock(互斥鎖)實現,這也就是為什麼說重量級鎖比較“重”的原因了,作業系統在實現執行緒之間的切換時,需要從使用者態切換到核心態,成本非常高。在學習重量級鎖的工作原理前,首先需要了解一下monitor中的核心概念:

  • owner:標識擁有該monitor的執行緒,初始時和鎖被釋放後都為null
  • cxq (ConnectionList):競爭佇列,所有競爭鎖的執行緒都會首先被放入這個佇列中
  • EntryList:候選者列表,當owner解鎖時會將cxq佇列中的執行緒移動到該佇列中
  • OnDeck:在將執行緒從cxq移動到EntryList時,會指定某個執行緒為Ready狀態(即OnDeck),表明它可以競爭鎖,如果競爭成功那麼稱為owner執行緒,如果失敗則放回EntryList
  • WaitSet:因為呼叫wait()wait(time)方法而被阻塞的執行緒會被放在該佇列中
  • count:monitor的計數器,數值加1表示當前物件的鎖被一個執行緒獲取,執行緒釋放monitor物件時減1
  • recursions:執行緒重入次數

用圖來表示執行緒競爭的的過程:

當執行緒呼叫wait()方法,將釋放當前持有的monitor,將owner置為null,進入WaitSet集合中等待被喚醒。當有執行緒呼叫notify()notifyAll()方法時,也會釋放持有的monitor,並喚醒WaitSet的執行緒重新參與monitor的競爭。

4.2 重量級鎖原理

當升級為重量級鎖的情況下,鎖物件的mark word中的指標不再指向執行緒棧中的lock record,而是指向堆中與鎖物件關聯的monitor物件。當多個執行緒同時訪問同步程式碼時,這些執行緒會先嚐試獲取當前鎖物件對應的monitor的所有權:

  • 獲取成功,判斷當前執行緒是不是重入,如果是重入那麼recursions+1
  • 獲取失敗,當前執行緒會被阻塞,等待其他執行緒解鎖後被喚醒,再次競爭鎖物件

在重量級鎖的情況下,加解鎖的過程涉及到作業系統的Mutex Lock進行互斥操作,執行緒間的排程和執行緒的狀態變更過程需要在使用者態和核心態之間進行切換,會導致消耗大量的cpu資源,導致效能降低。

總結

在jdk1.6中,引入了偏向鎖和輕量級鎖,並使用鎖升級機制對synchronized進行了充分的優化。其實除鎖升級外,還使用了鎖消除、鎖粗化等優化手段,所以對它的認識要脫離“重量級”這一概念,不要再單純的認為它的效能差了。在某些場景下,synchronized的效能甚至已經超過了Lock同步鎖。

儘管java對synchronized做了這些優化,但是在使用過程中,我們還是要儘量減少鎖的競爭,通過減小加鎖粒度和減少同步程式碼的執行時間,來降低鎖競爭,儘量使鎖維持在偏向鎖和輕量級鎖的級別,避免升級為重量級鎖,造成效能的損耗。

最後不得不再提一句,在java15中已經預設禁用了偏向鎖,並棄用所有相關的命令列選項,雖然說不確定未來的LTS版本會怎樣改動,但是瞭解一下偏向鎖的基礎也沒什麼不好的,畢竟你發任你發,我用java8~

如果文章對您有所幫助,歡迎關注公眾號 碼農參上

再談synchronized鎖升級

相關文章