切換語言為:簡體

使用Synchronized時,鎖資源物件升級過程是否一定會經過偏向鎖階段?

  • 爱糖宝
  • 2024-08-20
  • 2059
  • 0
  • 0

在java語言中,有兩個鎖一個是jdk層面的ReentrantLock,一個是jvm層面的synchronized,這兩個鎖是我們掌握併發程式設計中不可或缺的兩個類。本文主要從使用、原理兩個方面來介紹synchronized

使用

synchronized用法

public class SynchronizedTest {

    Object o1 = new Object();

    static Object o2 = new Object();

    @SneakyThrows
    public synchronized void test1() {
        Thread.sleep(1000);
    }

    @SneakyThrows
    public static synchronized void test2() {
        Thread.sleep(1000);
    }

    @SneakyThrows
    public void test3() {
        synchronized (o1) {
            Thread.sleep(1000);
        }
    }

    @SneakyThrows
    public void test4() {
        synchronized (o2) {
            Thread.sleep(1000);
        }
    }

    @SneakyThrows
    public void test5() {
        synchronized (this) {
            Thread.sleep(1000);
        }
    }

    @SneakyThrows
    public void test6() {
        synchronized (SynchronizedTest.class) {
            Thread.sleep(1000);
        }
    }
}

這是synchronized的幾種常見用法,但你瞭解其中鎖的粒度麼?哪些是鎖物件、哪些是鎖整個類的呢?下面寫了一個測試的執行程式碼,透過程式碼的執行結果最有說服力。
程式碼說明:

  1. 獲取開始時間;

  2. 分別建立兩個執行緒,並且new兩個SynchronizedTest,使用物件分別呼叫上述方法;

  3. 開啟兩個執行緒;

  4. 等待兩個執行緒執行完成;

  5. 列印執行的總時長;

    • 如果時長為1s,則說明鎖的粒度是當前new出來的物件;

    • 如果時長為2s,則說明鎖的力度是當前類。

@SneakyThrows
public static void main(String[] args) {
    long startTime = System.currentTimeMillis();
    Thread t1 = new Thread(() -> new SynchronizedTest().test1());
    Thread t2 = new Thread(() -> new SynchronizedTest().test1());
    t1.start();
    t2.start();

    t1.join();
    t2.join();
    System.out.println("執行時間: " + (System.currentTimeMillis() - startTime) / 1000);
}

鎖粒度

大家可以根據上述程式碼自己執行一遍,這樣印象會更深;下面我直接貼出採用各使用方法時鎖的粒度。

test1 : 鎖的是當前實體的該方法,執行結果為1s.
test2 : 鎖的是當前,執行結果為2s.
test3 : 鎖的是當前實體的o1屬性,執行結果為1s.
test4 : 鎖的是當前類的o2屬性,執行結果為2s.
test5 : 鎖的是當前物件,執行結果為1s.
test6 : 鎖的是當前,執行結果為1s.

原理

在原理方面,會先看一下加了synchronized後位元組碼會有什麼變化,再介紹一下在jdk1.6做了哪些最佳化,然後著重介紹一下鎖的各個階段以及重量級鎖的實現方法。

位元組碼

首先我們來分析一下,使用synchronized時位元組碼層面會如何變化,這裏我寫了一個類再透過javac、javap來檢視一下位元組碼有什麼變化。java程式碼如下:

public class Test {
    Object o = new Object();
    public void test1(){
    }

    public void test2(){
        synchronized (o){
        }
    }
}

在該java型別目錄下,執行javac Test.java,再執行 javap -v Test.class,執行完後會出現稍微能看明白一點的彙編程式碼,其中這兩個方法彙編程式碼如下:

  public void test1();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 11: 0

  public void test2();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1 
      /** * 0: 將物件引用壓入運算元棧 */ 
      0: aload_0 
      /** * 1: 獲取指定欄位的值並壓入運算元棧 */ 
      1: getfield #7 // Field o:Ljava/lang/Object; 
      /** * 4: 複製運算元棧頂元素 */ 
      4: dup 
      /** * 5: 將運算元棧頂元素儲存到區域性變數 1 中 */ 
      5: astore_1 
      /** * 6: 進入同步塊,獲取物件的監視器鎖 */ 
      6: monitorenter 
      /** * 7: 將區域性變數 1 所引用的物件壓入運算元棧 */ 
      7: aload_1 
      /** * 8: 退出同步塊,釋放物件的監視器鎖 */ 
      8: monitorexit 
      /** * 9: 跳轉到偏移量為 17 的指令 */ 
      9: goto 17 
      /** * 12: 將異常物件引用儲存到區域性變數 2 中 */ 
      12: astore_2 
      /** * 13: 將區域性變數 1 所引用的物件壓入運算元棧 */ 
      13: aload_1 
      /** * 14: 退出同步塊,釋放物件的監視器鎖 */ 
      14: monitorexit 
      /** * 15: 將區域性變數 2 所引用的異常物件壓入運算元棧 */ 
      15: aload_2 
      /** * 16: 丟擲運算元棧頂的異常 */ 
      16: athrow 
      /** * 17: 方法返回 */ 
      17: return

這裏我給test2方法增加了註釋,可以觀察到到加了synchronized後會多出來monitorenter,monitorexit兩個指令,後面我們分析原始碼時可以著重看一下monitor是如何實現的鎖。

鎖最佳化

在JDK1.5的時候,Doug Lee推出了JUC包,在這裏實現了ReentrantLock,並且它的效能遠高於synchronized,所以JDK團隊就在JDK1.6中,對synchronized做了大量的最佳化。例如:

鎖消除:在synchronized修飾的程式碼中,如果不存在操作共享變數的情況,會觸發鎖消除,即便寫了synchronized,也不會觸發。如:

public int test(){
    synchronized (o){
        int i = 0;
        return ++i;
    }
}

鎖膨脹:如果在一個方法中,頻繁的獲取和釋放鎖資源,就會最佳化為將鎖的範圍擴大,避免頻繁的競爭和獲取鎖資源帶來不必要的消耗。如:

public void test1() {
    for (int i = 0; i < 100000; i++) {
        synchronized (o) {
        }
    }
}

鎖升級:在synchronized在1.6版本之前,獲取不到鎖時,會立即掛起當前執行緒,後續釋放鎖時再喚醒競爭執行緒進行鎖的競爭。而到了1.6版本時,對synchronized做了鎖升級的最佳化;

無鎖、匿名偏向:當前物件沒有作為鎖存在。
偏向鎖:如果當前鎖資源,只有一個執行緒在使用,那麼這個執行緒過來,只需要判斷,該物件的threadId指向的是否為當前執行緒。

  • 如果是,直接獲取鎖;

  • 如果不是,基於CAS的方式,嘗試透過cas將threadId指向當前執行緒。如果獲取不到,觸發鎖升級,升級為輕量級鎖。(偏向鎖狀態出現了鎖競爭的情況)

輕量級鎖:會採用自旋鎖的方式去頻繁的以CAS的形式獲取鎖資源(採用的是自適應自旋鎖

  • 如果cas成功則獲取鎖;

  • 如果自旋了一定次數,沒獲取到鎖,鎖升級。

重量級鎖:就是最傳統的synchronized方式,獲取不到鎖資源,就掛起當前執行緒,等待其他執行緒釋放鎖並喚起該執行緒進行鎖資源競爭。

觀察鎖升級

爲了更直觀的觀察到鎖的升級過程,首先我在下面我貼了一張鎖資源物件頭中markdown在各個狀態下各個位元組含義的示意圖,大家對比著看一下。

使用Synchronized時,鎖資源物件升級過程是否一定會經過偏向鎖階段? 其次在程式碼中引用了jol-core,它可以在程式碼層面列印出物件頭資訊,接下來我會舉一些例子讓大家能更直觀的看到鎖的升級。

implementation 'org.openjdk.jol:jol-core:0.9'

無鎖

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

上面程式碼就是列印物件o的例項資訊,列印結果如下:

使用Synchronized時,鎖資源物件升級過程是否一定會經過偏向鎖階段?

可以看到我用紅色框框圈出來的就是鎖的標識位001為無鎖狀態

偏向鎖

public static void main(String[] args) {
    Object o = new Object();
    new Thread(() -> {
        synchronized (o) {
            //t1  - 偏向鎖
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }).start();
}

上面程式碼就是用一個執行緒獲取鎖資源,此時該鎖資源就會偏向到該執行緒,再看一下列印結果:

使用Synchronized時,鎖資源物件升級過程是否一定會經過偏向鎖階段? 使用Synchronized時,鎖資源物件升級過程是否一定會經過偏向鎖階段? 這兩張圖的結果都是正確的,不同的jdk版本會出現不同的結果,如果你本地執行發現是000輕量級鎖的標識不用驚訝,這是因為你使用的jdk版本預設開啟了鎖的偏向延遲。我本地測試使用open jdk8是第一種結果,在程式碼執行前加上Thread.sleep(5000L);會變成第二種結果,使用zulu jdk11、jdk17、jdk21都是都是000,他們預設都不開啟偏向鎖。

偏向鎖在升級為輕量級鎖時,會涉及到偏向鎖撤銷,需要等到一個安全點(STW),纔會讓偏向鎖撤銷,在知道有併發時,就可以選擇不開啟偏向鎖,或者是設定偏向鎖延遲開啟。

因為JVM在啟動時,需要載入大量的.class檔案到記憶體中,這個操作會涉及到synchronized的使用,爲了避免出現偏向鎖撤銷操作,JVM啟動初期,有一個延遲4s開啟偏向鎖的操作

命令引數如下:

-XX:+/-UseBiasedLocking 啟動/禁用偏向鎖,預設虛擬機器啟動4秒後啟動偏向鎖 -XX:BiasedLockingStartupDelay 虛擬機器啟動後,立刻啟動偏向鎖

輕量級鎖

這裏不就舉例了,上個例子中不開啟偏向鎖會直接變更為輕量級鎖的。

重量級鎖

大家還記得上面在看位元組碼時有一個Monitor物件,我們就來看一下這個物件中有什麼內容。

ObjectMonitor() { 
  // 頭節點,儲存著 MarkWord 
  _header = NULL; 
  // 競爭鎖的執行緒個數 
  _count = 0; 
  // 等待(wait)的執行緒個數 
  _waiters = 0; 
  // 標識當前 synchronized 鎖重入的次數 
  _recursions = 0; 
  // 關聯的物件 
  _object = NULL; 
  // 持有鎖的執行緒 
  _owner = NULL; 
  // 儲存等待(wait)執行緒的資訊,雙向連結串列 
  _WaitSet = NULL; 
  // WaitSet 的鎖 
  _WaitSetLock = 0 ; 
  // 負責的執行緒(可能用於特定的職責標識) 
  _Responsible = NULL ; 
  // 後繼節點(可能用於連結串列結構) 
  _succ = NULL ; 
  // 獲取鎖資源失敗後,執行緒要放到當前的單向連結串列中 
  _cxq = NULL ; 
  // 下一個空閒節點(可能用於連結串列管理) 
  FreeNext = NULL ; 
  // 等待獲取鎖的執行緒佇列(_cxq 以及被喚醒的 WaitSet 中的執行緒,在一定機制下,會放到這裏) 
  _EntryList = NULL ; 
  // 自旋頻率 
  _SpinFreq = 0 ; 
  // 自旋時鐘 
  _SpinClock = 0 ; 
  // 是否為執行緒所有者的標識 
  OwnerIsThread = 0 ; 
  // 前一個所有者執行緒的執行緒 ID 
  _previous_owner_tid = 0; 
}

上述標識我自己翻譯了一遍,這個類還有很多實現方法,但由於都是c語言了,就不再詳細解釋了,可以觀察到這裏面有幾個屬性和ReentrantLock很像,大概也借鑑了ReentrantLock的實現。如重入次數連結串列等待執行緒持有鎖的執行緒等,有一個區別是這裏採用了單向連結串列,而ReentrantLock採用了雙向連結串列因為ReentrantLock有找前驅節點的需求所以採用了雙向連結串列,例如在釋放鎖資源喚醒後置節點時,此時後置已經被取消,那麼就需要從tail節點往前查詢,又或者加入到連結串列中,從後插入只需要O(1)的時間複雜度。而ObjectMonitor採用單向連結串列,由於我沒詳細看過程式碼,沒法給大傢俱體的觀點,如果有會c語言的同學可以幫忙解答一下。

總結

‌在jdk1.6以後在低併發時使用synchronized的效能是高於lock的,因為它是在JVM層面實現的,而且不需要顯式地獲取和釋放鎖。但是在高併發下synchronized是不如lock,例如lock支援讀寫鎖分離、執行緒掛起時插入到連結串列中時間複雜度為O(1)等。
除此之外lock還支援更多使用場景,例如:公平鎖和非公平鎖、嘗試獲取鎖等。具體選擇哪個還是要根據具體的應用場景和需求來選擇,大家平時都使用哪個鎖呢?

0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.