首頁 > 軟體

Java多執行緒並行synchronized 關鍵字

2022-06-16 10:02:36

基礎

Java 在虛擬機器器層面提供了 synchronized 關鍵字供開發者快速實現互斥同步的重量級鎖來保障執行緒安全。

synchronized 關鍵字可用於兩種場景:

  • 修飾方法。
  • 持有一個物件,並執行一個程式碼塊。

而根據加鎖的物件不同,又分為兩種情況:

  • 物件鎖
  • 類物件鎖

以下程式碼範例是 synchronized 的具體用法:

1. 修飾方法
synchronized void function() { ... }
2. 修飾靜態方法
static synchronized void function() { ... }
3. 對物件加鎖
synchronized(object) {
    // ...
}

修飾普通方法

synchronized 修飾方法加鎖,相當於對當前物件加鎖,類 A 中的 function() 是一個 synchronized 修飾的普通方法:

class A {
    synchronized void function() { ... }
}

它等效於:

class A {
    void function() { 
        synchronized(this) { ... }
    }
}

結論synchronized 修飾普通方法,實際上是對當前物件進行加鎖處理,也就是物件鎖。

修飾靜態方法

synchronized 修飾靜態方法,相當於對靜態方法所屬類的 class 物件進行加鎖,這裡的 class 物件是 JVM 在進行類載入時建立的代表當前類的 java.lang.Class 物件,每個類都有唯一的 Class 物件。這種對 Class 物件加鎖,稱之為類物件鎖

類載入階段主要做了三件事情:

根據特定名稱查詢類或介面型別的二進位制位元組流。

將這個二進位制位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。

在記憶體中生成一個代表這個類的 java.lang.Class 物件,作為方法區這個類的各種資料的存取入口。

class A {
    static synchronized void function() { ... }
    // 相當於對 class 物件加鎖,這裡只是描述,靜態方法和普通方法不可等效。
    void function() {
        synchronized(A.class) { ... }
    }
}

也就是說,如果一個普通方法中持有了 A.class ,那麼就會與靜態方法 function() 互斥,因為本質上它們加鎖的物件是同一個。

Synchronized 加鎖原理

public class Sync {
    Object lock = new Object();
    public void function() {
        synchronized (lock) {
            System.out.print("lock");
        }
    }
}

這是一個簡單的 synchronized 關鍵字對 lock 物件進行加鎖的 demo ,經過javac Sync.java 命令反編譯生成 class 檔案,然後通過 javap -verbose Sync 命令檢視內容:

  public void function();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #7                  // Field lock:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter                      // 【1】
         7: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
        10: ldc           #19                 // String lock
        12: invokevirtual #20                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        15: aload_1
        16: monitorexit                       // 【2】
        17: goto          25
        20: astore_2
        21: aload_1
        22: monitorexit                       // 【3】
        23: aload_2
        24: athrow
        25: return

【1】與【2】處的 monitorenter 和 monitorexit 兩個指令就是加鎖操作的關鍵。

而【3】處的 monitorexit ,是為了保證在同步程式碼塊中出現 Exception 或者 Error 時,通過呼叫第二個monitorexit 指令來保證釋放鎖。

monitorenter 指令會讓物件在物件頭中的鎖計數器計數 + 1, monitorexit 指令則相反,計數器 - 1。

monitor 鎖的底層邏輯

物件會關聯一個 monitor ,monitorenter 指令會檢查物件是否管理了 monitor 如果沒有建立一個 ,並將其關聯到這個物件。

monitor 內部有兩個重要的成員變數 owner(擁有這把鎖的執行緒)和 recursions(記錄執行緒擁有鎖的次數),當一個執行緒擁有 monitor 後其他執行緒只能等待。

加鎖意味著在同一時間內,物件只能被一個執行緒獲取到。

monitorenter

monitorenter 指令標記了同步程式碼塊的開始位置,也就是這個時候會建立一個 monitor ,然後當前執行緒會嘗試獲取這個 monitor 。

monitorenter 指令觸發時,執行緒嘗試獲取 monitor 鎖有三種邏輯:

  • monitor 鎖計數器為 0 ,意味著目前還沒有被任意執行緒持有,那這個執行緒就會立刻持有這個 monitor 鎖,然後把鎖計數器+1,一旦+1,別的執行緒再想獲取,就需要等待。
  • 如果又對當前物件執行了一個 monitorenter 指令,那麼物件關聯的 monitor 已經存在,就會把鎖計數器 + 1,鎖計數器的值此時是 2,並且隨著重入的次數,會一直累加。
  • monitor 鎖已被其他執行緒持有,鎖計數器不為 0 ,當前執行緒等待鎖釋放。

monitorexit

monitorexit 指令會對鎖計數器進行 - 1 ,如果在執行 - 1 後鎖計數器仍不為 0 ,持有鎖的執行緒仍持有這個鎖,直到鎖計數器等於 0 ,持有執行緒才釋放了鎖。

任意執行緒存取加鎖物件時,首先要獲取物件的 monitor ,如果獲取失敗,該現場進入阻塞狀態,即 Blocked。當這個物件的 monitor 被持有執行緒釋放後,阻塞等待的執行緒就有機會獲取到這個 monitor 。

synchronized 修飾靜態方法

根據鎖計數器的原理,理論上說, monitorenter 和 monitorexit 兩個指令應該成對出現(拋除處理 Exception 或 Error 的 monitorexit)。重複對同一個執行緒進行加鎖。

我們來寫一個範例檢查一下:

public class Sync {
    Object lock = new Object();
    public void function() {
        synchronized (Sync.class) {
            System.out.print("lock");
            method();
        }
    }
    synchronized static void method() {
        System.out.print("method");
    };
}

synchronized (Sync.class) 先持有了 Sync 的類物件,然後再通過 synchronized 靜態方法進行一次加鎖,理論上說,反編譯後應該是出現兩對 monitorenter 和 monitorexit ,檢視反編譯 class 檔案:

  public void function();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #8                  // class javatest/Sync
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #19                 // String lock
        10: invokevirtual #20                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        13: invokestatic  #26                 // Method method:()V
        16: aload_1
        17: monitorexit
        18: goto          26
        21: astore_2
        22: aload_1
        23: monitorexit
        24: aload_2
        25: athrow
        26: return

method方法的位元組碼:

  static synchronized void method();
    descriptor: ()V
    flags: (0x0028) ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #29                 // String method
         5: invokevirtual #20                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
         8: return

神奇的現象出現了,monitorenter 出現了一次, monitorexit 出現了兩次,這和我們最開始只加一次鎖的 demo 一致了。

那麼是不是因為靜態方法的原因呢,我們將 demo 改造成下面的效果:

public class Sync {
    public void function() {
        synchronized (Sync.class) {
            System.out.print("lock");
        }
        method();
    }
    void method() {
        synchronized (Sync.class) {
            System.out.print("method");
        }
    }
}

反編譯結果:

  public void function();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #7                  // class javatest/Sync
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #15                 // String lock
        10: invokevirtual #17                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        13: aload_0
        14: invokevirtual #23                 // Method method:()V
        17: aload_1
        18: monitorexit
        19: goto          27
        22: astore_2
        23: aload_1
        24: monitorexit
        25: aload_2
        26: athrow
        27: return

method 方法的編譯結果:

  void method();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #7                  // class javatest/Sync
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #26                 // String method
        10: invokevirtual #17                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return

從這裡看,的確是出現了兩組 monitorentermonitorexit 。

而從靜態方法的 flags: (0x0028) ACC_STATIC, ACC_SYNCHRONIZED 中,我們可以看出,JVM 對於同步靜態方法並不是通過monitorenter和 monitorexit 實現的,而是通過方法的 flags 中新增 ACC_SYNCHRONIZED 標記實現的。

而如果換一種方式,不使用巢狀加鎖,改為連續執行兩次對同一個物件加鎖解鎖:

public void function() {
    synchronized (Sync.class) {
        System.out.print("lock");
    }
    method();
}

反編譯:

public void function();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #7                  // class javatest/Sync
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #15                 // String lock
        10: invokevirtual #17                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: aload_0
        24: invokevirtual #23                 // Method method:()V
        27: return

method 方法的編譯結果是:

  void method();
    descriptor: ()V
    flags: (0x0000)
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #7                  // class javatest/Sync
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #26                 // String method
        10: invokevirtual #17                 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return

看來結果也是一樣的,monitorenter 和 monitorexit 成對出現。

優點、缺點及優化

synchronized 關鍵字是 JVM 提供的 API ,是重量級鎖,所以它具有重量級鎖的優點,保持嚴格的互斥同步。

而缺點則同樣是互斥同步的角度來說的:

  • 效率低:鎖的釋放情況少,只有程式碼執行完畢或者異常結束才會釋放鎖;試圖獲取鎖的時候不能設定超時,不能中斷一個正在使用鎖的執行緒,相對而言,Lock 可以中斷和設定超時。
  • 不夠靈活:加鎖和釋放的時機單一,每個鎖僅有一個單一的條件(某個物件)。

優化方案:Java 提供了java.util.concurrent 包,其中 Lock 相關的一些 API ,拓展了很多功能,可以考慮使用 J.U.C 中豐富的鎖機制實現來替代 synchronized

其他說明

最後,本文環境基於:

java version "14.0.1" 2020-04-14
Java(TM) SE Runtime Environment (build 14.0.1+7)
Java HotSpot(TM) 64-Bit Server VM (build 14.0.1+7, mixed mode, sharing)
JDK version 1.8.0_312

到此這篇關於Java多執行緒並行synchronized 關鍵字的文章就介紹到這了,更多相關Java synchronized內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com