synchronized底層是怎麼實現的?

紀莫發表於2020-09-14

前言

面試的時候有被問到,synchronized底層是怎麼實現的,回答的比較淺,面試官也不是太滿意,所以覺得要好好總結一下,啃啃這個硬骨頭。

synchronized使用場景

我們在使用synchronized的時候都知道它是可以使用在方法上的也可以使用在程式碼塊上的,那麼使用在這兩個地方有什麼區別呢?

synchronized用在方法上

使用在靜態方法上,synchronized鎖住的是類物件。

public class SynchronizedTest {

    /**
     * synchronized 使用在靜態方法上
     */
    public static synchronized void test1(){
        System.out.println("I am test1 method");
    }
}

使用在例項方法上,synchronized鎖住的是例項物件。

public class SynchronizedTest {
   
    /**
     * synchronized 使用在例項方法上
     * @return
     */
    public synchronized String syncOnMethod(){
        return "a developer name Jimoer";
    }
}

synchronized用在程式碼塊上

synchronized的同步程式碼塊用在類例項的物件上,鎖住的是當前的類的例項。
即執行buildName的時候,整個物件都會被鎖住,直到執行完成buildName後釋放鎖。

public class SynchronizedTest {
    
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    /**
     * 帶姓氏的名稱
     * @param firstName 姓氏
     */
    public void buildName(String firstName){
        synchronized(this){
            this.setName(firstName+this.getName());
        }
    }
}

synchronized的同步程式碼塊用在類物件上,鎖住的是該類的類物件。

public class SynchronizedTest {
    private static String myName = "Jimoer";
    /**
     * 帶姓氏的名稱
     * @param firstName 姓氏
     */
    public static void buildName(String firstName){
        synchronized(SynchronizedTest.class){
            System.out.println(firstName+myName);
        }
    }
}

synchronized的同步程式碼塊用在任意例項物件上,鎖住的就是配置的例項物件。

public class SynchronizedTest {
    private String lastName;

    public String getLastName() {
        return lastName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    /**
     * 帶姓氏的名稱
     * @param firstName 姓氏
     */
    public void buildName(String firstName){
        synchronized(lastName){
            System.out.println(firstName+lastName);
        }
    }
}

synchronized的使用就介紹到這裡,正常情況下會用了就可以了,能在實際場景中使用的時候知道鎖住的範圍就可以了,但是面試的時候可是要問原理的,而且在程式出現問題的時候,知道原理也是能快速定位問題的基礎。

synchronized的原理

我們來看一下synchronized底層是怎麼實現的吧。

例如:
下面一段程式碼,包含一個synchronized程式碼塊和一個synchronized的同步方法。

public class SynchronizedTest {
    private static String myName = "Jimoer";
    public static void main(String[] args) {
        synchronized (myName){
            System.out.println(myName);
        }
    }
    /**
     * synchronized 使用在靜態方法上
     */
    public static synchronized void test1(){
        System.out.println("I am test1 method");
    }
}

在編譯完成後生成了class檔案,我將class檔案反編譯出來,看看生成的class檔案的內容。

javap -p -v -c SynchronizedTest.class 

反編譯出來的位元組碼檔案內容有點多,我只擷取了關鍵部分來分析。

在這裡插入圖片描述
注意上面我用紅框標出來的地方,synchronized關鍵字在經過Javac編譯之後,會在同步塊的前後形成monitorentermonitorexit兩個位元組碼指令。
根據《Java虛擬機器規範》的要求

  • 在執行monitorenter指令的時候,首先要去嘗試獲取物件的鎖(獲取物件鎖的過程,其實是獲取monitor物件的所有權的過程)。
  • 如果這個物件沒被鎖定,或者當前執行緒已經持有了那個物件的鎖,就把鎖的計數器的值增加一。
  • 而在執行monitorexit指令時會將鎖計數器減一。一旦計數器的值為零,鎖隨即就被釋放了。
  • 如果獲取物件鎖失敗,那當前執行緒就應當被阻塞等待,直到請求鎖定的物件被持有它的執行緒釋放為止。

同步方法

同步方法test1的反編譯後的位元組碼檔案部分如下:
在這裡插入圖片描述
注意我用紅框圈起來的部分,這個ACC_SYNCHRONIZED標誌。代表的是當執行緒執行到方法後會檢查是否有這個標誌,如果有的話就會隱式的去呼叫monitorentermonitorexit兩個命令來將方法鎖住。

monitor物件

我在上面說了,獲取物件鎖的過程,其實是獲取monitor物件的所有權的過程。哪個執行緒持有了monitor物件,那麼哪個執行緒就獲得了鎖,獲得了鎖的物件可以重複的來獲取monitor物件,但是同一個執行緒每獲取一次monitor物件所有權鎖計數就加一,在解鎖的時候也是需要將鎖計數減成0才算真的釋放了鎖。
monitor物件,我們其實在Java的反編譯檔案中並沒有看到。這個物件是存放在物件頭中的。

物件頭

這裡要介紹一下物件頭,首先要說一下物件的記憶體佈局,在HotSpot虛擬機器裡,物件在堆記憶體中的儲存佈局可以劃分為三個部分:物件頭(Header)例項資料(Instance Data)對齊填充(Padding)

  • 例項資料裡面儲存的是物件的真正有效資料,裡面包含各種型別的欄位內容,無論是自身的還是從父類繼承來的。
  • 對齊填充這部分並不是必然存在的,只是為了佔位。虛擬機器自動管理記憶體系統要求物件的大小必須是8位元組的整數倍,當整個物件的大小不是8位元組的整數倍時,用來對齊填充補全。
  • 物件頭部分包含兩類資訊。
    1、第一類是自身執行時資料,如何雜湊碼(hashcode)、GC分代年齡、鎖狀態標誌執行緒持有的鎖偏向執行緒ID等,這部分資料官方稱它為“Mark Word”。
    2、第二類是型別指標,即物件指向它的型別後設資料的指標,虛擬機器通過它來確定物件是哪個型別的例項。

接著回到我們的monitor物件,monitor物件的原始碼是C++寫的,在虛擬機器的ObjectMonitor.hpp檔案中。
資料結構長這個樣子。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;  // 執行緒重入次數
    _object       = NULL;  // 儲存Monitor物件
    _owner        = NULL;  // 持有當前執行緒的owner
    _WaitSet      = NULL;  // wait狀態的執行緒列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 單向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 處於等待鎖狀態block狀態的執行緒列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

有想對這個monitor物件更深入瞭解的可以去Java虛擬機器的原始碼裡看看。

重量級鎖

在主流的Java虛擬機器實現中,Java的執行緒是對映到作業系統的原生核心執行緒之上的,如果要阻塞或喚醒一條執行緒,則需要作業系統來幫忙完成,這就不可避免地陷入使用者態到核心態的轉換中,這種狀態的轉換要耗費很多的處理時間。
所以在ObjectMonitor檔案中的呼叫過程和複雜的作業系統執行機制導致執行緒的阻塞或喚醒時是很耗費資源的。
這樣在JDK1.6之前都稱synchronized為重量級鎖。

重量級鎖的減重

高效併發是從JDK5升級到JDK6的一項重要的改進項,在JDK6版本上虛擬機器開發團隊花費了大量的資源去實現各種鎖優化技術,來為重量級鎖減重。
synchronized在升級後的整個加鎖過程,大致如下圖。
在這裡插入圖片描述
這裡要說明一下,鎖升級的過程是不可逆的。

偏向鎖

上面在介紹物件頭的時候,說到了物件頭中包含的內容了,其中有一個就是偏向鎖的執行緒ID,它代表的意思就是說,如果當一個執行緒獲取到了鎖之後,鎖的標誌計數器就會+1,並且把這個執行緒的id儲存在鎖住的這個物件的物件頭上面。
這個過程是通過CAS來實現的,每次執行緒進入都是無鎖的,當執行CAS成功後,直接將鎖的標誌計數+1(持有偏向鎖的執行緒以後每次進入鎖時不做任何操作,標誌計數直接+1),這個時候其他執行緒再進來時,執行CAS就會失敗,也就是獲取鎖失敗。
在這裡插入圖片描述

偏向鎖在JDK1.6是預設開啟的,通過引數進行關閉xx:-UseBiasedLocking=false

偏向鎖可以提高帶有同步但無競爭的程式效能,但如果大多數的鎖都總是被多個不同的執行緒訪問,那偏向鎖就是多餘的。

輕量級鎖

輕量級鎖還是和物件頭的第一部分(Mark Word)相關。

  • 在程式碼即將進入同步塊的時候,如果此同步物件沒有被鎖定,虛擬機器首先將當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,使用者儲存鎖物件目前的Mark Word的拷貝。
  • 然後JVM將使用CAS操作嘗試把物件的Mark Word更新為指向Lock Record的指標。如果這個更新動作成功了,說明執行緒獲取鎖成功,並執行後面的同步操作。
  • 如果這個更新動作失敗了,說明鎖物件已經被其他執行緒搶佔了,那輕量級鎖不在有效,必須膨脹為重量級鎖。此時被鎖住的物件的標誌變為重量級鎖的標誌。

在這裡插入圖片描述

自旋鎖

當輕量級鎖獲取失敗後,就會升級為重量級鎖,但是重量級鎖之前也介紹了是很耗資源的,JVM開發團隊注意到許多程式上,共享資料的二鎖定狀態只會持續很短一段時間,為了這段時間去掛起和恢復執行緒並不值得。
所以想到了一個策略,那就是當執行緒請求一個已經被鎖住的物件時,可以讓未獲取鎖的執行緒“稍等一會”,但不放棄處理器執行時間,只需要讓執行緒執行一個忙迴圈(自旋),這就是所謂的自旋鎖。
自旋鎖在JDK1.4.2中引入,預設關閉,可以通過-XX:UserSpinning引數來開啟,預設自旋次數是10次,使用者可以自定義次數,配置引數是-XX:PreBockSpin。

無論是使用者指定還是預設值的自旋次數,對JVM重所有的鎖來說都是相同的。在JDK6中引入了自適應自旋,根據前一次在同一鎖上的自旋時間及擁有者的狀態來決定。如果上一次同一個物件自旋鎖獲得成功了,那麼再次進行自旋時就會認為成功機率很大,那麼自旋次數就會自動增加。反之如果自旋很少成功獲得鎖,那麼以後這個自旋過程都有可能被省略掉。

這樣在輕量級失敗後,就會升級為自旋鎖,如果自旋鎖也失敗了,那就只能是升級到重量級鎖了。
在這裡插入圖片描述
參考資料:《深入理解Java虛擬機器》、死磕synchronized底層實現

相關文章