synchronized底層是怎麼實現的?

前言

面試的時候有被問到,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底層實現

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章