深入分析synchronized原理和鎖膨脹過程(二)

薛8發表於2019-03-22
image

前言

上一篇文章介紹了多執行緒的概念及synchronized的使用方法《synchronized的使用(一)》,但是僅僅會用還是不夠的,只有瞭解其底層實現才能在開發過程中運籌帷幄,所以本篇探討synchronized的實現原理及鎖升級(膨脹)的過程。

synchronized實現原理

synchronized是依賴於JVM來實現同步的,在同步方法和程式碼塊的原理有點區別。

同步程式碼塊

我們在程式碼塊加上synchronized關鍵字

public void synSay() {
    synchronized (object) {
        System.out.println("synSay----" + Thread.currentThread().getName());
    }
}
複製程式碼

編譯之後,我們利用反編譯命令javap -v xxx.class檢視對應的位元組碼,這裡為了減少篇幅,我就只貼上對應的方法的位元組碼。

  public void synSay();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: getfield      #2                  // Field object:Ljava/lang/String;
         4: dup
         5: astore_1
         6: monitorenter
         7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: new           #4                  // class java/lang/StringBuilder
        13: dup
        14: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        17: ldc           #6                  // String synSay----
        19: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        22: invokestatic  #8                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        25: invokevirtual #9                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        28: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        31: invokevirtual #10                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        34: invokevirtual #11                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        37: aload_1
        38: monitorexit
        39: goto          47
        42: astore_2
        43: aload_1
        44: monitorexit
        45: aload_2
        46: athrow
        47: return
      Exception table:
         from    to  target type
             7    39    42   any
            42    45    42   any
      LineNumberTable:
        line 21: 0
        line 22: 7
        line 23: 37
        line 24: 47
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      48     0  this   Lcn/T1;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 42
          locals = [ class cn/T1, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
複製程式碼

可以發現synchronized同步程式碼塊是通過加monitorentermonitorexit指令實現的。
每個物件都有個**監視器鎖(monitor) **,當monitor被佔用的時候就代表物件處於鎖定狀態,而monitorenter指令的作用就是獲取monitor的所有權,monitorexit的作用是釋放monitor的所有權,這兩者的工作流程如下:
monitorenter

  1. 如果monitor的進入數為0,則執行緒進入到monitor,然後將進入數設定為1,該執行緒稱為monitor的所有者。
  2. 如果是執行緒已經擁有此monitor(即monitor進入數不為0),然後該執行緒又重新進入monitor,則將monitor的進入數+1,這個即為鎖的重入
  3. 如果其他執行緒已經佔用了monitor,則該執行緒進入到阻塞狀態,知道monitor的進入數為0,該執行緒再去重新嘗試獲取monitor的所有權

monitorexit:執行該指令的執行緒必須是monitor的所有者,指令執行時,monitor進入數-1,如果-1後進入數為0,那麼執行緒退出monitor,不再是這個monitor的所有者。這個時候其它阻塞的執行緒可以嘗試獲取monitor的所有權。

同步方法

在方法上加上synchronized關鍵字

synchronized public void synSay() {
    System.out.println("synSay----" + Thread.currentThread().getName());
}
複製程式碼

編譯之後,我們利用反編譯命令javap -v xxx.class檢視對應的位元組碼,這裡為了減少篇幅,我就只貼上對應的方法的位元組碼。

  public synchronized void synSay();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: new           #3                  // class java/lang/StringBuilder
         6: dup
         7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
        10: ldc           #5                  // String synSay----
        12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        15: invokestatic  #7                  // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        18: invokevirtual #8                  // Method java/lang/Thread.getName:()Ljava/lang/String;
        21: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        30: return
      LineNumberTable:
        line 20: 0
        line 21: 30
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      31     0  this   Lcn/T1;
複製程式碼

從位元組碼上看,加有synchronized關鍵字的方法,常量池中比普通的方法多了個ACC_SYNCHRONIZED標識,JVM就是根據這個標識來實現方法的同步。
當呼叫方法的時候,呼叫指令會檢查方法是否有ACC_SYNCHRONIZED標識,有的話執行緒需要先獲取monitor,獲取成功才能繼續執行方法,方法執行完畢之後,執行緒再釋放monitor,同一個monitor同一時刻只能被一個執行緒擁有。

兩種同步方式區別

synchronized同步程式碼塊的時候通過加入位元組碼monitorentermonitorexit指令來實現monitor的獲取和釋放,也就是需要JVM通過位元組碼顯式的去獲取和釋放monitor實現同步,而synchronized同步方法的時候,沒有使用這兩個指令,而是檢查方法的ACC_SYNCHRONIZED標誌是否被設定,如果設定了則執行緒需要先去獲取monitor,執行完畢了執行緒再釋放monitor,也就是不需要JVM去顯式的實現。
這兩個同步方式實際都是通過獲取monitor和釋放monitor來實現同步的,而monitor的實現依賴於底層作業系統的mutex互斥原語,而作業系統實現執行緒之間的切換的時候需要從使用者態轉到核心態,這個轉成過程開銷比較大。
執行緒獲取、釋放monitor的過程如下:

深入分析synchronized原理和鎖膨脹過程(二)

執行緒嘗試獲取monitor的所有權,如果獲取失敗說明monitor被其他執行緒佔用,則將執行緒加入到的同步佇列中,等待其他執行緒釋放monitor當其他執行緒釋放monitor後,有可能剛好有執行緒來獲取monitor的所有權,那麼系統會將monitor的所有權給這個執行緒,而不會去喚醒同步佇列的第一個節點去獲取,所以synchronized是非公平鎖。如果執行緒獲取monitor成功則進入到monitor中,並且將其進入數+1

關於什麼是公平鎖、非公平鎖可以參考一下美團技術團隊寫的《不可不說的Java“鎖”事》

到這裡我們也清楚了synchronized的語義底層是通過一個monitor的物件完成,其實waitnotiyfnotifyAll等方法也是依賴於monitor物件來完成的,這也就是為什麼需要在同步方法或者同步程式碼塊中呼叫的原因(需要先獲取物件的鎖,才能執行),否則會丟擲java.lang.IllegalMonitorStateException的異常

Java物件的組成

我們知道了執行緒要訪問同步方法、程式碼塊的時候,首先需要取得鎖,在退出或者丟擲異常的時候又必須釋放鎖,那麼鎖到底是什麼?又儲存在哪裡?
為了解開這個疑問,我們需要進入Java虛擬機器(JVM) 的世界。在HotSpot虛擬機器中,Java物件在記憶體中儲存的佈局可以分為3塊區域:物件頭例項資料對齊填充synchronized使用的鎖物件儲存在物件頭中

深入分析synchronized原理和鎖膨脹過程(二)

深入分析synchronized原理和鎖膨脹過程(二)

物件頭

物件頭的資料長度在32位和64位(未開啟壓縮指標)的虛擬機器中分別為32bit64bit。物件頭由以下三個部分組成:

  • Mark Word:記錄了物件和鎖的有關資訊,儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖標誌位、執行緒持有的鎖、偏向執行緒ID、偏向時間戳、物件分代年齡等。注意這個Mark Word結構並不是固定的,它會隨著鎖狀態標誌的變化而變化,而且裡面的資料也會隨著鎖狀態標誌的變化而變化,這樣做的目的是為了節省空間
  • 型別指標:指向物件的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
  • 陣列長度:這個屬性只有陣列物件才有,儲存著陣列物件的長度。

32位虛擬機器下,Mark Word的結構和資料可能為以下5種中的一種。

深入分析synchronized原理和鎖膨脹過程(二)

64位虛擬機器下,Mark Word的結構和資料可能為以下2種中的一種。

深入分析synchronized原理和鎖膨脹過程(二)

這裡重點注意是否偏向鎖鎖標誌位,這兩個標識和synchronized的鎖膨脹息息相關。

例項資料

儲存著物件的實際資料,也就是我們在程式中定義的各種型別的欄位內容。

對齊填充

HotSpot虛擬機器的對齊方式為8位元組對齊,即一個物件必須為8位元組的整數倍,如果不是,則通過這個對齊填充來佔位填充。

synchronized鎖膨脹過程

上文介紹的 "synchronized實現原理" 實際是synchronized實現重量級鎖的原理,那麼上文頻繁提到monitor物件和物件又存在什麼關係呢,或者說monitor物件儲存在物件的哪個地方呢?
在物件的物件頭中,當鎖的狀態為重量級鎖的時候,它的指標即指向monitor物件,如圖:

深入分析synchronized原理和鎖膨脹過程(二)
深入分析synchronized原理和鎖膨脹過程(二)
那鎖的狀態為其它狀態的時候是不是就沒用上monitor物件?答案:是的。
這也是JVMsynchronized的優化,我們知道重量級鎖的實現是基於底層作業系統的mutex互斥原語的,這個開銷是很大的。所以JVMsynchronized做了優化,JVM先利用物件頭實現鎖的功能,如果執行緒的競爭過大則會將鎖升級(膨脹)為重量級鎖,也就是使用monitor物件。當然JVM對鎖的優化不僅僅只有這個,還有引入適應性自旋、鎖消除、鎖粗化、輕量級鎖、偏向鎖等。

那麼鎖的是怎麼進行膨脹的或者依據什麼來膨脹,這也就是本篇需要介紹的重點,首先我們需要了解幾個概念。

鎖的優化

自旋鎖和自適應性自旋鎖

自旋:當有個執行緒A去請求某個鎖的時候,這個鎖正在被其它執行緒佔用,但是執行緒A並不會馬上進入阻塞狀態,而是迴圈請求鎖(自旋)。這樣做的目的是因為很多時候持有鎖的執行緒會很快釋放鎖的,執行緒A可以嘗試一直請求鎖,沒必要被掛起放棄CPU時間片,因為執行緒被掛起然後到喚醒這個過程開銷很大,當然如果執行緒A自旋指定的時間還沒有獲得鎖,仍然會被掛起。

自適應性自旋:自適應性自旋是自旋的升級、優化,自旋的時間不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態決定。例如執行緒如果自旋成功了,那麼下次自旋的次數會增多,因為JVM認為既然上次成功了,那麼這次自旋也很有可能成功,那麼它會允許自旋的次數更多。反之,如果對於某個鎖,自旋很少成功,那麼在以後獲取這個鎖的時候,自旋的次數會變少甚至忽略,避免浪費處理器資源。有了自適應性自旋,隨著程式執行和效能監控資訊的不斷完善,JVM對程式鎖的狀況預測就會變得越來越準確,JVM也就變得越來越聰明。

鎖消除

鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除

鎖粗化

在使用鎖的時候,需要讓同步塊的作用範圍儘可能小,這樣做的目的是為了使需要同步的運算元量儘可能小,如果存在鎖競爭,那麼等待鎖的執行緒也能儘快拿到鎖

輕量級鎖

所謂輕量級鎖是相對於使用底層作業系統mutex互斥原語實現同步的重量級鎖而言的,因為輕量級鎖同步的實現是基於物件頭的Mark Word。那麼輕量級鎖是怎麼使用物件頭來實現同步的呢,我們看看具體實現過程。

獲取鎖過程

  1. 線上程進入同步方法、同步塊的時候,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為"01"狀態,是否為偏向鎖為"0"),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Recored)的空間,用於儲存鎖物件目前的Mark Word的拷貝(官方把這份拷貝加了個Displaced字首,即Displaced Mark Word)。

深入分析synchronized原理和鎖膨脹過程(二)

  1. 將物件頭的Mark Word拷貝到執行緒的鎖記錄(Lock Recored)中。
  2. 拷貝成功後,虛擬機器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標。如果這個更新成功了,則執行步驟4,否則執行步驟5
  3. 更新成功,這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位將轉變為"00",即表示此物件處於輕量級鎖的狀態。

深入分析synchronized原理和鎖膨脹過程(二)

  1. 更新失敗,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,可以直接進入同步塊繼續執行,否則說明這個鎖物件已經被其其它執行緒搶佔了。進行自旋執行步驟3,如果自旋結束仍然沒有獲得鎖,輕量級鎖就需要膨脹為重量級鎖,鎖標誌位狀態值變為"10",Mark Word中儲存就是指向monitor物件的指標,當前執行緒以及後面等待鎖的執行緒也要進入阻塞狀態。

深入分析synchronized原理和鎖膨脹過程(二)

釋放鎖的過程

  1. 使用CAS操作將物件當前的Mark Word和執行緒中複製的Displaced Mark Word替換回來(依據Mark Word中鎖記錄指標是否還指向本執行緒的鎖記錄),如果替換成功,則執行步驟2,否則執行步驟3
  2. 如果替換成功,整個同步過程就完成了,恢復到無鎖的狀態(01)。
  3. 如果替換失敗,說明有其他執行緒嘗試獲取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被掛起的執行緒。

偏向鎖

偏向鎖的目的是消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS操作區消除同步使用的互斥量,那麼偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不用做了。偏向鎖預設是開啟的,也可以關閉
偏向鎖"偏",就是"偏心"的"偏",它的意思是這個鎖會偏向於第一個獲得它的程式,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。

獲取鎖的過程

  1. 檢查Mark Word是否為可偏向鎖的狀態,即是否偏向鎖即為1即表示支援可偏向鎖,否則為0表示不支援可偏向鎖。
  2. 如果是可偏向鎖,則檢查Mark Word儲存的執行緒ID是否為當前執行緒ID,如果是則執行同步塊,否則執行步驟3
  3. 如果檢查到Mark WordID不是本執行緒的ID,則通過CAS操作去修改執行緒ID修改成本執行緒的ID,如果修改成功則執行同步程式碼塊,否則執行步驟4
  4. 當擁有該鎖的執行緒到達安全點之後,掛起這個執行緒,升級為輕量級鎖。

鎖釋放的過程

  1. 有其他執行緒來獲取這個鎖,偏向鎖的釋放採用了一種只有競爭才會釋放鎖的機制,執行緒是不會主動去釋放偏向鎖,需要等待其他執行緒來競爭。
  2. 等待全域性安全點(在這個是時間點上沒有位元組碼正在執行)。
  3. 暫停擁有偏向鎖的執行緒,檢查持有偏向鎖的執行緒是否活著,如果不處於活動狀態,則將物件頭設定為無鎖狀態,否則設定為被鎖定狀態。如果鎖物件處於無鎖狀態,則恢復到無鎖狀態(01),以允許其他執行緒競爭,如果鎖物件處於鎖定狀態,則掛起持有偏向鎖的執行緒,並將物件頭Mark Word的鎖記錄指標改成當前執行緒的鎖記錄,鎖升級為輕量級鎖狀態(00)

深入分析synchronized原理和鎖膨脹過程(二)

鎖的轉換過程

鎖主要存在4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭的情況逐漸升級,這幾個鎖只有重量級鎖是需要使用作業系統底層mutex互斥原語來實現,其他的鎖都是使用物件頭來實現的。需要注意鎖可以升級,但是不可以降級。

深入分析synchronized原理和鎖膨脹過程(二)
這裡盜個圖,這個圖總結的挺好的!(圖被壓縮過了 看不清,可以開啟這個地址檢視高清圖>>高清圖<<)

深入分析synchronized原理和鎖膨脹過程(二)

三種鎖的優缺點比較

image

參考

深入理解Java虛擬機器 Java的物件頭和物件組成詳解
JVM(三)JVM中物件的記憶體佈局詳解
JVM——深入分析物件的記憶體佈局
啃碎併發(七):深入分析Synchronized原理
Java Synchronized實現原理

原文地址:ddnd.cn/2019/03/22/…

相關文章