Synchronized同步鎖

LZC發表於2020-12-17

原文出處:time.geekbang.org/column/article/1...

在併發程式設計中,多個執行緒訪問同一個共享資源時,我們必須考慮如何維護資料的原子性。**在 JDK1.5 之前,Java 是依靠 Synchronized 關鍵字實現鎖功能來做到這點的。Synchronized 是 JVM 實現的一種內建鎖,鎖的獲取和釋放是由 JVM 隱式實現。

到了 JDK1.5 版本,併發包中新增了 Lock 介面來實現鎖功能,它提供了與 Synchronized 關鍵字類似的同步功能,只是在使用時需要顯示獲取和釋放鎖。

Lock 同步鎖是基於 Java 實現的,而 Synchronized 是基於底層作業系統的 Mutex Lock 實現的,每次獲取和釋放鎖操作都會帶來使用者態和核心態的切換,從而增加系統效能開銷。因此,在鎖競爭激烈的情況下,Synchronized 同步鎖在效能上就表現得非常糟糕,它也常被大家稱為重量級鎖。

特別是在單個執行緒重複申請鎖的情況下,JDK1.5 版本的 Synchronized 鎖效能要比 Lock 的效能差很多。例如,在 Dubbo 基於 Netty 實現的通訊中,消費端向服務端通訊之後,由於接收返回訊息是非同步,所以需要一個執行緒輪詢監聽返回資訊。而在接收訊息時,就需要用到鎖來確保 request session 的原子性。如果我們這裡使用 Synchronized 同步鎖,那麼每當同一個執行緒請求鎖資源時,都會發生一次使用者態和核心態的切換。

到了 JDK1.6 版本之後,Java 對 Synchronized 同步鎖做了充分的最佳化,甚至在某些場景下,它的效能已經超越了 Lock 同步鎖。這一講我們就來看看 Synchronized 同步鎖究竟是透過了哪些最佳化,實現了效能地提升。

瞭解 Synchronized 同步鎖最佳化之前,我們先來看看它的底層實現原理,這樣可以幫助我們更好地理解後面的內容。

通常 Synchronized 實現同步鎖的方式有兩種,一種是修飾方法,一種是修飾方法塊。以下就是透過 Synchronized 實現的兩種同步方法加鎖的方式:

public class Main {
    Object o = new Object();
    // 關鍵字在例項方法上,鎖為當前例項
    public synchronized void method1() {
        // code
    }
    // 關鍵字在程式碼塊上,鎖為括號裡面的物件
    public void method2() {
        synchronized (o) {
            // code
        }
    }
}

下面我們可以透過反編譯看下具體位元組碼的實現,執行以下反編譯命令,就可以輸出我們想要的位元組碼:

javac -encoding UTF-8 Main.java  // 先執行編譯 class 檔案命令
javap -v Main.class // 再透過 javap 列印出位元組檔案

透過輸出的位元組碼,你會發現:Synchronized 在修飾同步程式碼塊時,是由 monitorenter 和 monitorexit 指令來實現同步的。進入 monitorenter 指令後,執行緒將持有 Monitor 物件,退出 monitorenter 指令後,執行緒將釋放該 Monitor 物件。

 public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #3                  // Field o:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter
         7: aload_1
         8: monitorexit
         9: goto          17
        12: astore_2
        13: aload_1
        14: monitorexit
        15: aload_2
        16: athrow
        17: return
      Exception table:
         from    to  target type
             7     9    12   any
            12    15    12   any
      LineNumberTable:
        line 9: 0
        line 11: 7
        line 12: 17
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 12
          locals = [ class Main, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

再來看以下同步方法的位元組碼,你會發現:當 Synchronized 修飾同步方法時,並沒有發現 monitorenter 和 monitorexit 指令,而是出現了一個 ACC_SYNCHRONIZED 標誌。

這是因為 JVM 使用了 ACC_SYNCHRONIZED 訪問標誌來區分一個方法是否是同步方法。當方法呼叫時,呼叫指令將會檢查該方法是否被設定 ACC_SYNCHRONIZED 訪問標誌。如果設定了該標誌,執行執行緒將先持有 Monitor 物件,然後再執行方法。在該方法執行期間,其它執行緒將無法獲取到該 Mointor 物件,當方法執行完成後,再釋放該 Monitor 物件。

 public synchronized void method1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 6: 0

透過以上的原始碼,我們再來看看 Synchronized 修飾方法是怎麼實現鎖原理的。

JVM 中的同步是基於進入和退出管程(Monitor)物件實現的。每個物件例項都會有一個 Monitor,Monitor 可以和物件一起建立、銷燬。Monitor 是由 ObjectMonitor 實現,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 檔案實現,如下所示:

ObjectMonitor() {
   _header = NULL;
   _count = 0; // 記錄個數
   _waiters = 0,
   _recursions = 0;
   _object = NULL;
   _owner = NULL;
   _WaitSet = NULL; // 處於 wait 狀態的執行緒,會被加入到 _WaitSet
   _WaitSetLock = 0 ;
   _Responsible = NULL ;
   _succ = NULL ;
   _cxq = NULL ;
   FreeNext = NULL ;
   _EntryList = NULL ; // 處於等待鎖 block 狀態的執行緒,會被加入到該列表
   _SpinFreq = 0 ;
   _SpinClock = 0 ;
   OwnerIsThread = 0 ;
}

當多個執行緒同時訪問一段同步程式碼時,多個執行緒會先被存放在 ContentionList 和 _EntryList 集合中,處於 block 狀態的執行緒,都會被加入到該列表。接下來當執行緒獲取到物件的 Monitor 時,Monitor 是依靠底層作業系統的 Mutex Lock 來實現互斥的,執行緒申請 Mutex 成功,則持有該 Mutex,其它執行緒將無法獲取到該 Mutex,競爭失敗的執行緒會再次進入 ContentionList 被掛起。

如果執行緒呼叫 wait() 方法,就會釋放當前持有的 Mutex,並且該執行緒會進入 WaitSet 集合中,等待下一次被喚醒。如果當前執行緒順利執行完方法,也將釋放 Mutex。

看完上面的講解,相信你對同步鎖的實現原理已經有個深入的瞭解了。

總結來說就是:同步鎖在這種實現方式中,因 Monitor 是依賴於底層的作業系統實現,存在使用者態與核心態之間的切換,所以增加了效能開銷。

為了提升效能,JDK1.6 引入了偏向鎖、輕量級鎖、重量級鎖概念,來減少鎖競爭帶來的上下文切換,而正是新增的 Java 物件頭實現了鎖升級功能。

當 Java 物件被 Synchronized 關鍵字修飾成為同步鎖後,圍繞這個鎖的一系列升級操作都將和 Java 物件頭有關。

在 JDK1.6 JVM 中,物件例項在堆記憶體中被分為了三個部分:物件頭例項資料對齊填充。其中 Java 物件頭由 Mark Word、指向類的指標以及陣列長度三部分組成。

Mark Word 記錄了物件和鎖有關的資訊。Mark Word 在 64 位 JVM 中的長度是 64bit,我們可以一起看下 64 位 JVM 的儲存結構是怎麼樣的。如下圖所示:

鎖升級功能主要依賴於 Mark Word 中的鎖標誌位和釋放偏向鎖標誌位,Synchronized 同步鎖就是從偏向鎖開始的,隨著競爭越來越激烈,偏向鎖升級到輕量級鎖,最終升級到重量級鎖。下面我們就沿著這條最佳化路徑去看下具體的內容。

偏向鎖

偏向鎖主要用來最佳化同一個執行緒多次申請同一個鎖的最佳化。在某些情況下,大部分時間是同一個執行緒競爭鎖資源,例如,在建立一個執行緒並線上程中執行迴圈監聽的場景下,或單執行緒操作一個執行緒安全集合時,同一執行緒每次都需要獲取和釋放鎖,每次操作都會發生使用者態與核心態的切換

偏向鎖的作用就是,當一個執行緒再次訪問這個同步程式碼或方法時,該執行緒只需去物件頭的 Mark Word 中去判斷一下是否有偏向鎖指向它的 ID,無需再進入 Monitor 去競爭物件了。當物件被當做同步鎖並有一個執行緒搶到了鎖時,鎖標誌位還是 01,“是否偏向鎖”標誌位設定為 1,並且記錄搶到鎖的執行緒 ID,表示進入偏向鎖狀態。

一旦出現其它執行緒競爭鎖資源時,偏向鎖就會被撤銷。偏向鎖的撤銷需要等待全域性安全點,暫停持有該鎖的執行緒,同時檢查該執行緒是否還在執行該方法,如果是,則升級鎖,反之則被其它執行緒搶佔。

下圖中紅線流程部分為偏向鎖獲取和撤銷流程:

因此,在高併發場景下,當大量執行緒同時競爭同一個鎖資源時,偏向鎖就會被撤銷,發生 stop the word 後, 開啟偏向鎖無疑會帶來更大的效能開銷,這時我們可以透過新增 JVM 引數關閉偏向鎖來調優系統效能,示例程式碼如下:

-XX:-UseBiasedLocking // 關閉偏向鎖(預設開啟)

或者

-XX:+UseHeavyMonitors  // 設定重量級鎖

輕量級鎖

當有另外一個執行緒競爭獲取這個鎖時,由於該鎖已經是偏向鎖,當發現物件頭 Mark Word 中的執行緒 ID 不是自己的執行緒 ID,就會進行 CAS 操作獲取鎖,如果獲取成功,直接替換 Mark Word 中的執行緒 ID 為自己的 ID,該鎖會保持偏向鎖狀態;如果獲取鎖失敗,代表當前鎖有一定的競爭,偏向鎖將升級為輕量級鎖。

輕量級鎖適用於執行緒交替執行同步塊的場景,絕大部分的鎖在整個同步週期內都不存在長時間的競爭。

下圖中紅線流程部分為升級輕量級鎖及操作流程:

(1)輕量級鎖加鎖執行緒在執行同步塊之前,JVM會先在當前執行緒的棧楨中建立用於儲存鎖記錄的空間並將物件頭中的Mark Word複製到鎖記錄中,官方稱為Displaced Mark Word。然後執行緒嘗試使用CAS將物件頭中的Mark Word替換為指向鎖記錄的指標。如果成功,當前執行緒獲得鎖,如果失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。

如果出現兩條以上的執行緒爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹為重量級鎖,鎖標誌的狀態值變為“10”,此時Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也必須進入阻塞狀態。

(2)輕量級鎖解鎖輕量級解鎖時,會使用原子的CAS操作將Displaced MarkWord替換回到物件頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。

自旋鎖與重量級鎖

輕量級鎖 CAS 搶鎖失敗,執行緒將會被掛起進入阻塞狀態。如果正在持有鎖的執行緒在很短的時間內釋放資源,那麼進入阻塞狀態的執行緒無疑又要申請鎖資源。

JVM 提供了一種自旋鎖,可以透過自旋方式不斷嘗試獲取鎖,從而避免執行緒被掛起阻塞。這是基於大多數情況下,執行緒持有鎖的時間都不會太長,畢竟執行緒被掛起阻塞可能會得不償失。

從 JDK1.7 開始,自旋鎖預設啟用,自旋次數由 JVM 設定決定,這裡我不建議設定的重試次數過多,因為 CAS 重試操作意味著長時間地佔用 CPU。

自旋鎖重試之後如果搶鎖依然失敗,同步鎖就會升級至重量級鎖,鎖標誌位改為 10。在這個狀態下,未搶到鎖的執行緒都會進入 Monitor,之後會被阻塞在 _WaitSet 佇列中。

下圖中紅線流程部分為自旋後升級為重量級鎖的流程:

在鎖競爭不激烈且鎖佔用時間非常短的場景下,自旋鎖可以提高系統效能。一旦鎖競爭激烈或鎖佔用的時間過長,自旋鎖將會導致大量的執行緒一直處於 CAS 重試狀態,佔用 CPU 資源,反而會增加系統效能開銷。所以自旋鎖和重量級鎖的使用都要結合實際場景。

在高負載、高併發的場景下,我們可以透過設定 JVM 引數來關閉自旋鎖,最佳化系統效能,示例程式碼如下:

-XX:-UseSpinning // 引數關閉自旋鎖最佳化(預設開啟) 
-XX:PreBlockSpin // 引數修改預設的自旋次數。JDK1.7後,去掉此引數,由jvm控制

除了鎖升級最佳化,Java 還使用了編譯器對鎖進行最佳化。JIT 編譯器在動態編譯同步塊的時候,藉助了一種被稱為逃逸分析的技術,來判斷同步塊使用的鎖物件是否只能夠被一個執行緒訪問,而沒有被髮布到其它執行緒。

確認是的話,那麼 JIT 編譯器在編譯這個同步塊的時候不會生成 synchronized 所表示的鎖的申請與釋放的機器碼,即消除了鎖的使用。在 Java7 之後的版本就不需要手動配置了,該操作可以自動實現。

鎖粗化同理,就是在 JIT 編譯器動態編譯時,如果發現幾個相鄰的同步塊使用的是同一個鎖例項,那麼 JIT 編譯器將會把這幾個同步塊合併為一個大的同步塊,從而避免一個執行緒“反覆申請、釋放同一個鎖”所帶來的效能開銷。

除了鎖內部最佳化和編譯器最佳化之外,我們還可以透過程式碼層來實現鎖最佳化,減小鎖粒度就是一種慣用的方法。

當我們的鎖物件是一個陣列或佇列時,集中競爭一個物件的話會非常激烈,鎖也會升級為重量級鎖。我們可以考慮將一個陣列和佇列物件拆成多個小物件,來降低鎖競爭,提升並行度。

最經典的減小鎖粒度的案例就是 JDK1.8 之前實現的 ConcurrentHashMap 版本。我們知道,HashTable 是基於一個陣列 + 連結串列實現的,所以在併發讀寫操作集合時,存在激烈的鎖資源競爭,也因此效能會存在瓶頸。而 ConcurrentHashMap 就很很巧妙地使用了分段鎖 Segment 來降低鎖資源競爭,如下圖所示:

JVM 在 JDK1.6 中引入了分級鎖機制來最佳化 Synchronized,當一個執行緒獲取鎖時,首先物件鎖將成為一個偏向鎖,這樣做是為了最佳化同一執行緒重複獲取導致的使用者態與核心態的切換問題;其次如果有多個執行緒競爭鎖資源,鎖將會升級為輕量級鎖,它適用於在短時間內持有鎖,且分鎖有交替切換的場景;輕量級鎖還使用了自旋鎖來避免執行緒使用者態與核心態的頻繁切換,大大地提高了系統效能;但如果鎖競爭太激烈了,那麼同步鎖將會升級為重量級鎖。

減少鎖競爭,是最佳化 Synchronized 同步鎖的關鍵。我們應該儘量使 Synchronized 同步鎖處於輕量級鎖或偏向鎖,這樣才能提高 Synchronized 同步鎖的效能;透過減小鎖粒度來降低鎖競爭也是一種最常用的最佳化方法;另外我們還可以透過減少鎖的持有時間來提高 Synchronized 同步鎖在自旋時獲取鎖資源的成功率,避免 Synchronized 同步鎖升級為重量級鎖。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章