深入理解偏向鎖、輕量級鎖、重量級鎖

狂盗一枝梅發表於2024-10-25

一、物件結構和鎖狀態

synchronized關鍵字是java中的內建鎖實現,內建鎖實際上就是個任意物件,其記憶體結構如下圖所示

Java物件結構

其中,Mark Word欄位在64位虛擬機器下佔64bit長度,其結構如下所示

64位Mark Word的結構資訊

可以看到Mark Word欄位有個很重要的作用就是記錄當前物件鎖狀態,最後3bit欄位用來標記當前鎖狀態是無鎖、偏向鎖、輕量級鎖還是重量級鎖。

(1)lock:鎖狀態標記位,佔兩個二進位制位,由於希望用盡可能少的二進位制位表示儘可能多的資訊,因此設定了lock標記。該標記的值不同,整個Mark Word表示的含義就不同。

(2)biased_lock:物件是否啟用偏向鎖標記,只佔1個二進位制位。為1時表示物件啟用偏向鎖,為0時表示物件沒有偏向鎖。

lock和biased_lock兩個標記位組合在一起共同表示Object例項處於什麼樣的鎖狀態。二者組合的含義具體如下表所示

image-20240919171225689

在JDK 1.6版本之前,所有的Java內建鎖都是重量級鎖。重量級鎖會造成CPU在使用者態和核心態之間頻繁切換,所以代價高、效率低。JDK 1.6版本為了減少獲得鎖和釋放鎖所帶來的效能消耗,引入了偏向鎖和輕量級鎖的實現。所以,在JDK 1.6版本中內建鎖一共有4種狀態:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這些狀態隨著競爭情況逐漸升級。內建鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖後不能再降級成偏向鎖。這種能升級卻不能降級的策略,其目的是提高獲得鎖和釋放鎖的效率。

隨著出現競爭以及競爭升級,鎖狀態會依次從無鎖狀態變為偏向鎖、輕量級鎖、重量級鎖,下面透過案例講解這個鎖膨脹的過程(基於Java8)。

二、無鎖

無鎖狀態的物件記憶體結構如下所示

image-20241018160620782

一個物件沒有被synchronized修飾的狀態就是無鎖狀態,看以下程式碼

import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;

/**
 * @author kdyzm
 * @date 2024/10/18
 */
@Slf4j
public class NoLockTest {

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

    @Data
    public static class User {
        private String userName;
    }
}

這裡使用JOL工具輸出了普通物件user的記憶體結構(關於java物件記憶體結構,詳情檢視《深入理解Java物件結構》)

image-20241018155332377

可以看到,輸出的64位MarkWord的值是十六進位制數 0x0000000000000001 (non-biasable; age: 0) 甚至還貼心的給出了當前是非偏向鎖狀態以及當前的物件年齡。由於這裡使用了JOL的高版本0.17,輸出的內容是大端序的,所以根據最後一個位元組01可以知道它的二進位制數是0000 0001,也就是說,biased_lock和lock 3 bit的數值是001

image-20240919171225689

根據鎖狀態標誌,可以知道當前物件是無鎖狀態。需要注意的是,這裡輸出無鎖狀態是因為沒開啟偏向鎖機制,如果開啟了偏向鎖標誌,建立的物件預設自帶偏向鎖標誌。

三、偏向鎖

偏向鎖是指一段同步程式碼一直被同一個執行緒所訪問,那麼該執行緒會自動獲取鎖,降低獲取鎖的代價。如果內建鎖處於偏向狀態,當有一個執行緒來競爭鎖時,先用偏向鎖,表示內建鎖偏愛這個執行緒,這個執行緒要執行該鎖關聯的同步程式碼時,不需要再做任何檢查和切換。偏向鎖在競爭不激烈的情況下效率非常高。

需要注意的是,偏向鎖狀態並非第一次發生了鎖佔用才設定的,JVM預設啟動4秒鐘之後才會延遲啟動偏向鎖機制,這時候建立的物件會預設加上偏向鎖標記。可以透過新增JVM啟動引數:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0,讓系統預設開啟偏向鎖機制。

偏向鎖狀態的Mark Word會記錄內建鎖自己偏愛的執行緒ID,內建鎖會將該執行緒當作自己的熟人。偏向鎖狀態下物件的Mark Word如下圖所示

image-20241018161149485

1、偏向鎖核心原理

偏向鎖的核心原理是:如果不存線上程競爭的一個執行緒獲得了鎖,那麼鎖就進入偏向狀態,此時Mark Word的結構變為偏向鎖結構,鎖物件的鎖標誌位(lock)被改為01,偏向標誌位(biased_lock)被改為1,然後執行緒的ID記錄在鎖物件的Mark Word中(使用CAS操作完成)。以後該執行緒獲取鎖時判斷一下執行緒ID和標誌位,就可以直接進入同步塊,連CAS操作都不需要,這樣就省去了大量有關鎖申請的操作,從而也就提升了程式的效能。

偏向鎖的主要作用是消除無競爭情況下的同步原語,進一步提升程式效能,所以,在沒有鎖競爭的場合,偏向鎖有很好的最佳化效果。但是,一旦有第二條執行緒需要競爭鎖,那麼偏向模式立即結束,進入輕量級鎖的狀態。

假如在大部分情況下同步塊是沒有競爭的,那麼可以透過偏向來提高效能。即在無競爭時,之前獲得鎖的執行緒再次獲得鎖時會判斷偏向鎖的執行緒ID是否指向自己,如果是,那麼該執行緒將不用再次獲得鎖,直接就可以進入同步塊;如果未指向當前執行緒,當前執行緒就會採用CAS操作將Mark Word中的執行緒ID設定為當前執行緒ID,如果CAS操作成功,那麼獲取偏向鎖成功,執行同步程式碼塊,如果CAS操作失敗,那麼表示有競爭,搶鎖執行緒被掛起,撤銷佔鎖執行緒的偏向鎖,然後將偏向鎖膨脹為輕量級鎖。

偏向鎖的缺點:如果鎖物件時常被多個執行緒競爭,偏向鎖就是多餘的,並且其撤銷的過程會帶來一些效能開銷。

2、偏向鎖案例

以下程式碼列印了單執行緒在佔據鎖前、已佔有鎖、釋放鎖後的記憶體結構(JOL工具輸出),從而觀察偏向鎖狀態(注意開啟偏向鎖功能:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;


/**
 * @author kdyzm
 * @date 2024/10/18
 * 啟動的時候注意加上JVM啟動引數:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
 */
@Slf4j
public class BiasedLockTest {

    public static void main(String[] args) throws InterruptedException {
        User lock = new User();
        log.info("搶佔鎖前lock的狀態:\n{}", lock.getObjectStruct());
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                synchronized (lock) {
                    if (i == 99) {
                        log.info("佔有鎖lock的狀態:\n{}", lock.getObjectStruct());
                    }
                }
            }
        }, "biased-lock-thread");
        thread.start();
        thread.join();
        log.info("釋放鎖後lock的狀態:\n{}", lock.getObjectStruct());
    }

    @ToString
    @Setter
    @Getter
    public static class User {
        private String userName;

        //物件結構字串
        public String getObjectStruct() {
            return ClassLayout.parseInstance(this).toPrintable();
        }
    }
}

輸出結果

image-20241019215404495

分析輸出結果:

搶佔鎖前:列印鎖記憶體結構,可以看到最後一個位元組是05,對應著biased+lock狀態組合,倒數三個bit是101,也就是偏向鎖狀態。但是列印出來的結果有點不一樣,是“biasable”狀態,這時候鎖物件還未鎖定、未偏向,所以也就是“可偏向”的狀態

佔有鎖:佔有鎖之後,可以看到最後一個位元組還是05,但是輸出已經提示是“biased”也就是偏向鎖狀態了,鎖的MarkWord已經記錄了佔有鎖的執行緒id,不過由於此執行緒ID不是Java中的Thread例項的ID,因此沒有辦法直接在Java程式中比對。

釋放鎖後:可以看到最後一個位元組還是05,也就是還是偏向鎖狀態,這是因為鎖釋放需要一定的開銷,而偏向鎖是一種樂觀鎖,它認為還是有很大可能偏向鎖的持有執行緒會繼續獲取鎖,所以不會主動撤銷偏向鎖狀態。

思考一個問題:同一個執行緒重複獲取相同的鎖,lock物件鎖變成了偏向鎖,那麼如果當前執行緒結束後,新建一個執行緒並重新獲取lock鎖,lock鎖中記錄的執行緒id是否會被更新成新執行緒的執行緒id實現重偏向呢?

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;


/**
 * @author kdyzm
 * @date 2024/10/18
 */
@Slf4j
public class BiasedLockTest {

    public static void main(String[] args) throws InterruptedException {
        User lock = new User();
        Thread threadA = runThread(lock, "A");
        Thread threadB = runThread(lock, "B");
        threadA.start();
        threadA.join();
        threadB.start();
        threadB.join();
    }

    private static Thread runThread(User lock, String name) throws InterruptedException {
        Thread thread = new Thread(() -> {
            log.info("搶佔鎖前lock的狀態:\n{}", lock.getObjectStruct());
            for (int i = 0; i < 100; i++) {
                synchronized (lock) {
                    if (i == 99) {
                        log.info("佔有鎖lock的狀態:\n{}", lock.getObjectStruct());
                    }
                }
            }
            log.info("釋放鎖後lock的狀態:\n{}", lock.getObjectStruct());
        }, name);
        return thread;
    }

    @ToString
    @Setter
    @Getter
    public static class User {
        private String userName;

        //JOL物件結構字串
        public String getObjectStruct() {
            return ClassLayout.parseInstance(this).toPrintable();
        }
    }
}

執行緒A執行結果:

image-20241021221902252

執行緒B執行結果:

image-20241021224745019

可以看到執行緒B等到執行緒A結束之後獲取到了lock鎖,但是鎖的狀態並沒有預想中的仍然保持偏向鎖狀態執行緒id指向執行緒B,而是直接鎖升級成了輕量級鎖。

從執行結果上來看,偏向鎖更像是“一錘子買賣”,只要偏向了某個執行緒,後續其他執行緒嘗試獲取鎖,都會變為輕量級鎖,這樣的偏向非常侷限。事實上並不是這樣,想要解釋這個問題,需要先了解“批次重偏向”的知識。

3、批次重偏向

批次重偏向技術是JVM針對鎖物件的Class的一種最佳化技術,如果某Class有很多個物件,每個物件都被當做物件鎖來用,當首次被執行緒獲取到之後,都會變成偏向鎖狀態;當都被釋放之後,它們預設保持偏向鎖狀態:一方面防止短時間內對應的執行緒再次獲取鎖,另一方面撤銷偏向狀態一個兩個還行,如果太多的話系統開銷也比較大;已經被釋放並且保持偏向鎖狀態的這些物件鎖這時候如果被另外的執行緒訪問,JVM就遇到一個問題:我是否應當立即修改物件鎖的MarkWord中的執行緒id,改成新執行緒的執行緒id?

你一個新執行緒過來獲取鎖,上來就重偏向你,是不是不大合理?再怎麼著也得考察考察你到底有沒有資格受JVM“偏愛”吧,考察方式就是新執行緒嘗試獲取物件鎖多少次,假設設定了閾值20,那就是我有20個物件鎖都已經是偏向鎖狀態了,偏向了執行緒A,如果這時候新執行緒B又要依次獲取這些物件鎖,那前19次都不會重偏向到B執行緒,直接升級輕量級鎖,到第20次的時候執行緒B又來了,那說明執行緒B以後大機率還是會嘗試獲取鎖,這時候再把偏向鎖重偏向執行緒B。

在在linux命令列或者Windows中的git bash中執行命令:java -XX:+PrintFlagsFinal | grep BiasedLock 檢視偏向鎖相關的配置引數

[root@lenovo ~]# java -XX:+PrintFlagsFinal | grep BiasedLock
     intx BiasedLockingBulkRebiasThreshold          = 20                                  {product}
     intx BiasedLockingBulkRevokeThreshold          = 40                                  {product}
     intx BiasedLockingDecayTime                    = 25000                               {product}
     intx BiasedLockingStartupDelay                 = 4000                                {product}
     bool TraceBiasedLocking                        = false                               {product}
     bool UseBiasedLocking                          = true                                {product}

其中,我們已經使用過了UseBiasedLocking以及BiasedLockingStartupDelay引數,之前使用它控制系統啟動時就啟用偏向鎖並且設定偏向鎖啟動延遲時間為0:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0,現在則關心下BiasedLockingBulkRebiasThreshold 引數

引數 釋義
BiasedLockingBulkRebiasThreshold=20 批次偏向閾值,預設值20

這個批次偏向閾值為20的意思就是:一個類有20個物件,執行緒A對每個物件synchronized獲取到了鎖並將每個物件修改成了偏向鎖,執行緒B重新獲取這20個物件鎖,嘗試重偏向,前19次都失敗了,這19個物件鎖從偏向鎖升級成了輕量級鎖,第20個則到達批次偏向閾值,會發生重偏向,偏向鎖從偏向A執行緒變成了偏向B執行緒。

下面透過程式碼驗證

import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;

import java.util.ArrayList;
import java.util.List;

/**
 * 偏向鎖-批次重偏向測試
 * 執行前注意加 -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 JVM啟動引數
 **/
@Slf4j
public class BulkRebiasTest {

    static class User {

    }

    public static void main(String[] args) throws InterruptedException {
        final List<User> list = new ArrayList<>();
        Thread A = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 20; i++) {
                    User a = new User();
                    list.add(a);
                    //獲取鎖之後都變成了偏向鎖,偏向執行緒A
                    synchronized (a) {

                    }
                }
            }
        };

        Thread B = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 20; i++) {
                    User a = list.get(i);
                    //從list當中拿出都是偏向執行緒A
                    log.info("B 加鎖前第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i + 1);
                    synchronized (a) {
                        //前19次撤銷偏向鎖偏向執行緒A,然後升級輕量級鎖指向執行緒B執行緒棧當中的鎖記錄
                        //第20次開始重偏向執行緒B
                        log.info("B 加鎖中第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i + 1);
                    }
                    //因為前19次是輕量級鎖,釋放之後為無鎖不可偏向
                    //但是第20次是偏向鎖 偏向執行緒B 釋放之後依然是偏向執行緒B
                    log.info("B 加鎖結束第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i + 1);
                }
                //第20次發生了重偏向以後,User類的epoch欄位變成了1,新生成的物件中的epoch欄位也是1
                log.info("新產生的物件:" + ClassLayout.parseInstance(new User()).toPrintable());
            }

        };
        A.start();
        Thread.sleep(1000);
        B.start();
        Thread.sleep(1000);
    }
}

執行結果如下

B執行緒前19迴圈次執行結果:

image-20241023104105626

B執行緒第20次執行結果:

image-20241023104355105

最後,重新生成一個User類的例項,直接列印看結果

image-20241023110816192

這裡出現了一個重要的欄位:epoch

4、epoch

epoch欄位是Mark Word中的一個佔2bit的欄位:

image-20241018161149485

epoch在偏向鎖中發揮了重要的作用,它決定了當前偏向鎖走重偏向還是鎖升級的邏輯。

每個class類維護了一個偏向鎖撤銷計數器,只要 class 的物件發生偏向撤銷,該計數器 +1,當這個值達到重偏向閾值(預設 20)時:BiasedLockingBulkRebiasThreshold=20,JVM 就認為該 class 的偏向鎖有問題,因此會進行批次重偏向, 它的實現方式就用到了epoch欄位。

每個 class 物件會有一個對應的epoch欄位,每個處於偏向鎖狀態物件mark word 中也有該欄位,其初始值為建立該物件時 class 中的epoch的值(此時二者是相等的)。

每次發生批次重偏向時,就將該值加 1,同時遍歷 JVM 中所有執行緒的棧:

  1. 找到該 class 所有正處於加鎖狀態的偏向鎖物件,將其epoch欄位改為新值
  2. class 中不處於加鎖狀態的偏向鎖物件(沒被任何執行緒持有,但之前是被執行緒持有過的,這種鎖物件的 markword 肯定也是有偏向的),保持 epoch 欄位值不變

這樣,當偏向鎖被其他執行緒嘗試獲取鎖時,就會檢查偏向鎖物件類中的epoch是否和偏向鎖Mark Word中的epoch相同:

  1. 如果不相同,表示偏向鎖物件類中的epoch增加時,偏向鎖的持有執行緒已經結束,所以偏向鎖Mark Word中的epoch並沒有增加,也就是說,該物件的偏向鎖已經無效了,這時候可以走重偏向邏輯(起名epoch"紀元"也就是這個意思,來到新紀元,老紀元的事情就不管了)。
  2. 如果相同,表示還是當前紀元內,當前的偏向鎖沒有發生過重偏向,又有新執行緒來競爭鎖,那鎖就要升級。

回到上面批次重偏向的案例,B執行緒迴圈20次對User類的偏向鎖例項每次都獲取到了鎖。前19次迴圈,由於User類的epoch還是0,每個偏向鎖物件Mark Word中的epoch也都是0,所以走了鎖升級流程,偏向鎖升級成了輕量級鎖,由於升級輕量級鎖的過程中發生了鎖撤銷,所以User類的偏向鎖撤銷計數器同時+1;第20次的時候User類的epoch加1變成了20觸發了批次重偏向,本應當走輕量級鎖升級的走了批次重偏向。發生批次重偏向之後,User類的epoch欄位加1變成了1,所以新建User類的例項預設epoch和User類保持一致,都是1。

5、批次撤銷

這裡討論下關於偏向鎖的另外一個引數:BiasedLockingBulkRevokeThreshold,

引數 釋義
BiasedLockingBulkRevokeThreshold=40 批次撤銷閾值,預設值40

從輸出上來看,它的預設值是40

[root@lenovo ~]# java -XX:+PrintFlagsFinal | grep BiasedLock
     intx BiasedLockingBulkRebiasThreshold          = 20                                  {product}
     intx BiasedLockingBulkRevokeThreshold          = 40                                  {product}
     intx BiasedLockingDecayTime                    = 25000                               {product}
     intx BiasedLockingStartupDelay                 = 4000                                {product}
     bool TraceBiasedLocking                        = false                               {product}
     bool UseBiasedLocking                          = true                                {product}

BiasedLockingBulkRevokeThreshold用來設定偏向鎖批次撤銷的閾值,當偏向鎖撤銷次數到達這個值時會發生兩件事情

  1. class 的 markword 將被修改為不可偏向無鎖狀態,這樣該class再生成物件將會變成無鎖狀態,當執行緒競爭鎖時不再走偏向鎖,直接進入輕量級鎖狀態。
  2. 遍歷所有當前存活的執行緒的棧,找到該 class 所有正處於偏向鎖狀態的鎖例項物件,執行偏向鎖的撤銷操作。

下面看驗證的例子

import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.LockSupport;

/**
 * @author kdyzm
 * @date 2024/10/23
 */
@Slf4j
public class BulkRevoteTest {

    static class User {

    }

    private static Thread A;
    private static Thread B;
    private static Thread C;

    public static void main(String[] args) throws InterruptedException {
        final List<User> list = new ArrayList<>();
        //A執行緒建立40把偏向鎖,並全部偏向A
        A = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 40; i++) {
                    User a = new User();
                    list.add(a);
                    //獲取鎖之後都變成了偏向鎖,偏向執行緒A
                    synchronized (a) {

                    }
                }
                //喚醒執行緒B
                LockSupport.unpark(B);
            }
        };

        //B執行緒撤銷前20把偏向鎖,達到批次重偏向閾值
        //對後20把偏向鎖重偏向到執行緒B
        B = new Thread() {
            @Override
            public void run() {
                //等待執行緒A喚醒
                LockSupport.park();
                for (int i = 0; i < 40; i++) {
                    User a = list.get(i);
                    //從list當中拿出都是偏向執行緒A
                    log.info("B 加鎖前第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i + 1);
                    synchronized (a) {
                        //前19次撤銷偏向鎖偏向執行緒A,然後升級輕量級鎖指向執行緒B執行緒棧當中的鎖記錄
                        //第20次開始重偏向執行緒B
                        log.info("B 加鎖中第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i + 1);
                    }
                    //因為前19次是輕量級鎖,釋放之後為無鎖不可偏向
                    //但是第20次是偏向鎖 偏向執行緒B 釋放之後依然是偏向執行緒B
                    log.info("B 加鎖結束第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i + 1);
                }
                //第20次發生了重偏向以後,User類的epoch欄位變成了1,新生成的物件中的epoch欄位也是1
                log.info("B 新產生的物件:" + ClassLayout.parseInstance(new User()).toPrintable());
                //喚醒執行緒C
                LockSupport.unpark(C);
            }

        };
		
        //C執行緒對後20把偏向到執行緒B的偏向鎖進行偏向鎖撤銷
        //加上B執行緒撤銷的20次共40次,達到偏向鎖批次撤銷的閾值
        C = new Thread() {
            @Override
            public void run() {
                //等待執行緒B喚醒
                LockSupport.park();
                //list陣列20座標以前是輕量級鎖
                //20以及20以後是偏向執行緒B的偏向鎖,所以從20座標開始
                for (int i = 20; i < 40; i++) {
                    User a = list.get(i);
                    //這裡拿出的都是偏向執行緒B的偏向鎖
                    log.info("C 加鎖前第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i - 20 + 1);
                    synchronized (a) {
                        //由於epoch相同都是1,全升級成了輕量級鎖
                        log.info("C 加鎖中第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i - 20 + 1);
                    }
                    //輕量級鎖釋放之後全變成了不可偏向的無鎖狀態
                    log.info("C 加鎖結束第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i - 20 + 1);
                    //觀察此時是否發生了批次撤銷,若發生了批次撤銷,新物件將會是無鎖狀態
                    log.info("C 第{}次生成新物件:{}", i - 20 + 1, ClassLayout.parseInstance(new User()).toPrintable());
                }
                //C執行緒再發生偏向鎖撤銷20次,達到批次撤銷的閾值
                //此時建立的物件應當都是無鎖狀態
                log.info("C 新產生的物件:" + ClassLayout.parseInstance(new User()).toPrintable());
            }

        };
        A.start();
        B.start();
        C.start();
        C.join();
    }

}

執行結果如下

先看執行緒B二十次以前的執行結果,從第1個元素到第19個偏向鎖都被撤銷,鎖升級到了輕量級鎖,鎖釋放後變成了無鎖狀態

image-20241023154629522

執行緒B獲取第20個偏向鎖時到達批次重偏向閾值,它和前19次不一樣,發生了批次重偏向,執行緒保持偏向鎖狀態,並且偏向執行緒B;之後的20到40也都這樣

image-20241023160657936

接著看執行緒C,執行緒C從第21個元素開始取元素,取出來的元素肯定都是偏向執行緒B的偏向鎖,執行緒C嘗試獲取這些鎖會導致鎖升級成輕量級鎖,第21到39的輸出都和第39輸出類似,以39為例輸出如下

image-20241023161648421

接下來是關鍵點,因為執行緒C第20次獲取list中的第40個偏向鎖,合計B執行緒撤銷的20次,會達到40次,也就是偏向鎖撤銷的閾值,如果不出意外,這次過後會發生偏向鎖批次撤銷,再建立物件,會是無鎖狀態

image-20241023162246144

果然,發生了批次撤銷,證明了撤銷40次會觸發批次撤銷,再建立的物件將變成無鎖狀態,這時候,該類的偏向鎖實際上就徹底被禁用了。

6、批次撤銷冷靜期

這裡討論下關於偏向鎖的另外一個引數:BiasedLockingDecayTime,

引數 釋義
BiasedLockingDecayTime=25000 批次撤銷時間閾值

從輸出上來看,它的預設值是25s

[root@lenovo ~]# java -XX:+PrintFlagsFinal | grep BiasedLock
     intx BiasedLockingBulkRebiasThreshold          = 20                                  {product}
     intx BiasedLockingBulkRevokeThreshold          = 40                                  {product}
     intx BiasedLockingDecayTime                    = 25000                               {product}
     intx BiasedLockingStartupDelay                 = 4000                                {product}
     bool TraceBiasedLocking                        = false                               {product}
     bool UseBiasedLocking                          = true                                {product}

這個引數的意思是

  1. 如果在距離上次批次重偏向發生的 25 秒之內,並且累計撤銷計數達到 40,就會發生批次撤銷(該類的偏向鎖功能徹底被禁用)
  2. 如果在距離上次批次重偏向發生超過 25 秒之外,就會重置在 [20, 40) 內的計數, 再給次機會

這玩意我給他起了個名字叫“撤銷冷靜期”,在冷靜期內別再去嘗試獲取鎖發生偏向鎖撤銷,那過了冷靜期之後就再給你1到20次的撤銷機會,相當於把批次撤銷的閾值提高了。

可以在上一章節的批次撤銷演示程式碼中加入延遲操作驗證這個問題。為了減少等待時間,可以新增JVM啟動引數:-XX:BiasedLockingDecayTime=5000 將批次撤銷時間閾值改為5秒。在C執行緒迴圈第20次的時候停止執行緒6秒鐘

C = new Thread() {
            @Override
            public void run() {
                LockSupport.park();
                //list陣列20座標以前是輕量級鎖
                //20以及20以後是偏向執行緒B的偏向鎖,所以從20座標開始
                for (int i = 20; i < 40; i++) {
                    //TODO 嘗試停止執行緒6秒鐘、5秒鐘、4.9秒鐘觀察最終的輸出結果
                    if (i == 39) {
                        try {
                            Thread.sleep(4900);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    User a = list.get(i);
                    //這裡拿出的都是偏向執行緒B的偏向鎖
                    log.info("C 加鎖前第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i - 20 + 1);
                    synchronized (a) {
                        //由於epoch相同都是1,全升級成了輕量級鎖
                        log.info("C 加鎖中第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i - 20 + 1);
                    }
                    //輕量級鎖釋放之後全變成了不可偏向的無鎖狀態
                    log.info("C 加鎖結束第 {} 次" + ClassLayout.parseInstance(a).toPrintable(), i - 20 + 1);
                    //觀察此時是否發生了批次撤銷,若發生了批次撤銷,新物件將會是無鎖狀態
                    log.info("C 第{}次生成新物件:{}", i - 20 + 1, ClassLayout.parseInstance(new User()).toPrintable());
                }
                //C執行緒再發生偏向鎖撤銷20次,達到批次撤銷的閾值
                //此時建立的物件應當都是無鎖狀態
                log.info("C 新產生的物件:" + ClassLayout.parseInstance(new User()).toPrintable());
            }

        };

會發現暫停6秒鐘、5秒鐘之後,C執行緒最終建立的新物件都是可偏向的狀態,而暫停4.9秒鐘,則C執行緒最終建立的執行緒變成了不可偏向的無鎖狀態。

7、流程圖

偏向鎖是個非常複雜的鎖型別,由於其複雜性,在JDK15及其以後的版本已經被廢棄(注意是廢棄,而非移除)。當然,你發任你發,我用java8,嘿嘿。總之掌握了總比沒掌握的好,根據理解,我畫了張流程圖展示偏向鎖鎖狀態的轉化過程,這個圖應該有錯誤的,如有錯誤請留言,我來改正~

無鎖、偏向鎖、輕量級鎖、重量級鎖-偏向鎖轉化流程圖.drawio

四、輕量級鎖

引入輕量級鎖的主要目的是在多執行緒競爭不激烈的情況下,透過CAS機制競爭鎖減少重量級鎖產生的效能損耗。重量級鎖使用了作業系統底層的互斥鎖(Mutex Lock),會導致執行緒在使用者態和核心態之間頻繁切換,從而帶來較大的效能損耗。

1、輕量級鎖核心原理

輕量鎖存在的目的是儘可能不動用作業系統層面的互斥鎖,因為其效能比較差。執行緒的阻塞和喚醒需要CPU從使用者態轉為核心態,頻繁地阻塞和喚醒對CPU來說是一件負擔很重的工作。同時我們可以發現,很多物件鎖的鎖定狀態只會持續很短的一段時間,例如整數的自加操作,在很短的時間內阻塞並喚醒執行緒顯然不值得,為此引入了輕量級鎖。輕量級鎖是一種自旋鎖,因為JVM本身就是一個應用,所以希望在應用層面上透過自旋解決執行緒同步問題。

輕量級鎖的執行過程:在搶鎖執行緒進入臨界區之前,如果內建鎖(臨界區的同步物件)沒有被鎖定,JVM首先將在搶鎖執行緒的棧幀中建立一個鎖記錄(Lock Record),用於儲存物件目前Mark Word的複製,這時的執行緒堆疊與內建鎖物件頭大致如下圖所示

image-20241024141506721

然後搶鎖執行緒將使用CAS自旋操作,嘗試將內建鎖物件頭的Mark Word的ptr_to_lock_record(鎖記錄指標)更新為搶鎖執行緒棧幀中鎖記錄的地址,如果這個更新執行成功了,這個執行緒就擁有了這個物件鎖。然後JVM將Mark Word中的lock標記位改為00(輕量級鎖標誌),即表示該物件處於輕量級鎖狀態。搶鎖成功之後,JVM會將Mark Word中原來的鎖物件資訊(如雜湊碼等)儲存在搶鎖執行緒鎖記錄的Displaced Mark Word(可以理解為放錯地方的Mark Word)欄位中,再將搶鎖執行緒中鎖記錄的owner指標指向鎖物件。

在輕量級鎖搶佔成功之後,鎖記錄和物件頭的狀態如圖所示

輕量級鎖結果圖示

2、輕量級鎖案例

關於輕量級鎖的案例在上一章節已經有提過,現在再拿出來瞧瞧

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;


/**
 * @author kdyzm
 * @date 2024/10/18
 * 啟動的時候注意加上JVM啟動引數:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
 */
@Slf4j
public class BiasedLockTest {

    public static void main(String[] args) throws InterruptedException {
        User lock = new User();
        Thread threadA = runThread(lock, "A");
        Thread threadB = runThread(lock, "B");
        threadA.start();
        threadA.join();
        threadB.start();
        threadB.join();
    }

    private static Thread runThread(User lock, String name) throws InterruptedException {
        Thread thread = new Thread(() -> {
            log.info("搶佔鎖前lock的狀態:\n{}", lock.getObjectStruct());
            for (int i = 0; i < 100; i++) {
                synchronized (lock) {
                    if (i == 99) {
                        log.info("佔有鎖lock的狀態:\n{}", lock.getObjectStruct());
                    }
                }
            }
            log.info("釋放鎖後lock的狀態:\n{}", lock.getObjectStruct());
        }, name);
        return thread;
    }

    @ToString
    @Setter
    @Getter
    public static class User {
        private String userName;

        //JOL物件結構字串
        public String getObjectStruct() {
            return ClassLayout.parseInstance(this).toPrintable();
        }
    }
}

執行緒A執行結果:

image-20241021221902252

執行緒B執行結果:

image-20241021224745019

可以看到執行緒B等到執行緒A結束之後獲取到了lock鎖,再次獲取lock鎖,鎖就變成了輕量級鎖,釋放後變成了無鎖狀態。從上一個章節中已經解釋過了,之所以沒有發生重偏向,是因為批次重偏向需要達到批次重偏向的撤銷次數的閾值,預設是20次,在此之前,任何新執行緒嘗試獲取鎖的行為都會導致偏向鎖升級成輕量級鎖。

3、輕量級鎖分類

輕量級鎖本質上就是自旋鎖,所謂的“自旋”本質上就是迴圈重試,它有兩種型別:普通自旋鎖自適應自旋鎖

普通自旋鎖

所謂普通自旋鎖,就是指當有執行緒來競爭鎖時,搶鎖執行緒會在原地迴圈等待,而不是被阻塞,直到那個佔有鎖的執行緒釋放鎖之後,這個搶鎖執行緒才可以獲得鎖。

鎖在原地迴圈等待的時候是會消耗CPU的,就相當於在執行一個什麼也不幹的空迴圈。所以輕量級鎖適用於臨界區程式碼耗時很短的場景,這樣執行緒在原地等待很短的時間就能夠獲得鎖了。

在JDK 1.6中,Java虛擬機器提供-XX:+UseSpinning引數來開啟自旋鎖,預設情況下,自旋的次數為10次,使用-XX:PreBlockSpin引數來設定自旋鎖的等待次數。

在JDK 1.7後的版本,自旋鎖的引數被取消,虛擬機器不再支援由使用者配置自旋鎖。自旋鎖總是被執行,自旋次數也由虛擬機器自行調整。

自適應自旋鎖

所謂自適應自旋鎖,就是等待執行緒空迴圈的自旋次數並非是固定的,而是會動態地根據實際情況來改變自旋等待的次數,自旋次數由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。自適應自旋鎖的大概原理是:

(1)如果搶鎖執行緒在同一個鎖物件上之前成功獲得過鎖,JVM就會認為這次自旋很有可能再次成功,因此允許自旋等待持續相對更長的時間。

(2)如果對於某個鎖,搶鎖執行緒很少成功獲得過,那麼JVM將可能減少自旋時間甚至省略自旋過程,以避免浪費處理器資源。

自適應自旋解決的是“鎖競爭時間不確定”的問題。自適應自旋假定不同執行緒持有同一個鎖物件的時間基本相當,競爭程度趨於穩定。總的思想是:根據上一次自旋的時間與結果調整下一次自旋的時間。

4、流程圖

以下是無鎖狀態的物件變成輕量級鎖的流程圖

image-20241024164546231

五、重量級鎖

重量級鎖和偏向鎖、輕量級鎖不同,偏向鎖、輕量級鎖本質上都是樂觀鎖,它們都是應用級別的鎖(JVM本身就是一個應用),重量級鎖則基於作業系統核心的互斥鎖實現,會發生使用者態和核心態的切換,開銷要更大,所以才叫“重量級鎖”。

1、重量級鎖核心原理

之前曾經提過,關於synchronized關鍵字底層使用了監視器鎖,JVM中每個物件都會有一個監視器,監視器和物件一起建立、銷燬。監視器相當於一個用來監視這些執行緒進入的特殊房間,其義務是保證(同一時間)只有一個執行緒可以訪問被保護的臨界區程式碼塊。

本質上,監視器是一種同步工具,也可以說是一種同步機制,主要特點是:

(1)同步。監視器所保護的臨界區程式碼是互斥地執行的。一個監視器是一個執行許可,任一執行緒進入臨界區程式碼都需要獲得這個許可,離開時把許可歸還。

(2)協作。監視器提供Signal機制,允許正持有許可的執行緒暫時放棄許可進入阻塞等待狀態,等待其他執行緒傳送Signal去喚醒;其他擁有許可的執行緒可以傳送Signal,喚醒正在阻塞等待的執行緒,讓它可以重新獲得許可並啟動執行。

在Hotspot虛擬機器中,監視器是由C++類ObjectMonitor實現的,ObjectMonitor類定義在share/vm/runtime/objectMonitor.hpp檔案中,其構造器程式碼大致如下:

//Monitor結構體
 ObjectMonitor::ObjectMonitor() {  
   _header      = NULL;  
   _count       = 0;  
   _waiters     = 0,  

   //執行緒的重入次數
   _recursions  = 0;      
   _object       = NULL;  

//標識擁有該Monitor的執行緒
   _owner        = NULL;   

  //等待執行緒組成的雙向迴圈連結串列
   _WaitSet             = NULL;  
   _WaitSetLock  = 0 ;  
   _Responsible  = NULL ;  
   _succ                = NULL ;  

  //多執行緒競爭鎖進入時的單向連結串列
   cxq                  = NULL ; 
   FreeNext             = NULL ;  

  //_owner從該雙向迴圈連結串列中喚醒執行緒節點
   _EntryList           = NULL ; 
   _SpinFreq            = 0 ;  
   _SpinClock           = 0 ;  
   OwnerIsThread = 0 ;  
 }

ObjectMonitor的Owner(_owner)、WaitSet(_WaitSet)、Cxq(_cxq)、EntryList(_EntryList)這幾個屬性比較關鍵。ObjectMonitor的WaitSet、Cxq、EntryList這三個佇列存放搶奪重量級鎖的執行緒,而ObjectMonitor的Owner所指向的執行緒即為獲得鎖的執行緒。

Cxq、EntryList、WaitSet這三個佇列的說明如下:

(1)Cxq:競爭佇列(Contention Queue),所有請求鎖的執行緒首先被放在這個競爭佇列中。

(2)EntryList:Cxq中那些有資格成為候選資源的執行緒被移動到EntryList中。

(3)WaitSet:某個擁有ObjectMonitor的執行緒在呼叫Object.wait()方法之後將被阻塞,然後該執行緒將被放置在WaitSet連結串列中。

內部搶鎖過程如下

image-20241025144330190

設計C++底層的視線,程式碼比較複雜,有興趣的可以參考文章:

Java多執行緒:objectMonito原始碼解讀

2、重量級鎖案例

下面透過自增案例展示從無鎖、偏向鎖、輕量級鎖,最終膨脹為重量級鎖的案例。

import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;

import java.util.concurrent.locks.LockSupport;

/**
 * @author kdyzm
 * @date 2024/10/25
 * 執行前注意加上JVM啟動引數:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 -XX:BiasedLockingDecayTime=5000
 */
@Slf4j
public class FatLock {

    private static final FatLock LOCK = new FatLock();

    private static Long counter = 0L;

    public static void main(String[] args) throws InterruptedException {
        //A執行緒執行100次自增
        Thread A = new Thread(() -> {
            log.info("加鎖前:{}", ClassLayout.parseInstance(LOCK).toPrintable());
            for (int i = 0; i < 100; i++) {
                synchronized (LOCK) {
                    counter++;
                    if (i == 99) {
                        log.info("加鎖中:{}", ClassLayout.parseInstance(LOCK).toPrintable());
                    }
                }
            }
            log.info("加鎖後:{}", ClassLayout.parseInstance(LOCK).toPrintable());
        }, "A");
        //B執行緒執行100次自增並在第100次時進入等待狀態
        Thread B = new Thread(() -> {
            log.info("加鎖前:{}", ClassLayout.parseInstance(LOCK).toPrintable());
            for (int i = 0; i < 100; i++) {
                synchronized (LOCK) {
                    counter++;
                    if (i == 99) {
                        log.info("加鎖中:{}", ClassLayout.parseInstance(LOCK).toPrintable());
                        //進入等待狀態創造重量級鎖形成條件
                        LockSupport.park();
                    }
                }
            }
            log.info("加鎖後:{}", ClassLayout.parseInstance(LOCK).toPrintable());
        }, "B");
        //C執行緒執行100次自增
        Thread C = new Thread(() -> {
            log.info("加鎖前:{}", ClassLayout.parseInstance(LOCK).toPrintable());
            for (int i = 0; i < 100; i++) {
                synchronized (LOCK) {
                    counter++;
                    if (i == 99) {
                        log.info("加鎖中:{}", ClassLayout.parseInstance(LOCK).toPrintable());
                    }
                }
            }
            log.info("加鎖後:{}", ClassLayout.parseInstance(LOCK).toPrintable());
        }, "C");

        A.start();
        //等待A執行緒執行完成
        Thread.sleep(1000);
        B.start();
        //等待B執行緒執行完成並進入等待狀態
        Thread.sleep(1000);
        C.start();
        //等待C執行緒進入阻塞狀態
        Thread.sleep(1000);
        //恢復B執行緒的執行狀態
        LockSupport.unpark(B);
    }
}

執行結果如下

執行緒名 加鎖前 加鎖中 加鎖後
A biasable(可偏向的,未設定偏向執行緒id) biased(已偏向A執行緒) biased(已偏向A執行緒)
B biased(已偏向A執行緒) thin lock(輕量級鎖) fat lock(重量級鎖)
C thin lock(輕量級鎖) fat lock(重量級鎖) non-biasable(無鎖)

流程圖如下所示

無鎖、偏向鎖、輕量級鎖、重量級鎖-重量級鎖升級.drawio

六、參考文件

《JAVA高併發核心程式設計 卷2:多執行緒、鎖、JMM、JUC、高併發設計模式》

偏向鎖-批次重偏向和批次撤銷測試

深入淺出偏向鎖

深入JVM鎖

偏向鎖和重量級鎖的多連問,你能接住幾個?

Java多執行緒:objectMonitor原始碼解讀(3)

偏向鎖、輕量級鎖、重量級鎖,Synchronized底層原始碼終極解析!



最後,歡迎關注我的部落格:https://blog.kdyzm.cn ~

相關文章