萬字乾貨|Synchronized關鍵字詳解

小牛呼嚕嚕發表於2022-12-08

作者:小牛呼嚕嚕 | https://xiaoniuhululu.com
計算機內功、JAVA底層、面試、職業成長相關資料等更多精彩文章在公眾號「小牛呼嚕嚕

前言

大家好,我是呼嚕嚕,在之前的文章中https://mp.weixin.qq.com/s/0Ii636KQ9sWwX-OhdlPIYw,我們知道了volatile關鍵字可以保證可見性、有序性,但無法保證原子性的。今天我們來聊聊synchronized關鍵字,其可以同時保證三者,實現執行緒安全。

執行緒安全

在介紹synchronized關鍵字之前,我們得強調一下什麼是執行緒安全,所謂執行緒安全:

當多個執行緒同時訪問一個物件時, 如果不用考慮這些執行緒在執行時環境下的排程和交替執行, 也不需要進行額外的同步, 或者在呼叫方進行任何其他的協調操作, 呼叫這個物件的行為都可以獲得正確的結果, 那就稱這個物件是執行緒安全的

什麼是synchronized關鍵字?

在 Java 早期版本中,synchronized 屬於 重量級鎖,效率低下;不過在 Java 6 之後,Java 官方對從 JVM 層面對 synchronized 較大最佳化,所以現在的 synchronized 鎖效率也最佳化得非常不錯。目前不論是各種開源框架還是 JDK 原始碼都大量使用了 synchronized 關鍵字

synchronized實現方式

synchronized的使用其實比較簡單,可以用它來修飾例項方法和靜態方法,也可以用來修飾程式碼塊。我們需要注意的是synchronized是一個物件鎖,也就是它鎖的是一個物件。我們無論使用哪一種方法,synchronized都需要有一個鎖物件

  1. 修飾例項方法
  2. 修飾靜態方法
  3. 修飾程式碼塊

1.修飾例項方法

synchronized修飾例項方法, 在方法上加上synchronized關鍵字即可。

public class SynchronizedTest1 {
    public synchronized void test() {
        System.out.println("synchronized 修飾 方法");
    }
}

此時,synchronized加鎖的物件就是這個方法所在例項的本身,作用於當前例項加鎖,進入同步程式碼前要獲得**當前例項的鎖 **。

補充一個常見的面試題:構造方法可以用synchronized關鍵字修飾嗎?

不能,也不需要,因為構造方法本身就是執行緒安全的

2.修飾靜態方法

synchronized修飾靜態方法的使用與例項方法並無差別,在靜態方法上加上synchronized關鍵字即可

public static synchronized void test(){
       i++;
}

由於靜態方法不屬於任何一個例項物件,歸整個類所有,不依賴於類的特定例項,被類的所有例項共享。給靜態方法加synchronized鎖,會作用於類的所有物件例項 ,進入同步程式碼前要獲得 當前靜態方法所在類的Class物件的鎖

有一點我們需要知道:如果一個執行緒 A 呼叫一個例項物件的非靜態 synchronized 方法,而執行緒 B 需要呼叫這個例項物件所屬類的靜態 synchronized 方法,是允許的,不會發生互斥現象,因為訪問靜態 synchronized 方法佔用的鎖是當前類的鎖,而訪問非靜態 synchronized 方法佔用的鎖是當前例項物件鎖

3.修飾程式碼塊

synchronized修飾程式碼塊需要傳入一個物件。

public class SynchronizedTest2 {
    public void test() {
        synchronized (this) {
            System.out.println("synchronized 修飾 程式碼塊");
        }
    }
}

此時synchronized加鎖物件即為傳入的這個物件例項,指定加鎖物件,進入同步程式碼庫前要獲得給定物件的鎖
需要注意的是這裡的this

  1. synchronized(object) ,表示進入同步程式碼庫前要獲得 給定物件的鎖
  2. synchronized(類.class) ,表示進入同步程式碼前要獲得 給定 Class 的鎖
  3. 最好不要使用 synchronized(String a) ,因為在 JVM 中,字串常量池具有快取功能,如果我們多次加鎖,會加鎖在同一個物件上?

synchronized關鍵字底層原理

想要搞清楚synchronized關鍵字的底層原理,我們得看下其對應的底層位元組碼

synchronized修飾例項方法

以上文SynchronizedTest1類為例子,其中synchronized關鍵字修飾例項方法

獲取SynchronizedTest1.class的位元組碼:

javac SynchronizedTest1.java
javap -c -v SynchronizedTest1.class

Classfile /D:/ideaProjects/src/main/java/com/zj/ideaprojects/demo/test2/SynchronizedTest1.class
  Last modified 2022-10-28; size 466 bytes
  MD5 checksum a28131024850f35538b6ad60621b8838
  Compiled from "SynchronizedTest1.java"
public class com.zj.ideaprojects.demo.test2.SynchronizedTest1
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#14         // java/lang/Object."<init>":()V
   #2 = Fieldref           #15.#16        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #17            // synchronized 修飾 方法
   #4 = Methodref          #18.#19        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #20            // com/zj/ideaprojects/demo/test2/SynchronizedTest1
   #6 = Class              #21            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               test
  #12 = Utf8               SourceFile
  #13 = Utf8               SynchronizedTest1.java
  #14 = NameAndType        #7:#8          // "<init>":()V
  #15 = Class              #22            // java/lang/System
  #16 = NameAndType        #23:#24        // out:Ljava/io/PrintStream;
  #17 = Utf8               synchronized 修飾 方法
  #18 = Class              #25            // java/io/PrintStream
  #19 = NameAndType        #26:#27        // println:(Ljava/lang/String;)V
  #20 = Utf8               com/zj/ideaprojects/demo/test2/SynchronizedTest1
  #21 = Utf8               java/lang/Object
  #22 = Utf8               java/lang/System
  #23 = Utf8               out
  #24 = Utf8               Ljava/io/PrintStream;
  #25 = Utf8               java/io/PrintStream
  #26 = Utf8               println
  #27 = Utf8               (Ljava/lang/String;)V
{
  public com.zj.ideaprojects.demo.test2.SynchronizedTest1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public synchronized void test(); //需要注意的地方!
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED //需要注意的地方!
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String synchronized 修飾 方法
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
}
SourceFile: "SynchronizedTest1.java"



筆者已將位元組碼中 需要注意的地方,標註出來了

我們可以發現當synchronized修飾方法時,jvm往位元組碼中新增一個ACC_SYNCHRONIZED識別符號,它的作用是:
當一個執行緒訪問方法時,會去檢查是否存在ACC_SYNCHRONIZED識別符號,如果存在,則先要獲得對應的monitor鎖,然後執行方法。當方法執行結束(不管是正常return還是丟擲異常)都會釋放對應的monitor鎖。如果此時有其他執行緒也想要訪問這個方法時,會因得不到monitor鎖而阻塞。當同步方法中丟擲異常且方法內沒有捕獲,則在向外丟擲時會先釋放已獲得的monitor鎖;

大家看到這裡就會有疑問: monitor鎖是什麼?

monitor鎖是什麼?

monitor字面意思就是"監視器",也叫管程。它的出現是為了解決作業系統級別關於執行緒同步原語的使用複雜性,類似於語法糖,對複雜操作進行封裝。在HotSpot原始碼中,Monitor是基於C++實現的,詳情可見objectMonitor。每個物件中都內建了一個 ObjectMonitor物件。

monitor的作用:限制同一時刻,只有一個執行緒能進入monitor框定的臨界區(持有權),達到執行緒互斥,保護臨界區中臨界資源的安全,實現程式執行緒安全的目的。它還具有管理程式,執行緒狀態的功能,Java中Object類提供了notify和wait方法來對執行緒進行控制,其實它們都依賴於monitor物件,這就是wait/notify等方法只能在同步的塊或者方法中才能被呼叫的根本原因

ObjectMonitor中有3個重要部分,分別為_ower,_WaitSet和_EntryList

  1. 如果沒有其他執行緒正在持有物件的Monitor,入口佇列的"等待執行緒"需要和等待佇列被喚醒的"等待執行緒"競爭(CPU排程),選出一個執行緒來獲取物件的Monitor,執行受保護的程式碼段,執行完畢後釋放Monitor;如果已經有執行緒持有物件的Monitor,那麼需要等待其釋放Monitor後再進行競爭
  2. 如果一個執行緒是從等待佇列中被notify()方法喚醒後(呼叫notify方法,並不意味著釋放了Monitor,必須要等同步程式碼塊結束後才會釋放Monitor),獲取到的Monitor,它會去讀取它自己先前儲存的PC計數器中的地址,從它呼叫wait方法的地方繼續執行

那monitor鎖究竟是什麼呢?呼嚕嚕檢視了JVM官方文件中關於Synchronization的部分:
monitor鎖就是Java基於monitor機制的實現的重量級鎖 。在Java中,每一個物件例項都會關聯一個Monitor物件,既可以與物件一起建立銷燬,也可以線上程試圖獲取物件鎖時自動生成。當這個Monitor物件被執行緒持有後,它便處於鎖定狀態;如果執行緒沒有獲取到monitor會被阻塞。

那麼物件例項是怎麼做到關聯一個Monitor物件,一起建立銷燬的?換句話說如何透過java物件可以獲取到和它對應的監視器,這就需要我們去了解Java物件記憶體佈局

Java物件記憶體佈局

在JVM中,物件在記憶體中儲存的佈局可以分為三個區域,分別是物件頭、例項資料以及填充資料。

  • 物件頭(object header):物件頭又被分為兩部分,分別為:Mark Word(標記欄位)、Class Pointer(型別指標)。如果是陣列,那麼還會有陣列長度。我們等會重點討論
  • 例項資料(Instance Data):主要是存放類的資料資訊,父類的資訊,物件欄位屬性資訊。這部分記憶體按4位元組對齊。
  • 對齊填充(Padding):由於虛擬機器要求物件起始地址必須是8位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊。

物件頭:主要有兩部分,分別為:Mark Word和 Class Pointer

  1. Class Pointer(型別指標)

即型別指標,是物件指向它的類後設資料的指標,虛擬機器透過這個指標來確定這個物件是哪個類的例項。

  1. Mark Word(標記欄位)

用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等等。我們去看openjdk_1.8的原始碼,對應路徑/openjdk/hotspot/src/share/vm/oops,Mark Word對應到C++的程式碼markOop.hpp,可以從註釋中看到它們的組成
32位:

64位:

從上圖中,我們發現當物件頭中鎖狀態重量級鎖時,物件頭的MarkWord儲存了指向堆中的Monitor物件的指標。相信大家看的這裡,會恍然大悟 !

synchronized修飾程式碼塊

以上文SynchronizedTest2類為例子,其中synchronized關鍵字修飾程式碼塊

獲取SynchronizedTest2.class的位元組碼:

javac -encoding utf-8 SynchronizedTest2.java
javap -c -v SynchronizedTest2.class

Classfile /D:/ideaProjects/src/main/java/com/zj/ideaprojects/demo/test2/SynchronizedTest2.class
  Last modified 2022-10-28; size 575 bytes
  MD5 checksum ac915d460a3da67f6c76c5ed2aae01f1
  Compiled from "SynchronizedTest2.java"
public class com.zj.ideaprojects.demo.test2.SynchronizedTest2
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#18         // java/lang/Object."<init>":()V
   #2 = Fieldref           #19.#20        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #21            // synchronized ▒▒▒▒ ▒▒▒▒▒
   #4 = Methodref          #22.#23        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #24            // com/zj/ideaprojects/demo/test2/SynchronizedTest2
   #6 = Class              #25            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               test
  #12 = Utf8               StackMapTable
  #13 = Class              #24            // com/zj/ideaprojects/demo/test2/SynchronizedTest2
  #14 = Class              #25            // java/lang/Object
  #15 = Class              #26            // java/lang/Throwable
  #16 = Utf8               SourceFile
  #17 = Utf8               SynchronizedTest2.java
  #18 = NameAndType        #7:#8          // "<init>":()V
  #19 = Class              #27            // java/lang/System
  #20 = NameAndType        #28:#29        // out:Ljava/io/PrintStream;
  #21 = Utf8               synchronized ▒▒▒▒ ▒▒▒▒▒
  #22 = Class              #30            // java/io/PrintStream
  #23 = NameAndType        #31:#32        // println:(Ljava/lang/String;)V
  #24 = Utf8               com/zj/ideaprojects/demo/test2/SynchronizedTest2
  #25 = Utf8               java/lang/Object
  #26 = Utf8               java/lang/Throwable
  #27 = Utf8               java/lang/System
  #28 = Utf8               out
  #29 = Utf8               Ljava/io/PrintStream;
  #30 = Utf8               java/io/PrintStream
  #31 = Utf8               println
  #32 = Utf8               (Ljava/lang/String;)V
{
  public com.zj.ideaprojects.demo.test2.SynchronizedTest2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public void test();
    descriptor: ()V
    flags: ACC_PUBLIC  //注意這裡!
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter     //注意這裡!
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String synchronized ▒▒▒▒ ▒▒▒▒▒
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit     //注意這裡!
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit    //注意這裡!
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 5: 0
        line 6: 4
        line 7: 12
        line 8: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/zj/ideaprojects/demo/test2/SynchronizedTest2, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}
SourceFile: "SynchronizedTest2.java"


我們可以發現:synchronized 同步語句塊的在位元組碼中的實現,是使用了 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步程式碼塊的開始位置,monitorexit 指令則指明同步程式碼塊的結束位置。

  1. 每個物件都擁有一個monitor,當monitor被佔用時,就會處於鎖定狀態,執行緒執行monitorenter指令時會獲取monitor的所有權。
  2. 當monitor計數為0時,說明該monitor還未被鎖定,此時執行緒會進入monitor並將monitor的計數器設為1,並且該執行緒就是monitor的所有者。如果此執行緒已經獲取到了monitor鎖,再重新進入monitor鎖的話,那麼會將計時器count的值加1。
  3. 如果有執行緒已經佔用了monitor鎖,此時有其他的執行緒來獲取鎖,那麼此執行緒將進入阻塞狀態,待monitor的計時器count變為0,這個執行緒才會獲取到monitor鎖。
  4. 只有拿到了monitor鎖物件的執行緒才能執行monitorexit指令。在執行 monitorexit 指令後,將鎖計數器設為 0,表明鎖被釋放,其他執行緒可以嘗試獲取鎖。
  5. 如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到鎖被另外一個執行緒釋放為止

有個奇怪的現象不知道大家有沒有發現?為什麼monitorenter指令只出現了一次,但是monitorexit指令卻出現了2次?

因為編譯器必須保證無論同步程式碼塊中的程式碼以何種方式結束,程式碼中每次呼叫monitorenter必須執行對應的monitorexit指令。如果沒有執行 monitorexit指令,monitor一直被佔用,其他執行緒都無法獲取,這是非常危險的。

這個就很像"try catch finally"中的finally,不管程式執行結果如何,必須要執行monitorexit指令,釋放monitor所有權

小結一下:

  1. 同步程式碼塊是透過monitorenter和monitorexit指令來實現;同步方式是透過方法中的access_flags中設定ACC_SYNCHRONIZED識別符號來實現,ACC_SYNCHRONIZED識別符號會去隱式呼叫這兩個指令:monitorenter和monitorexit
  2. synchronized修飾方法、修飾程式碼塊 ,歸根到底,都是透過競爭monitor所有權來實現同步的
  3. 每個java物件都會與一個monitor相關聯,可以由執行緒獲取和釋放
  4. monitor透過維護一個計數器來記錄鎖的獲取,重入,釋放情況

鎖最佳化

為什麼說JDK早期,Synchronized是重量級鎖呢?
在JVM中monitorenter和monitorexit位元組碼依賴於底層的作業系統的Mutex Lock來實現的,但是由於使用Mutex Lock需要將當前執行緒掛起並從使用者態切換到核心態來申請鎖資源,還需要經過一箇中斷的呼叫,申請完之後還需要從核心態返回到使用者態。整個切換過程是非常消耗資源的,如果程式中存在大量的鎖競爭,那麼會引起程式頻繁的在使用者態和核心態進行切換,嚴重影響到程式的效能。

在Linux系統架構中可以分為使用者空間和核心,我們的程式都執行在使用者空間,進入使用者執行狀態就是所謂的使用者態。在使用者態可能會涉及到某些操作如I/O呼叫,就會進入核心中執行,此時程式就被稱為核心執行態,簡稱核心態。

  1. 核心 : 本質上可以理解為一種軟體,控制計算機的硬體資源,並提供上層應用程式執行的環境。
  2. 使用者空間 : 上層應用程式活動的空間。應用程式的執行必須依託於核心提供的資源,包括CPU資源、儲存資源、I/O資源等。
  3. 系統呼叫 : 為了使上層應用能夠訪問到這些資源,核心必須為上層應用提供訪問的介面:即系統呼叫。

為了解決這一問題,在JDK1.6對Synchronized進行大量的最佳化鎖自旋、鎖粗化、鎖消除,鎖膨脹等技術,在這部分擴充套件內容比較多,我們接下來一一道來。

自旋鎖

在jdk1.6前多執行緒競爭鎖時,當一個執行緒A獲取鎖時,它會阻塞其他所有正在競爭的執行緒,這樣對效能帶來了極大的影響。在掛起執行緒和恢復執行緒的操作都需要轉入核心態中完成,這些操作對系統的併發效能帶來了很大的壓力。由於在實際環境中,很多執行緒的鎖定狀態只會持續很短的一段時間,會很快釋放鎖,為了如此短暫的時間去掛起和阻塞其他所有競爭鎖的執行緒,是非常浪費資源的,我們完全可以讓另一個沒有獲取到鎖的執行緒在門外等待一會(自旋),但不放棄CPU的執行時間,等待持有鎖的執行緒A釋放鎖,就裡面去獲得鎖。這其實就是自旋鎖

但是我們也無法保證執行緒獲取鎖之後,就一定很快釋放鎖。萬一遇到有執行緒,長時間不釋放鎖,其會帶來更多的效能開銷。因為線上程自旋時,始終會佔用CPU的時間片,如果鎖佔用的時間太長,那麼自旋的執行緒會消耗掉CPU資源。所以我們需要對鎖自旋的次數有所限制,如果自旋超過了限定的次數仍然沒有成功獲取到鎖,就應該重新使用傳統的方式去掛起執行緒了。在JDK定義中,自旋鎖預設的自旋次數為10次,使用者可以使用引數-XX:PreBlockSpin來更改。

後來也有改進型的自適應自旋鎖,自適應意味著自旋的次數不在固定,而是由前一次在同一個鎖上的自旋時間和鎖的擁有者的狀態共同決定。
如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也是很可能再次成功的,進而它將會允許執行緒自旋相對更長的時間。如果對於某個鎖,執行緒很少成功獲得過,則會相應減少自旋的時間甚至直接進入阻塞的狀態,避免浪費處理器資源。筆者感覺這個跟CPU的分支預測,有異曲同工之妙

鎖粗化

一般來說,同步塊的作用範圍應該儘可能小,縮短阻塞時間,如果存在鎖競爭,那麼等待鎖的執行緒也能儘快獲取鎖
但某些情況下,可能會對同一個鎖頻繁訪問,或者有人在迴圈裡面寫上了synchronized關鍵字,為了降低短時間內大量的鎖請求、釋放帶來的效能損耗,Java虛擬機器發現了之後會適當擴大加鎖的範圍,以避免頻繁的拿鎖釋放鎖的過程。將多個鎖請求合併為一個請求,這就是鎖粗化

public class LockCoarseningTest {
	public String test() {
		StringBuffer sb = new StringBuffer();
		for(int i = 0; i < 100; i++) {
			sb.append("test");
		}
		return sb.toString();
	}
}

append() 為同步方法,短時間內大量進行鎖請求、鎖釋放,JVM 會自動進行鎖粗化,將加鎖範圍擴大至 for 迴圈外部,從而只需要進行一次鎖請求、鎖釋放

鎖消除

鎖消除:透過執行時JIT編譯器的逃逸分析來消除一些沒有在當前同步塊以外被其他執行緒共享的資料的鎖保護,透過逃逸分析也可以線上程本的Stack上進行物件空間的分配(同時還可以減少Heap上的垃圾收集開銷)。其實就是即時編譯器透過對執行上下文的掃描,對不可能存在共享資源競爭的鎖進行消除,從而節約大量的資源開銷,提高效率

public class LockEliminateTest {
	static int i = 0;
 
	public void method1() {
		i++;
	}
 
	public void method2() {
		Object obj = new Object();
		synchronized (obj) {
			i++;
		}
	}
}

method2() 方法中的 obj 為區域性變數,顯然不可能被共享,對其加鎖也毫無意義,故被即時編譯器消除

鎖膨脹

鎖膨脹方向: 無鎖 → 偏向鎖 → 輕量級鎖 → 重量級鎖
偏向鎖、輕量級鎖,這兩個鎖既是一種最佳化策略,也是一種膨脹過程,接下來我們分別聊聊

偏向鎖

在大多數情況下雖然加了鎖,但是沒有鎖競爭的發生,甚至是同一個執行緒反覆獲得這個鎖,那麼多次的獲取鎖和釋放鎖會帶來很多不必要的效能開銷和上下文切換。偏向鎖就為了針對這種情況而出現的

偏向鎖指,鎖偏向於第一個獲取他的執行緒,若接下來的執行過程中,該鎖一直沒有被其他執行緒獲取,則持有偏向鎖的執行緒永遠不需要再進行同步。這樣就在無鎖競爭的情況下避免在鎖獲取過程中執行不必要的獲取鎖和釋放鎖操作

偏向鎖的具體過程:

  1. 首先JVM要設定為可用偏向鎖。然後當一個程式訪問同步塊並且獲得鎖的時候,會在物件頭和棧幀的鎖記錄裡面儲存取得偏向鎖的執行緒ID。
  2. 等下一次有執行緒嘗試獲取鎖的時候,首先檢查這個物件頭的MarkWord是不是儲存著這個執行緒的ID。如果是,那麼直接進去而不需要任何別的操作。
  3. 如果不是,那麼分為兩種情況:
  • 物件的偏向鎖標誌位為0(當前不是偏向鎖),說明發生了競爭,已經膨脹為輕量級鎖,這時使用CAS操作嘗試獲得鎖。
  • 偏向鎖標誌位為1,說明還是偏向鎖不過請求的執行緒不是原來那個了。這時只需要使用CAS嘗試把物件頭偏向鎖從原來那個執行緒指向目前求鎖的執行緒。

輕量級鎖

在實際情況中,大部分的鎖,在整個同步生命週期內都不存在競爭,在無鎖競爭的情況下完全可以避免呼叫作業系統層面的重量級互斥鎖,可以透過CAS原子指令就可以完成鎖的獲取及釋放。當存在鎖競爭的情況下,執行CAS指令失敗的執行緒將呼叫作業系統互斥鎖進入到阻塞狀態,當鎖被釋放的時候被喚醒。
當升級為輕量級鎖之後,MarkWord的結構也會隨之變為輕量級鎖結構。JVM會利用CAS嘗試把物件原本的MarkWord 更新為Lock Record的指標,成功就說明加鎖成功,改變鎖標誌位為00,然後執行相關同步操作。
輕量級鎖所適應的場景是執行緒交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖就會失效,進而膨脹為重量級鎖。

CAS(Compare-And-Swap):顧名思義比較並替換。這是一個由CPU硬體提供並實現的原子操作.可以被認為是一種樂觀鎖,會以一種更加樂觀的態度對待事情,認為自己可以操作成功。當多個執行緒操作同一個共享資源時,僅能有一個執行緒同一時間獲得鎖成功,在樂觀鎖中,其他執行緒發現自己無法成功獲得鎖,並不會像悲觀鎖那樣阻塞執行緒,而是直接返回,可以去選擇再次重試獲得鎖,也可以直接退出

CAS機制所保證的只是一個變數的原子性操作,無法保證整個程式碼塊的原子性

最後再小結一下,鎖的優缺點對比:

最高效的是偏向鎖,儘量使用偏向鎖,如果不能(發生了競爭)就膨脹為輕量級鎖,當發生鎖競爭時,輕量級鎖的CAS操作會自動失效,鎖再次膨脹為重量級鎖。鎖一般是隻能升級但不能降級,這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。(hotspot其實是可以發生鎖降級的,但觸發鎖降級的條件比較苛刻

偏向鎖,輕量級鎖,只需在使用者態就可以實現,而不需要進行使用者態和核心態之間的切換

經過如此多的鎖最佳化,如今的 synchronized 鎖效率非常不錯,目前不論是各種開源框架還是 JDK 原始碼都大量使用了 synchronized 關鍵字。

synchronized關鍵字實現單例模式

我們來看一個經典的例子,利用synchronized關鍵字實現單例模式

/**
 * 懶漢 - 雙層校驗鎖
 */
public class SingleDoubleCheck {
    private static SingleDoubleCheck instance = null;

    private SingleDoubleCheck(){}//將構造器 私有化,防止外部呼叫

    public static SingleDoubleCheck getInstance() {
        if (instance == null) { //part 1
            synchronized (SingleDoubleCheck.class) {
                if (instance == null) { //part 2
                    instance = new SingleDoubleCheck();//part 3
                }
            }
        }
        return instance;
    }
}

對單例模式感興趣的話,見擴充:https://mp.weixin.qq.com/s/TyiCfVMeeDwa-2hd9N9XJQ

synchronized 和 volatile 的區別?

synchronized 關鍵字和 volatile 關鍵字是兩個互補的存在,而不是對立的存在

  1. volatile 關鍵字是執行緒同步的輕量級實現,所以 volatile效能肯定比synchronized關鍵字要好 。但是 volatile 關鍵字只能用於變數而 synchronized 關鍵字可以修飾方法以及程式碼塊 。
  2. volatile 關鍵字能保證資料的可見性,但不能保證資料的原子性。synchronized 關鍵字兩者都能保證。
  3. volatile關鍵字主要用於解決變數在多個執行緒之間的可見性,而 synchronized 關鍵字解決的是多個執行緒之間訪問資源的同步性。
  4. volatile只能修飾例項變數和類變數,而synchronized可以修飾方法,以及程式碼塊。

尾語

本文擴充內容確實有點多,很開心你能看到最後,我們再簡明地回顧一下synchronized 的特性

  1. 原子性:確保執行緒互斥的訪問同步程式碼。synchronized保證只有一個執行緒拿到鎖,進入同步程式碼塊操作共享資源,因此具有原子性。

  2. 可見性:保證共享變數的修改能夠及時可見。當某執行緒進入synchronized程式碼塊前後,執行緒會獲得鎖,清空工作記憶體,從主記憶體複製共享變數最新的值到工作記憶體成為副本,執行程式碼,將修改後的副本的值重新整理回主記憶體中,執行緒釋放鎖。其他獲取不到鎖的執行緒會阻塞等待,所以變數的值一直都是最新的。

  3. 有序性:synchronized內的程式碼和外部的程式碼禁止排序,至於內部的程式碼,則不會禁止排序,但是由於只有一個執行緒進入同步程式碼塊,因此在同步程式碼塊中相當於是單執行緒的,根據 as-if-serial 語義,即使程式碼塊內發生了重排序,也不會影響程式執行的結果。

  4. 悲觀鎖:synchronized是悲觀鎖。每次使用共享資源時都認為會和其他執行緒產生競爭,所以每次使用共享資源都會上鎖。

  5. 獨佔鎖(排他鎖):synchronized是獨佔鎖(排他鎖)。該鎖一次只能被一個執行緒所持有,其他執行緒被阻塞。

  6. 非公平鎖:synchronized是非公平鎖。執行緒獲取鎖的順序可以不按照執行緒的阻塞順序。允許新來的執行緒有可能立即獲得監視器,而在等待區中等候已久的執行緒可能再次等待。這樣有利於提高效能,但是也可能會導致飢餓現象

  7. 可重入鎖:synchronized是可重入鎖。持鎖執行緒可以再次獲取自己的內部的鎖,可一定程度避免死鎖。

參考資料:

https://openjdk.org/groups/hotspot/docs/HotSpotGlossary.html
《深入理解java虛擬機器》
《Java併發程式設計的藝術》
https://www.cnblogs.com/qingshan-tang/p/12698705.html
https://www.cnblogs.com/jajian/p/13681781.html


本篇文章到這裡就結束啦,很感謝你能看到最後,如果覺得文章對你有幫助,別忘記關注我!

相關文章