美團一面:說說synchronized的實現原理?問麻了。。。。

码农Academy發表於2024-04-08

引言

在現代軟體開發領域,多執行緒併發程式設計已經成為提高系統效能、提升使用者體驗的重要手段。然而,多執行緒環境下的資料同步與資源共享問題也隨之而來,處理不當可能導致資料不一致、死鎖等各種併發問題。為此,Java語言提供了一種內建的同步機制——synchronized關鍵字,它能夠有效地解決併發控制的問題,確保共享資源在同一時間只能由一個執行緒訪問,從而維護程式的正確性與一致性。

synchronized作為Java併發程式設計的基礎構建塊,其簡潔易用的語法形式背後蘊含著複雜的底層實現原理和技術細節。深入理解synchronized的執行機制,不僅有助於我們更好地利用這一特性編寫出高效且安全的併發程式,同時也有利於我們在面對複雜併發場景時,做出更為明智的設計決策和最佳化策略。

本文將從synchronized的基本概念出發,逐步剖析其內在的工作機制,探討諸如監視器(Monitor)等關鍵技術點,並結合實際應用場景來展示synchronized的實際效果和最佳實踐。透過對synchronized底層實現原理的深度解讀,旨在為大家揭示Java併發世界的一隅,提升對併發程式設計的認知高度和實戰能力。

synchronized是什麼?

synchronized是Java中實現執行緒同步的關鍵字,主要用於保護共享資源的訪問,確保在多執行緒環境中同一時間只有一個執行緒能夠訪問特定的程式碼段或方法。它提供了互斥性和可見性兩個重要特性,確保了執行緒間操作的原子性和資料的一致性。

synchronized的特性

synchronized關鍵字具有三個基本特性,分別是互斥性、可見性和有序性。

互斥性

synchronized關鍵字確保了在其控制範圍內的程式碼在同一時間只能被一個執行緒執行,實現了資源的互斥訪問。當一個執行緒進入了synchronized程式碼塊或方法時,其他試圖進入該同步區域的執行緒必須等待,直至擁有鎖的執行緒執行完畢並釋放鎖。

可見性

synchronized還確保了執行緒間的資料可見性。一旦一個執行緒在synchronized塊中修改了共享變數的值,其他隨後進入同步區域的執行緒可以看到這個更改。這是因為synchronized的解鎖過程包含了將工作記憶體中的最新值重新整理回主記憶體的操作,而加鎖過程則會強制從主記憶體中重新載入變數的值。

有序性

synchronized提供的第三個特性是有序性,它可以確保在多執行緒環境下,對於同一個鎖的解鎖操作總是先行於隨後對同一個鎖的加鎖操作。這就意味著,透過synchronized建立起了執行緒之間的記憶體操作順序關係,有效地解決了由於編譯器和處理器最佳化可能帶來的指令重排序問題。

synchronized可以實現哪鎖?

有上述synchronized的特性,我們可以知道synchronized可以實現這些鎖:

  1. 可重入鎖(Reentrant Lock)synchronized 實現的鎖是可重入的,這意味著同一個執行緒可以多次獲取同一個鎖,而不會被阻塞。這種鎖機制允許執行緒在持有鎖的情況下再次獲取相同的鎖,避免了死鎖的發生。
  2. 排它鎖/互斥鎖/獨佔鎖synchronized 實現的鎖是互斥的,也就是說,在同一時間只有一個執行緒能夠獲取到鎖,其他執行緒必須等待該執行緒釋放鎖才能繼續執行。這確保了同一時刻只有一個執行緒可以訪問被鎖定的程式碼塊或方法,從而保證了資料的一致性和完整性。
  3. 悲觀鎖synchronized 實現的鎖屬於悲觀鎖,因為它預設情況下假設會發生競爭,並且會導致其他執行緒阻塞,直到持有鎖的執行緒釋放鎖。悲觀鎖的特點是對併發訪問持保守態度,認為會有其他執行緒來競爭共享資源,因此在訪問共享資源之前會先獲取鎖。
  4. 非公平鎖: synchronized在早期的Java版本中,預設實現的是非公平鎖,也就是說,執行緒獲取鎖的順序並不一定按照它們請求鎖的順序來進行,而是允許“插隊”,即已經在等待佇列中的執行緒可能被後來請求鎖的執行緒搶佔。

有關Java中的鎖的分類,請參考:阿里二面:Java中鎖的分類有哪些?你能說全嗎?

synchronized使用方式

synchronized關鍵字可以修飾方法、程式碼塊或靜態方法,用於確保同一時間只有一個執行緒可以訪問被synchronized修飾的程式碼片段。

修飾例項方法

synchronized修飾例項方法時,鎖住的是當前例項物件(this)。這意味著在同一時刻,只能有一個執行緒訪問此方法,所有對該物件例項的其他同步方法呼叫將會被阻塞,直到該執行緒釋放鎖。

public class SynchronizedInstanceMethod implements Runnable{

    private static int counter = 0;

    // 修飾例項方法,鎖住的是當前例項物件
    private synchronized void add() {
        counter++;
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            add();
        }
    }

    public static void main(String[] args) throws Exception {
        SynchronizedInstanceMethod sim = new SynchronizedInstanceMethod();
        Thread t1 = new Thread(sim);
        Thread t2 = new Thread(sim);
        
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Final counter value: " + counter);
    }
}

像上述這個例子,大家在接觸多執行緒時一定會看過或者寫過類似的程式碼,i++在多執行緒的情況下是執行緒不安全的,所以我們使用synchronized作用在累加的方法上,使其變成執行緒安全的。上述列印結果為:

Final block counter value: 2000

而對於synchronized作用於例項方法上時,鎖的是當前例項物件,但是如果我們鎖住的是不同的示例物件,那麼synchronized就不能保證執行緒安全了。如下程式碼:

public class SynchronizedInstanceMethod implements Runnable{

    private static int counter = 0;

    // 修飾例項方法,鎖住的是當前例項物件
    private synchronized void add() {
        counter++;
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            add();
        }
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(new SynchronizedInstanceMethod());
        Thread t2 = new Thread(new SynchronizedInstanceMethod());

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Final counter value: " + counter);
    }
}

執行結果為:

Final counter value: 1491
修飾靜態方法

synchronized修飾的是靜態方法,那麼鎖住的是類的Class物件,因此,無論多少個該類的例項存在,同一時刻也只有一個執行緒能夠訪問此靜態同步方法。針對修飾例項方法的執行緒不安全的示例,我們只需要在synchronized修飾的例項方法上加上static,將其變成靜態方法,此時synchronized鎖住的就是類的class物件。

public class SynchronizedStaticMethod implements Runnable{

    private static int counter = 0;

    // 修飾例項方法,鎖住的是當前例項物件
    private static synchronized void add() {
        counter++;
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            add();
        }
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(new SynchronizedStaticMethod());
        Thread t2 = new Thread(new SynchronizedStaticMethod());

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Final counter value: " + counter);
    }
}

執行結果為:

Final counter value: 2000
修飾程式碼塊

透過指定物件作為鎖,可以更精確地控制同步範圍。這種方式允許在一個方法內部對不同物件進行不同的同步控制。可以指定一個物件作為鎖,只有持有該物件鎖的執行緒才能執行被synchronized修飾的程式碼塊。

public class SynchronizedBlock implements Runnable{

    private static int counter = 0;

    @Override
    public void run() {
        // 這個this還可以是SynchronizedBlock.class,說明鎖住的是class物件
        synchronized (this){
            for (int i = 0; i < 1000; i++) {
                counter++;
            }
        }
    }

    public static void main(String[] args) throws Exception {
        SynchronizedBlock block = new SynchronizedBlock();
        Thread t1 = new Thread(block);
        Thread t2 = new Thread(block);

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Final counter value: " + counter);
    }
}

synchronized 內建鎖作為一種物件級別的同步機制,其作用在於確保臨界資源的互斥訪問,實現執行緒安全。它本質上鎖定的是物件的監視器(Object Monitor),而非具體的引用變數。這種鎖具有可重入性,即同一個執行緒在已經持有某物件鎖的情況下,仍能再次獲取該物件的鎖,這顯著增強了執行緒安全程式碼的編寫便利性,並在一定程度上有助於降低因執行緒互動引起的死鎖風險。

關於如何避免死鎖,請參考:阿里二面:如何定位&避免死鎖?連著兩個面試問到了!

synchronized的底層原理

在JDK 1.6之前,synchronized關鍵字所實現的鎖機制確實被認為是重量級鎖。這是因為早期版本的Java中,synchronized的實現依賴於作業系統的互斥量(Mutexes)來實現執行緒間的同步,這涉及到了從使用者態到核心態的切換以及執行緒上下文切換等相對昂貴的操作。一旦一個執行緒獲得了鎖,其他試圖獲取相同鎖的執行緒將會被阻塞,這種阻塞操作會導致執行緒狀態的改變和CPU資源的消耗,因此在高併發、低鎖競爭的情況下,這種鎖機制可能會成為效能瓶頸。

而在JDK 1.6中,對synchronized進行了大量最佳化,其中包括引入了偏向鎖(Biased Locking)、輕量級鎖(Lightweight Locking)的概念。接下來我們先說一下JDK1.6之前synchronized的原理。

物件的組成結構

在JDK1.6之前,在Java虛擬機器中,Java物件的記憶體結構主要有物件頭(Object Header)例項資料(Instance Data)對齊填充(Padding) 三個部分組成。

  1. 物件頭(Object Header)
    物件頭主要包含了兩部分資訊:Mark Word(標記欄位)和指向類後設資料(Class Metadata)的指標。Mark Word 包含了一些重要的標記資訊,比如物件是否被鎖定、物件的雜湊碼、GC相關資訊等。類後設資料指標指向物件的類後設資料,用於確定物件的型別資訊、方法資訊等。

  2. 例項資料(Instance Data)
    例項資料是物件的成員變數和例項方法所佔用的記憶體空間,它們按照宣告的順序依次儲存在物件的例項資料區域中。例項資料包括物件的所有非靜態成員變數和非靜態方法。

  3. 填充(Padding)
    在JDK 1.6及之前的版本中,為了保證物件在記憶體中的儲存地址是8位元組的整數倍,可能會在物件的例項資料之後新增一些填充位元組。這些填充位元組的目的是對齊記憶體地址,提高記憶體訪問效率。填充位元組通常不包含任何實際資料,只是用於佔位。

JDK1.6之前物件結構.png

物件頭

在JDK 1.6之前的Java HotSpot虛擬機器中,物件頭的基本組成依然包含Mark Word和型別指標(Klass Pointer),但當時對於鎖的實現還沒有引入偏向鎖和輕量級鎖的概念,因此物件頭中的Mark Word在處理鎖狀態時比較簡單,主要是用來儲存鎖的狀態資訊以及與垃圾收集相關的資料。在一個32位系統重物件頭大小通常約為32位,而在64位系統中大小通常為64位。
物件頭組成部分:

  1. Mark Word(標記字)
    在早期版本的HotSpot虛擬機器中,Mark Word主要儲存的資訊包括:
  • 物件的hashCode(在沒有鎖定時)。
  • 物件的分代年齡(用於垃圾回收演算法)。
  • 鎖狀態資訊,如無鎖、重量級鎖狀態(在使用synchronized關鍵字時)。
  • 物件的鎖指標(Monitor地址,當物件被重量級鎖鎖定時,儲存的是指向重量級鎖(Monitor)的指標)。

HotSpot虛擬機器物件頭Mark Word.png

物件頭中的Mark Word是一個非固定的資料結構,它會根據物件的狀態複用自己的儲存空間,儲存不同的資料。在Java HotSpot虛擬機器中,Mark Word會隨著程式執行和物件狀態的變化而儲存不同的資訊。其資訊變化如下:

image.png

從儲存資訊的變化可以看出:

  • 物件頭的最後兩位儲存了鎖的標誌位,01表示初始狀態,即未加鎖。此時,物件頭記憶體儲的是物件自身的雜湊碼。無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態。
  • 當進入偏向鎖階段時,物件頭內的標誌位變為01,並且儲存當前持有鎖的執行緒ID。這意味著只有第一個獲取鎖的執行緒才能繼續持有鎖,其他執行緒不能競爭同一把鎖。
  • 在輕量級鎖階段,標誌位變為00,物件頭記憶體儲的是指向執行緒棧中鎖記錄的指標。這種情況下,多個執行緒可以透過比較鎖記錄的地址與物件頭內的指標地址來確定自己是否擁有鎖。

其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行最佳化後新增加的。重量級鎖也就是通常說synchronized的物件鎖,鎖標識位為10,其中指標指向的是monitor物件(也稱為管程或監視器鎖)的起始地址。

  1. 型別指標(Klass Pointer 或 Class Pointer)
    型別指標指向物件的類後設資料(Class Metadata),即物件屬於哪個類的型別資訊,用於確定物件的方法表和欄位佈局等。在一個32位系統重大小通常約為32位,而在64位系統中大小通常為64位。

  2. 陣列長度(Array Length)(僅對陣列物件適用):
    如果物件是一個陣列,物件頭中會額外包含一個欄位來儲存陣列的長度。在一個32位系統中大小通常約為32位,而在64位系統中大小通常為64位。

監視器(Monitor)

在Java中,每個物件都與一個Monitor關聯,Monitor是一種同步機制,負責管理執行緒對共享資源的訪問許可權。當一個Monitor被執行緒持有時,物件便處於鎖定狀態。Java的synchronized關鍵字在JVM層面上透過MonitorEnterMonitorExit指令實現方法同步和程式碼塊同步。MonitorEnter嘗試獲取物件的Monitor所有權(即獲取物件鎖),MonitorExit確保每個MonitorEnter操作都有對應的釋放操作。

在HotSpot虛擬機器中,Monitor具體由ObjectMonitor實現,其結構如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //鎖計數器,表示重入次數,每當執行緒獲取鎖時加1,釋放時減1。
    _waiters      = 0, //等待執行緒總數,不一定在實際的ObjectMonitor中有直接體現,但在管理執行緒同步時是一個重要指標。
    _recursions   = 0; //與_count類似,表示當前持有鎖的執行緒對鎖的重入次數。
    _object       = NULL; // 通常指向關聯的Java物件,即當前Monitor所保護的物件。
    _owner        = NULL; // 持有ObjectMonitor物件的執行緒地址,即當前持有鎖的執行緒。
    _WaitSet      = NULL; //儲存那些呼叫過`wait()`方法並等待被喚醒的執行緒佇列。
    _WaitSetLock  = 0 ; // 用於保護_WaitSet的鎖。
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //阻塞在EntryList上的單向執行緒列表,可能用於表示自旋等待佇列或輕量級鎖的自旋連結串列。
    FreeNext      = NULL ; // 在物件Monitor池中可能用於連結空閒的ObjectMonitor物件。
    _EntryList    = NULL ; // 等待鎖的執行緒佇列,當執行緒請求鎖但發現鎖已被持有時,會被放置在此佇列中等待。
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ; // 標誌位,可能用於標識_owner是否指向一個真實的執行緒物件。
  }

其中最重要的就是_owner_WaitSet_EntryListcount幾個欄位,他們之間的轉換關係:

  1. _owner:
    當一個執行緒首次成功執行synchronized程式碼塊或方法時,會嘗試獲取物件的Monitor(即ObjectMonitor),並將自身設定為_owner。該執行緒此刻擁有了物件的鎖,可以獨佔訪問受保護的資源。

  2. _EntryList_owner:
    當多個執行緒同時嘗試獲取鎖時,除第一個成功獲取鎖的執行緒外,其餘執行緒會進入_EntryList排隊等待。一旦_owner執行緒釋放鎖,_EntryList中的下一個執行緒將有機會獲取鎖併成為新的_owner

  3. _owner_WaitSet:
    _owner執行緒在持有鎖的情況下呼叫wait()方法時,它會釋放鎖(即_owner置為NULL),並把自己從_owner轉變為等待狀態,然後將自己新增到_WaitSet中。這時,執行緒進入等待狀態,暫停執行,等待其他執行緒透過notify()notifyAll()喚醒。

  4. _WaitSet_EntryList:
    當其他執行緒呼叫notify()notifyAll()方法時,會選擇一個或全部在_WaitSet中的執行緒,將它們從_WaitSet移除,並重新加入到_EntryList中。這樣,這些執行緒就有機會再次嘗試獲取鎖併成為新的_owner

有上述轉換關係我們可以發現,當多執行緒訪問同步程式碼時:

  1. 執行緒首先嚐試進入_EntryList競爭鎖,成功獲取Monitor後,將_owner設定為當前執行緒並將count遞增。
  2. 若執行緒呼叫wait()方法,會釋放Monitor、清空_owner,並將執行緒移到_WaitSet中等待被喚醒。
  3. 當執行緒執行完畢或呼叫notify()/notifyAll()喚醒等待執行緒後,會釋放Monitor,使得其他執行緒有機會獲取鎖。

在Java物件的物件頭(Mark Word)中,儲存了與鎖相關的狀態資訊,這使得任意Java物件都能作為鎖來使用,同時,notify/notifyAll/wait等方法正是基於Monitor鎖物件來實現的,因此這些方法必須在synchronized程式碼塊中呼叫。

我們檢視上述同步程式碼塊SynchronizedBlock的位元組碼檔案:

image.png

從上述位元組碼中可以看到同步程式碼塊的實現是由monitorentermonitorexit指令完成的,其中monitorenter指令所在的位置是同步程式碼塊開始的位置,第一個monitorexit指令是用於正常結束同步程式碼塊的指令,第二個monitorexit指令是用於異常結束時所執行的釋放Monitor指令。

關於檢視class檔案的位元組碼檔案,有兩種方式:1、透過命令: javap -verbose <class路徑>/class檔案。2、IDEA中透過外掛:jclasslib Bytecode viewer

我們再看一下作用於同步方法的位元組碼:

image.png

我們可以看出同步方法上沒有monitorentermonitorexit 這兩個指令了,而在檢視該方法的class檔案的結構資訊時發現了Access flags後邊的synchronized標識,該標識表明了該方法是一個同步方法。Java虛擬機器透過該標識可以來辨別一個方法是否為同步方法,如果有該標識,執行緒將持有Monitor,在執行方法,最後釋放Monitor。

image.png

總結

synchronized作用於同步程式碼塊時的原理:
Java虛擬機器使用monitorenter和monitorexit指令實現同步塊的同步。monitorenter指令在進入同步程式碼塊時執行,嘗試獲取物件的Monitor(即鎖),monitorexit指令在退出同步程式碼塊時執行,釋放Monitor。

而對於方法級別的同步的原理:
Java虛擬機器透過在方法的訪問標誌(Access flags)中設定ACC_SYNCHRONIZED標誌來實現方法同步。當一個方法被宣告為synchronized時,編譯器會在生成的位元組碼中插入monitorenter和monitorexit指令,確保在方法執行前後正確地獲取和釋放物件的Monitor。

本文已收錄於我的個人部落格:碼農Academy的部落格,專注分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中介軟體、架構設計、面試題、程式設計師攻略等

相關文章