當前位置: 妍妍網 > 碼農

徹底理解Java並行編程之Synchronized關鍵字實作原理剖析

2024-02-27碼農

(給 哪咤編程 加星標,提高Java技能)

大家好,我是哪咤。

Synchronized 關鍵字(互斥鎖)原理,一線大廠不變的面試題,同時也是理解 Java 並行編程必不可少的一環!其中覆蓋的知識面很多,需要理解的點也很多,本文會以相關書籍和結合自己的個人理解,從基礎的套用範圍到底層深入剖析的方式進行闡述,如果錯誤或疑問歡迎各位看官評論區留言糾正,謝謝!

一、Synchronized套用方式及鎖型別

眾所周知,在計畫開發過程中使用多執行緒的效果就是一個字:快!

多執行緒編程能夠給我們的程式帶來很大的效能收益,同時也能夠把機器的效能發揮到極致。

而隨著如今時代的進步發展,機器早就擺脫了單核的限制,所以當我們在開發過程中,只是編寫單執行緒的程式時,在很多時候無疑會浪費機器的計算能力。正因如此,多執行緒編程在我們現在的開發過程中顯的越來越重要,同時也成了一線大廠面試必問的一個門檻。

而當我們在研究 Java 並行編程時,執行緒安全問題是我們的重要關註點,而構成這個問題的根本原因無非就三個要素:「多執行緒、共享資源(臨界資源)、非原子性操作」。一句話概敘執行緒安全問題產生的根本原因: 多條執行緒同時對一個共享資源進行非原子性操作時會誘發執行緒安全問題 。(如果對於這三個概念存在疑問,請仔細閱讀我的上篇文章理解: 【JMM與Volatile】 )。

既然程式會出現了執行緒安全問題,那又該怎麽去解決呢?無他,破壞掉構成這個問題的三要素中的任何一個就可以啦!因此為了解決這個問題,我們可以去把多執行緒的並列執行,變為單執行緒序列執行,同一時刻只讓一條執行緒執行,這種方案有一個高大尚而響亮的名字: 互斥鎖/排他鎖

也就是當多條執行緒,同時執行一段被互斥鎖保護的程式碼(臨界資源)時,需要先獲取鎖,這時只會有一個執行緒獲取到鎖資源成功執行,其他執行緒將陷入等待的狀態,直到當前執行緒執行完畢釋放鎖資源之後,其他執行緒才能執行。

在Java並行編程中提供了一種機制: synchronized 關鍵字來實作互斥鎖的功能。

當然我們也需要註意 Synchronized 的另一個作用: Synchronized 可以保證一個執行緒對臨界資源(共享資源)發生了改變後,能對其他所有執行緒可見,也就是代替上章節所說的 Volatile 可見性作用。

PS: Synchronized 無法完全取代 Volatile ,因為 Synchronized 可以保證可見性、原子性、「有序性」,但是無法禁止指令重排序,這點我們會在後面分析。

1.1、Synchronized三種鎖型別

Synchronized 本質上都是依賴物件來鎖,根據不同的物件型別,可以分為三種鎖粒度:

  • this :當前例項鎖

  • class :類物件鎖

  • Object :物件例項鎖

  • 1.2、Synchronized三種套用方式

  • • 修飾例項成員方法:使用 this 鎖,執行緒想要執行被 Synchronized 關鍵字修飾的普通方法,必須先獲取當前例項物件的鎖資源;

  • • 修飾靜態成員方法:使用 class 鎖,執行緒想要執行被 Synchronized 關鍵字修飾的靜態方法,必須先獲取當前類物件的鎖資源;

  • • 修飾程式碼塊:使用 Object 鎖,使用給定的物件實作鎖功能,執行緒想要執行被 Synchronized 關鍵字修飾的程式碼塊,必須先獲取當前給定物件的鎖資源。

  • 1.2.1、synchronized修飾例項成員方法

    public classSyncIncrDemoimplementsRunnable{
    //共享資源(臨界資源)
    staticinti = 0;
    //synchronized關鍵字修飾例項成員方法
    publicsynchronizedvoidincr(){
    i++;
    }
    @Override
    publicvoidrun() {
    for(int j=0;j<1000;j++){
    incr();
    }
    }
    publicstaticvoidmain(String[] args) throws InterruptedException {
    SyncIncrDemosyncIncrDemo = newSyncIncrDemo();
    Thread t1=newThread(syncIncrDemo);
    Thread t2=newThread(syncIncrDemo);
    t1.start();
    t2.start();
    /**
    *join:使得放棄當前執行緒的執行,並返回對應的執行緒,例如下面程式碼的意思就是:
    程式在main執行緒中呼叫t1,t2執行緒的join方法,則main執行緒放棄cpu控制權,並返回
    t1,t2執行緒繼續執行直到執行緒t1,t2執行完畢;
    所以結果是t1,t2執行緒執行完後,才到主執行緒執行,相當於在main執行緒中同步t1,t2
    執行緒,t1,t2執行完了,main執行緒才有執行的機會
    */

    t1.join();
    t2.join();
    System.out.println(i);
    }
    /**
    * 輸出結果:
    * 2000
    */

    }

    上述程式碼中,我們開啟 t1、t2 兩個執行緒操作同一個共享資源,即 int 變量 i ,由於自增的 i++ 操作,在我們上章節分析到該操作並不具備原子性,具體是分為三步來執行:

  • • ①先從主記憶體中讀取值;

  • • ②在自己工作記憶體進行 +1 操作;

  • • ③將結果重新整理回主記憶體。

  • 如果 t2 執行緒,在 t1 執行緒讀取舊值和寫回新值期間,也就是 t2 t1 在自己工作記憶體中做 +1 計算時,讀取全域資源 i 的值,那 t2 會和 t1 看到同一個值( i=1 ),並執行相同值的 +1 操作,這也就造成了執行緒不安全,因此對於 incr 方法必須使用 synchronized 修飾,做到多執行緒的互斥,解決執行緒安全問題。

    此時我們應該註意到: synchronized 修飾的 incr() ,是一個物件例項方法。在這樣的情況下,當前執行緒的鎖便是 this 例項鎖,也就是當前例項物件 syncIncrDemo (任意物件都可以作為鎖物件,依賴於物件頭實作,稍後會分析)。

    從程式碼執行結果來看確實是正確的,倘若我們沒有使用 synchronized 關鍵字修飾 incr() 方法,其最終輸出結果就有可能小於 2000 ,這便是 synchronized 關鍵字的作用,示意圖如下:

    多執行緒執行圖-1

    這裏我們還需要意識到:當一個執行緒正在存取一個被 synchronized 修飾的例項方法時,其他執行緒則不能存取該物件的其他被 synchronized 修飾的物件例項方法,畢竟一個物件只有一把鎖,當一個執行緒獲取了該物件的鎖之後,其他執行緒無法獲取該物件的鎖,所以無法存取該物件的其他被 synchronized 修飾的物件例項方法。

    public classA {
    publicsynchronizedvoidx(){}
    publicsynchronizedvoidy(){}
    }

    比如上述這個例子中,當一條執行緒正在執行 x() 時,其他執行緒存取 y() 方法也會陷入阻塞。

    但是如果有其他方法未被 synchronized 修飾,又或者其他被 synchronized 修飾的是靜態方法,這類方法其他執行緒還是可以存取的,再來看個例子:

    public classA {
    publicsynchronizedvoidx(){}
    publicsynchronizedvoidy(){}
    publicstaticvoidmain(String[] args) {
    newThread(()->{
    Aa1 = newA();
    a1.x();
    },"AA").strat();
    newThread(()->{
    Aa2 = newA();
    a2.x();
    },"BB").strat();
    }
    }


    如果執行緒 AA 存取的是 a1 物件的 x() 方法,另一個執行緒 BB 存取的是 a2 物件的 x() 方法,這樣是允許同時存取的,因為兩個例項物件鎖並不同。此時如果兩個執行緒運算元據並非共享的,可以保障執行緒安全,遺憾的是如果兩個執行緒操作的是共享數據,那麽執行緒安全就有可能無法保證了,如下程式碼將演示出該情況:

    public classSyncIncrDemoimplementsRunnable{
    //共享資源(臨界資源)
    staticinti = 0;
    //synchronized關鍵字修飾例項成員方法
    publicsynchronizedvoidincr(){
    i++;
    }
    @Override
    publicvoidrun() {
    for(int j=0;j<1000;j++){
    incr();
    }
    }
    publicstaticvoidmain(String[] args) throws InterruptedException {
    SyncIncrDemosyncIncrDemo1 = newSyncIncrDemo();
    SyncIncrDemosyncIncrDemo2 = newSyncIncrDemo();
    Thread t1=newThread(syncIncrDemo1);
    Thread t2=newThread(syncIncrDemo2);
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
    }
    /**
    * 輸出結果:
    * 1991
    */

    }

    上述程式碼與前面不同的是:我們同時建立了兩個新例項 syncIncrDemo1、syncIncrDemo2 ,然後啟動兩個不同的執行緒對共享變量 i 進行操作,可是結果是 1991 ,而不是期望結果 2000 ,因為上述程式碼犯了嚴重的錯誤。

    雖然我們使用 synchronized 修飾了 incr() 方法,但卻 new 了兩個不同的例項物件,這也就意味著存在著兩把不同的例項物件鎖,因此 t1 t2 都會獲取各自的物件鎖, t1、t2 執行緒使用的不是同一把鎖,因此執行緒安全是無法保證的,示意圖如下:

    多執行緒執行圖-2

    解決這種困境的方式是將 incr() 方法使用 static 來修飾,這樣的話,鎖物件就類的 class 物件,無論建立多少個例項物件,但對於的類物件( class 物件)來說,虛擬機器只會載入字節碼後生成一個,在這樣的情況下,鎖物件就是唯一的。

    下面我們看看如何使用將 synchronized 作用於靜態的 incr() 方法。

    1.2.2、synchronized修飾靜態成員方法

    synchronized 用於修飾靜態方法時,其鎖就是當前類的 class 物件,當使用 class 鎖時,當前 Java 程式中,一個類只會生成一個 class 物件,不會因為 new 出多個例項造成多把鎖、執行緒分別獲取不同鎖資源的情況發生。

    由於靜態成員不屬於任何一個例項物件,而是類成員,因此可以透過 class 物件鎖控制靜態成員的並行操作。需要註意的是:如果一個執行緒 A ,呼叫一個被 synchronized 修飾的普通例項方法;而執行緒 B 透過這個例項物件,呼叫被 synchronized 修飾的 static 方法,這是允許同時執行的,並不會發生互斥現象。

    因為存取靜態 synchronized 方法的執行緒,獲取的是當前類的 class 物件的鎖資源;而存取非靜態 synchronized 方法的執行緒,獲取的是當前例項物件鎖資源,看如下程式碼:

    public classSyncIncrDemoimplementsRunnable{
    //共享資源(臨界資源)
    staticinti = 0;
    //synchronized關鍵字修飾例項成員方法 鎖物件:this 當前 new 的例項物件
    publicsynchronizedvoidreduce(){
    i--;
    }
    //synchronized關鍵字修飾靜態成員方法 鎖物件: class SyncIncrDemo. class
    publicstaticsynchronizedvoidincr(){
    i++;
    }
    @Override
    publicvoidrun() {
    for(int j=0;j<1000;j++){
    incr();
    }
    }
    publicstaticvoidmain(String[] args) throws InterruptedException {
    SyncIncrDemosyncIncrDemo1 = newSyncIncrDemo();
    SyncIncrDemosyncIncrDemo2 = newSyncIncrDemo();
    Thread t1=newThread(syncIncrDemo1);
    Thread t2=newThread(syncIncrDemo2);
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
    }
    /**
    * 輸出結果:
    * 2000
    */

    }

    由於 synchronized 修飾的是靜態 incr() 方法,與修飾例項方法不同的是: 例項方法其鎖物件是當前例項物件( this 物件),而靜態方法的鎖物件是當前類的 class 物件 ,這樣就算 new 出多個例項物件,也不會在多執行緒同時執行 incr() 方法出現執行緒安全問題,示意圖如下:

    多執行緒執行圖-3

    註意程式碼中的 reduce() 方法是普通例項方法,其物件鎖是當前例項物件,如果別的執行緒呼叫該方法,將不會產生互斥現象,畢竟鎖物件不同。我們應該意識到這種情況下,可能會發生執行緒安全問題,畢竟 reduce() 方法也操作了共享變量 i

    PS:無論 synchronized 是修飾物件例項方法,還是修飾靜態成員方法,使用的鎖都是 this 鎖型別的,只不過在修飾物件例項方法時,這個 this 指的是當前 new 出來的物件,因為物件例項方法是屬於當前物件的。
    synchronized 是修飾靜態成員方法時,這個 this 指的是 class 物件,因為靜態成員不屬於任何一個例項物件,是類成員(這裏可能有點抽象難以理解,但是只要記住, synchronized 修飾在方法上時,用的就是 this 物件作為鎖物件)。

    1.2.3、synchronized修飾程式碼塊

    除了使用 synchronized 關鍵字修飾例項方法、靜態方法外,還可以用它修飾程式碼塊。畢竟在某些情況下,編寫的方法體可能比較大,比如 2000 行程式碼的方法,如果直接使用 synchronized 關鍵字修飾這個方法,那麽該方法執行的過程會比較耗時,而這 2000 行程式碼中,也並非所有的程式碼都會發生執行緒安全問題。

    假設 2000 行程式碼中還存在一些比較耗時的操作(如 IO 操作),這種情況直接對整個方法進行同步操作,那必然會導致大量的執行緒阻塞,最終得不償失。這時,我們可以使用同步程式碼塊的方式,對需要保障執行緒安全的程式碼進行包裹,這樣就無需對整個方法用 synchronized 關鍵字修飾了,程式碼範例如下:

    public classSyncIncrDemoimplementsRunnable{
    //共享資源(臨界資源)
    staticinti = 0;
    //synchronized關鍵字修飾程式碼塊
    publicvoidmethodA(){
    //省略一千行程式碼....
    /** 
    * 假設我們此時只有這裏存在對共享資源操作,我們如果對整個方法進行同步
    * 那麽是不應該的,而我們可以使用同步這段程式碼的形式使用`synchronized`
    * 關鍵字對它進行同步修飾
    */

    synchronized(SyncIncrDemo. class){
    i++;
    }
    // 省略八百行程式碼....
    }
    @Override
    publicvoidrun() {
    methodA();
    }
    publicstaticvoidmain(String[] args) throws InterruptedException {
    SyncIncrDemosyncIncrDemo = newSyncIncrDemo();
    for(int j=0;j<1000;j++){
    newThread(syncIncrDemo).start();
    }
    Thread.sleep(10000);
    System.out.println(i);
    }
    /**
    * 輸出結果:
    * 1000
    */

    }



    從上述程式碼可以看出,我們使用 synchronized 修飾程式碼塊時,將 class 類物件做為鎖資源(即鎖物件),每次當執行緒進入 synchronized 包裹的程式碼塊時,就會要求當前執行緒持有 SyncIncrDemo. class 類物件鎖。如果當前有其他執行緒正持有該鎖,那麽新到的執行緒就必須阻塞等待,這樣也就保證了同時只會有一個執行緒執行 i++ 操作。

    當然,除了類物件作為鎖資源外,我們還可以使用 this 物件(代表當前例項),或者給予一個物件作為鎖物件,如下程式碼:

    // 當前例項
    synchronized(this){
    i++;
    }
    // 給予物件
    Objectobj = newObject();
    synchronized(obj){
    i++;
    }

    到這裏,關於 synchronized 的基本描述與使用就告一段落了,接下來需要研究的是:** synchronized 關鍵字底層的實作原理**,從而進一步加深對於 synchronized 的理解。

    二、Synchronized底層原理剖析

    前面提到 synchronized 是依賴於物件實作的鎖功能(物件頭以及 Monitor ),而從官方的虛擬機器規範文件上,能看到關於同步的描述是這樣的:

    Java 虛擬機器中的同步( Synchronization )基於進入和結束管程( Monitor )物件實作。

    可以看到 Java 中的 synchronized 同步,的確是基於 Monitor (管程)物件來實作的。

  • • 獲取鎖:進入管程物件(顯式型: monitorenter 指令)

  • • 釋放鎖:結束管程物件(顯式型: monitorexit 指令)

  • 不過要明白一點,當我們使用 synchronized 修飾方法時,無法透過 javap 看到進入/結束管程物件的指令。因為當 synchronized 修飾方法時, 是透過呼叫指令,讀取執行時常量池中方法的 ACC_SYNCHRONIZED 標誌來實作的 synchronized 修飾方法時使用的隱式同步。

    不過無論是顯式同步,還是隱式同步,都是依靠進入/結束管程物件來實作的同步(關於顯式和隱式稍後會分析),不過值得一提的是:在 Java 中關於同步的概念,並不僅僅在 synchronized 中體現, synchronized 只是同步的一種實作,它並不能完全代表 Java 的同步機制。

    2.1、理解Java物件記憶體布局

    JVM 中,一個 Java 物件在記憶體的布局,會分為三個區域:物件頭、例項數據以及對齊填充:

    Java物件記憶體布局

    ①物件頭 :儲存 MarkWord 和型別指標( classMetadataAddress/KlassWord );如果是陣列物件,還會存在陣列長度( ArrayLength )。

    ②例項數據 :存放當前物件內容成員資訊,以及父類內容成員資訊,比如:

    public classA {
    privateint x;
    privateint y;
    privatelong z;
    }

    這個類存在兩個 int 和一個 long 型別的內容,那麽就是 4 + 4 + 8 = 16byte 大小。

    ③對齊填充 :由於虛擬機器要求物件起始地址必須是 8byte 的整數倍,所以虛擬機器會對於每個物件做 8 的倍數填充,如果這個物件的大小(物件頭+例項數據大小)已經是 8 的整數倍了,則不會出現對齊填充。

    為此,對齊填充並不是每個物件都有,這部份僅僅是為了字節對齊,避免減少堆記憶體的碎片空間和方便 OS 讀取。

    關於 Java 物件頭則是 synchronized 底層實作的關鍵要素,下面我們重點分析物件頭的構成, JVM 采取兩個字寬( Word/ class 指標大小)儲存物件頭。

    如果該物件是陣列,額外需要儲存陣列長度,所以 32 位虛擬機器采取 3 個字寬儲存物件頭,而 64 位虛擬機器采取兩個半字寬儲存物件頭,而在 32 位虛擬機器中,一個字寬的大小為 4byte/32bit 64 位虛擬機器下,一個字寬大小為 8byte/64bit 64 位開啟指標壓縮的情況下, MarkWord 8byte KlassWord 4byte

    而關於物件頭內的具體內容,很多資料都含糊不清,我在這裏例出如下資訊(如有任何疑問歡迎留言),先給出 32 位虛擬機器下的物件頭結構資訊:

    虛擬機器位元數 物件頭結構資訊 說明 大小
    32 MarkWord HashCode 、分代年齡、是否偏向鎖和鎖標記位 4byte/32bit
    32 classMetadataAddress/KlassWord 型別指標指向物件的類後設資料,JVM透過這個指標確定該物件是哪個類的例項 4byte/32bit
    32 ArrayLenght 如果是陣列物件儲存陣列長度,非陣列物件不存在 4byte/32bit

    再來看看 64 位虛擬機器下的物件頭結構:

    虛擬機器位元數 物件頭結構資訊 說明 大小
    64 MarkWord unused、HashCode 、分代年齡、是否偏向鎖和鎖標記位 8byte/64bit
    64 classMetadataAddress/KlassWord 型別指標指向物件的類後設資料,JVM透過這個指標確定該物件是哪個類的例項 8byte/64bit
    64 ArrayLenght 如果是陣列物件儲存陣列長度,非陣列物件不存在 4byte/32bit

    其中 32 位虛擬機器中,物件頭內的 MarkWord ,在預設情況下,儲存著物件的 HashCode 、分代年齡、是否偏向鎖、鎖標記位等資訊。而 64 位虛擬機器中,物件頭內的 MarkWord ,預設儲存著 HashCode 、分代年齡、是否偏向鎖、鎖標記位、 unused ,如下:

    虛擬機器位元數 鎖狀態 HashCode 分代年齡 是否偏向鎖 鎖標誌資訊
    32 無鎖態(預設) 25bit 4bit 1bit 2bit
    虛擬機器位元數 鎖狀態 HashCode 分代年齡 是否偏向鎖 鎖標誌資訊 unused
    64 無鎖態(預設) 31bit 4bit 1bit 2bit 26bit

    由於物件頭的資訊,與物件自身定義的成員內容數據沒有關系,物件頭屬於額外的儲存成本。考慮到 JVM 的空間效率, MarkWord 被設計成為一個非固定的數據結構,為了方便儲存更多有效的數據,它會根據物件本身的狀態,復用自己的儲存空間,除了上述列出的 MarkWord 預設儲存結構外,還有如下可能變化的結構(前面32位元,後面64位元):

    32位元虛擬機器markword變化資訊

    64位元虛擬機器markword變化資訊

    從上圖中可以看到,當物件狀態為偏向鎖時, MarkWord 儲存的是偏向的執行緒 ID

    當狀態為輕量級鎖時, MarkWord 儲存的是指向執行緒棧中 LockRecord 的指標, LockRecord 是什麽呢?由於 MarkWord 的空間有限,隨著物件狀態的改變,原本儲存在物件頭裏的一些資訊,如 HashCode 、物件年齡等,就沒有足夠的空間儲存。這時為了保證這些數據不遺失,就會拷貝一份原本的 MarkWord 放到執行緒棧中,這個拷貝過去的 MarkWord 叫作 Displaced Mark Word ,同時會配合一根指向物件的指標,形成 LockRecord (鎖記錄),而原本物件頭中的 MarkWord ,就只會儲存一根指向 LockRecord 的指標。

    下面再來對 MarkWord 的資訊稍作解釋(後續會用到):

  • unused :未使用的空間;

  • identity_hashcode :物件最原始的 hashcode ,就算重寫 hashcode() 也不會改變;

  • age :物件的 GC 年齡;

  • biased_lock :是否偏向鎖的標識;

  • lock :鎖標記位;

  • ThreadID :持有偏向鎖的執行緒 ID

  • epoch :偏向鎖時間戳;

  • ptr_to_lock_record :指向執行緒本地棧中 lock_record 的指標;

  • ptr_to_heavyweight_monitor :指向堆中 monitor 物件的指標。

  • 在這裏我們提到了輕量級鎖和偏向鎖,這是 JDK1.6 synchronized 最佳化後新增加的,稍後我們會簡要分析。

    這裏我們主要先分析一下重量級鎖,也就是通常說的 synchronized 物件鎖,鎖標識位為 10 ,其中指標指向的是 monitor 物件(也稱為管程或監視器鎖)的起始地址。每個 Java 物件都存在著一個 monitor 物件與之關聯。物件與其 monitor 之間的關系,有存在多種實作方式,如 monitor 可以與物件一起建立銷毀,或當執行緒試圖獲取物件鎖時自動生成,但當一個 monitor 被某個執行緒持有後,它便處於釘選狀態。

    HotSpot 虛擬機器中, monitor 是由 ObjectMonitor 實作的,其主要數據結構如下(位於 HotSpot 源碼的 ObjectMonitor.hpp 檔中):

    位置:openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp
    實作:C/C++
    程式碼:
    ObjectMonitor() {
    _header = NULL; //markOop物件頭
    _count = 0; //記錄個數
    _waiters = 0, //等待執行緒數
    _recursions = 0; //重入次數
    _object = NULL; //監視器鎖寄生的物件。鎖不是平白出現的,而是寄托儲存於物件中。
    _owner = NULL; //指向獲得ObjectMonitor物件的執行緒或基礎鎖
    _WaitSet = NULL; //處於wait狀態的執行緒,會被加入到_WaitSet
    _WaitSetLock = 0 ; 
    _Responsible = NULL;
    _succ = NULL;
    _cxq = NULL;
    FreeNext = NULL;
    _EntryList = NULL; //處於等待鎖block狀態的執行緒,會被加入到該列表
    _SpinFreq = 0 ;
    _SpinClock = 0 ;
    OwnerIsThread = 0 ; // _owner is (Thread *) vs SP/BasicLock
    _previous_owner_tid = 0; // 監視器前一個擁有者執行緒的ID
    }

    Monitor 存在於堆中,什麽是 Monitor ?我們可以把它理解為一個同步工具,也可以描述為一種同步機制,但是它的本質就是一個特殊的物件。

    萬物皆物件,而 Java 的所有物件都是天生的 Monitor ,每一個 Java 物件都有成為 Monitor 的潛質。因為在 Java 的設計中,每一個 Java 物件自打娘胎裏出來就帶了一把看不見的鎖,它叫做內部鎖或者 Monitor 鎖。

    Monitor 是執行緒私有的數據結構,每一個執行緒都有一個可用 monitor record 列表,同時還有一個全域的可用列表。每一個被鎖住的物件,都會和一個 monitor 關聯(物件頭的 MarkWord 中的 LockWord ,指向 monitor 的起始地址),同時 monitor 中有一個 Owner 欄位,存放擁有該鎖的執行緒唯一標識,表示該鎖被這個執行緒占用, Monitor 內部結構如下:

    Monitor物件結構
  • Contention List :競爭佇列,所有請求鎖的執行緒,首先被放在這個競爭佇列中(後續 1.8 版本中的 _cxq )。

  • Entry List Contention List 中那些有資格成為候選資源的執行緒被移動到 Entry List 中。

  • Wait Set :呼叫 Object.wait() 方法後,被阻塞的執行緒被放置在這裏。

  • OnDeck :任意時刻,最多只有一個執行緒正在競爭鎖資源,該執行緒被稱為 OnDeck

  • Owner :初始時為 NULL ,表示當前沒有任何執行緒擁有該 monitor record ,當執行緒成功擁有該鎖後,保存執行緒唯一標識,當鎖被釋放時,又設定為 NULL ,當前已經獲取到所資源的執行緒被稱為 Owner

  • !Owner :當前釋放鎖的執行緒。

  • RcThis :表示 blocked 阻塞或 waiting 等待在該 monitor record 上的執行緒個數。

  • Nest :用來實作重入鎖的計數。

  • Candidate :用來避免不必要的阻塞或等待執行緒喚醒,因為每一次只有一個執行緒能夠成功擁有鎖,如果每次前一個釋放鎖的執行緒,喚醒所有正在阻塞或等待的執行緒,會引起不必要的上下文切換(從阻塞到就緒,然後因為競爭鎖失敗又被阻塞),從而導致效能嚴重下降。 Candidate 只有兩種可能的值, 0 表示沒有需要喚醒的執行緒; 1 表示要喚醒一個繼任執行緒來競爭鎖。

  • HashCode :保存從物件頭拷貝過來的 HashCode 值(可能還包含 GC age )。

  • EntryQ :關聯一個系統互斥鎖( semaphore ),阻塞所有試圖鎖住 monitor record 失敗的執行緒。

  • ObjectMonitor 中有兩個佇列, _WaitSet _EntryList ,用來保存 ObjectWaiter 物件列表( 每個等待鎖的執行緒都會被封裝成 ObjectWaiter 物件), _owner 指向持有 ObjectMonitor 物件的執行緒,當多個執行緒同時存取一段同步程式碼時,首先會加入 _EntryList 集合,當執行緒獲取到物件的 monitor 後進入 _Owner 區域,並把 monitor 中的 owner 變量設定為當前執行緒,同時 monitor 中的計數器 count+1

    若執行緒呼叫 Object.wait() 方法,將釋放當前持有的 monitor owner 變量恢復為 null count 自減 1 ,同時該執行緒進入 WaitSet 集合中等待被喚醒。若當前執行緒執行完畢,也將釋放 monitor (鎖)並復位變量的值,以便其他執行緒進入獲取 monitor 。如下圖所示:

    狀態轉變

    由此看來, monitor 物件存在於堆空間內,每個 Java 物件的物件頭,其中 markword 存放指向 Monitor 物件的指標, synchronized 關鍵字便是透過這種方式獲取鎖的,這也是為什麽 Java 中任意物件可以作為鎖的原因。

    同時也是 notify/notifyAll/wait 等方法,存在於頂級物件 Object 中的原因(這點稍後會進一步分析),有了上述知識基礎後,下面我們將進一步分析 synchronized 在字節碼層面的具體語意實作。

    2.2、從反編譯字節碼理解synchronized修飾程式碼塊的原理

    先來看看編譯前的 Java 原始檔:

    public class SyncDemo{
    int i;
    public void incr(){
    synchronized(this){
    i++;
    }
    }
    }

    使用 javac 編譯如上程式碼,並使用 javap -p -v -c 進行反組譯,會得到如下字節碼:

    classfile /C:/Users/XYSM/Desktop/com/SyncDemo. class
    Last modified 2020-6-17; size 454 bytes
    MD5 checksum 457e08e7b9caa345db5c5cca53d8d612
    Compiled from "SyncDemo.java"
    public  classcom.SyncDemo
    minorversion0
    major version: 52
    flags: ACC_PUBLIC, ACC_SUPER
    Constant pool:
    ...... //省略常量池資訊
    {
    int i;
    descriptor: I
    flags:
    // 建構函式
    public com.SyncDemo();
    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
    4return
    LineNumberTable:
    line 30
    /*-------synchronized修飾incr()中程式碼塊,反組譯之後得到的字節碼檔--------*/
    public void incr();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
    stack=3, locals=3, args_size=1
    0: aload_0
    1: dup
    2: astore_1
    3: monitorenter // monitorenter進入同步
    4: aload_0
    5: dup
    6: getfield #2// Field i:I
    9: iconst_1
    10: iadd
    11: putfield #2// Field i:I
    14: aload_1
    15: monitorexit // monitorexit結束同步
    16goto24
    19: astore_2
    20: aload_1
    21: monitorexit // 第二次出現monitorexit結束同步
    22: aload_2
    23: athrow
    24return
    Exception table:
    // 省略其他字節碼資訊........

    }
    SourceFile: "SyncDemo.java"


    synchronized 有關的指令,只需關註如下字節碼:

     3: monitorenter // monitorenter進入同步
     15: monitorexit // monitorexit結束同步
     21: monitorexit // 第二次出現monitorexit結束同步

    從字節碼中可知, synchronized 修飾程式碼塊,是基於進入管程 monitorenter 和結束管程 monitorexit 指令實作的,其中 monitorenter 指令指向同步程式碼塊的開始位置, monitorexit 指令則指明同步程式碼塊的結束位置。

    當執行 monitorenter 指令時,當前執行緒將試圖獲取 objectref (即物件鎖)所對應的 monitor 的持有權,當 objectref monitor 計數器為 0 ,那執行緒可以嘗試占有 monitor ,如果將計數器值成功設定為 1 ,表示獲取鎖成功,虛擬碼如下:

    // monitorenter指令虛擬碼:
    if(count == 0){
    count = count + 1;
    獲取鎖成功!
    else{
    當前鎖資源已被其他執行緒持有,進入阻塞!
    }
    // monitorexit指令虛擬碼:
    count = 0;

    但值得註意的是:如果當前執行緒已經擁有 objectref monitor 的持有權,那它可以重入這個 monitor (關於重入性稍後會分析),重入時計數器的值也會 +1

    倘若其他執行緒已經擁有 objectref monitor 的所有權,那當前執行緒將被阻塞,直到持有的執行緒執行完畢,即 monitorexit 指令被執行,前一個執行緒將釋放 monitor (鎖),並設定計數器值為 0 ,其他執行緒將有機會持有 monitor

    同時, JVM 將會確保無論方法透過何種方式結束,方法中呼叫過的每條 monitorenter 指令獲取鎖,都有執行其對應 monitorexit 指令釋放鎖。說人話就是:無論這個方法是正常結束,還是異常結束,都會保證執行緒釋放鎖。這也是為什麽大家在上述字節碼檔中,能看到兩個 monitorexit 指令的原因。

    為了保證在方法異常結束時, monitorenter monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個例外處理器,這個例外處理器可處理所有的異常,它的目的就是用來執行 monitorexit 指令釋放鎖。

    從字節碼中看到的第二個 monitorexit 指令,它就是異常結束時,會被執行的釋放 monitor 指令,確保在方法執行過程中,由於異常導致的方法意外結束時,不出現死結現象。

    2.3、反編譯字節碼理解synchronized修飾方法原理

    方法級的同步是隱式鎖,即無需透過字節碼指令來控制的,獲取鎖、釋放鎖的實作位置,分別位於方法呼叫和返回操作時。 JVM 可以從方法常量池中的 method_info Structure 方法表結構中,靠 ACC_SYNCHRONIZED 存取標誌來區分一個方法是否為同步方法。

    當方法呼叫時,呼叫指令時將會檢查方法的 ACC_SYNCHRONIZED 存取標誌是否被設定,如果設定了,執行緒執行前,將需要先持有 monitor (虛擬機器規範中用的是管程一詞),然後再執行方法,最後再方法結束時釋放 monitor

    在方法執行期間,執行執行緒持有了 monitor ,其他任何執行緒都無法再獲得同一個 monitor 。如果一個同步方法執行期間丟擲了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的 monitor ,將在異常拋到同步方法之外時自動釋放。

    下面我們看看字節碼層面如何實作,編譯前 Java 原始檔:

    public class SyncDemo{
    int i;
    public synchronized void reduce(){
    i++;
    }
    }

    同樣先用 javac 編譯,再用 javap -p -v -c 得到反組譯後的字節碼:

    classfile /C:/Users/XYSM/Desktop/com/SyncDemo. class
    Last modified 2020-6-17; size 454 bytes
    MD5 checksum 457e08e7b9caa345db5c5cca53d8d612
    Compiled from "SyncDemo.java"
    public class com.SyncDemo
    minor version: 0
    major version: 52
    flags: ACC_PUBLIC, ACC_SUPER
    Constant pool:
    ...... //省略常量池資訊
    {
    int i;
    descriptor: I
    flags:
    // 建構函式
    public com.SyncDemo();
    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
    // synchronized修飾方法
    public synchronized void reduce();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
    stack=3, locals=1, args_size=1
    0: aload_0
    1: dup
    2: getfield #2 // Field i:I
    5: iconst_1
    6: iadd
    7: putfield #2 // Field i:I
    10: return
    LineNumberTable:
    line 11: 0
    line 12: 10
    // 省略其他字節碼資訊........
    }
    SourceFile: "SyncDemo.java"


    從字節碼中可以看出, synchronized 修飾的方法,並沒有出現 monitorenter 指令和 monitorexit 指令,取得代之的是: flags: ACC_PUBLIC 之後增加了一個 ACC_SYNCHRONIZED 標識 。這個標識指明了當前方法是一個同步方法, JVM 透過這個 ACC_SYNCHRONIZED 存取標誌,來辨別一個方法是否為同步方法,從而執行相應的同步呼叫。這便是 synchronized 修飾在方法上的實作原理。

    同時,大家還得明白,在 Java 早期版本中, synchronized 屬於重量級鎖,效率低下,因為 monitor 監視器鎖,依賴於底層作業系統的 Mutex Lock 來實作,而作業系統實作執行緒之間的切換時,需要從使用者態轉換到內核態,這個切態過程需要較長的時間,並且更方面成本較高,這也是早期的 synchronized 效能效率低的原因。

    不過值得慶幸的是在 Java6 之後, Java 官方從 JVM 層面對 synchronized 進行了最佳化,所以現在的 synchronized 鎖,效率也十分不錯了。 Java6 之後,為了減少獲得鎖、釋放鎖帶來的效能消耗,引入了輕量級鎖和偏向鎖,下面簡單了解一下官方對 synchronized 鎖的最佳化。

    三、Java6對於synchronized的最佳化:鎖膨脹

    JDK1.6 之後, synchronized 鎖的狀態總共有四種: 無鎖狀態、偏向鎖、輕量級鎖和重量級鎖 。隨著執行緒的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級一般是單向的,也就是說只能從低到高升級,通常不會出現鎖的降級。

    但是有個細節值得的註意,不會出現鎖降級,只是針對使用者執行緒而言,對於重量級鎖還是會出現鎖降級的情況,降級發生於 STW 階段,降級物件就是那些僅僅能被 VMThread 存取,而沒有其他 JavaThread 存取的 Monitor 物件(具體參考: 重量級鎖降級 )。

    關於重量級鎖,前面我們已詳細分析過,下面我們將介紹偏向鎖和輕量級鎖,以及 JVM 的其他最佳化手段,不過畢竟涉及到具體過程比較繁瑣,如需了解詳細過程可以查閱【深入理解Java虛擬機器原理】,在此並不會對鎖升級進行細節性的分析,而是階段性的總結。

    3.1、無鎖態

    當我們在Java程式中 new 一個物件時,會預設啟動匿名偏向鎖,但是值得註意的是有個小細節, 偏向鎖的啟動有個延時,預設是 4 ,也就是: JVM 啟動四秒之後才會開啟匿名偏向鎖,在 JVM 啟動的前四秒內, new 的物件不會啟動匿名偏向鎖,why?

    因為 JVM 虛擬機器自己有一些預設啟動的執行緒,裏面有好多 sync 程式碼,這些 sync 程式碼啟動時,就知道肯定會有競爭,如果使用偏向鎖,就會造成偏向鎖不斷的進行鎖撤銷和鎖升級的操作,效率較低。

    還有一點值得註意,對於一個物件而言,就算啟動了匿名偏向鎖,這個物件的腦袋裏,也沒有任何的執行緒 ID 。因為是新建立的物件,所以對於一個新 new 物件而言,不管有沒有啟動匿名偏向鎖,都被稱為概念上的無鎖態物件。

    畢竟就算啟動了匿名偏向鎖,但是在沒有成為真正的偏向鎖之前, markword 資訊中的 threadID 是空的,因為此時沒有執行緒獲取該鎖(但是當物件成為匿名偏向鎖時, mrakword 中的鎖標誌位仍然會改為 101 ,偏向鎖的標誌)。

    3.2、偏向鎖

    偏向鎖是 Java6 之後加入的新鎖,它是一種針對加鎖操作的最佳化手段。

    經過官方研究發現,在大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,為了減少同一執行緒獲取鎖的代價,如 CAS 操作帶來的耗時等,從而引入了偏向鎖。

    偏向鎖的核心思想是:如果一個執行緒獲得了鎖,那麽鎖就進入偏向模式,此時 Mark Word 的結構也變為偏向鎖結構,當這個執行緒再次請求鎖時,無需再做任何同步操作,即不需要再走獲取鎖的流程,這樣就省去了大量有關鎖申請的操作,從而也就提高程式的效能。

    換句通俗易懂的話說:偏向鎖其中的「偏」是偏心的偏,就是這個鎖會偏向於第一個獲得它的執行緒,在接下來的執行過程中,假如該鎖沒有被其他執行緒所持有,也沒有其他執行緒來競爭該鎖,那麽持有偏向鎖的執行緒,將永遠不需要進行獲取鎖操作。在此執行緒之後的執行過程中,如果再次進入或者結束同一段同步塊程式碼,並不需要再做加鎖或者解鎖動作,而是會做以下操作:

  • Load-and-test ,就是簡單判斷一下當前執行緒 id 是否與 Markword 中的執行緒 id 是否一致;

  • • 如果一致,則說明此執行緒持有的偏向鎖,沒有被其他執行緒覆蓋,直接執行下面的程式碼;

  • • 如果不一致,則要檢查一下物件是否還屬於可偏向狀態,即檢查「是否偏向鎖」標誌位;

  • • 如果還未偏向,則利用 CAS 操作來競爭鎖,再次將 ID 放進去,即重復第一次獲取鎖的動作。

  • 但是當第二個執行緒來嘗試獲取鎖時,如果此物件已經偏向了,並且不是偏向自己,則說明出現了競爭。此時會根據該鎖的競爭情況,可能會產生偏向撤銷,重新偏向的現象。

    但大部份情況下,就是直接膨脹成輕量級鎖了。所以,對於沒有鎖競爭的場合,偏向鎖有很好的最佳化效果,畢竟極有可能連續多次是同一個執行緒申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,畢竟這樣場合,極有可能每次申請鎖的執行緒都不相同,為此這種場合下,可以透過 JVM 參數關閉偏向鎖,否則會得不償失。

    3.2.1、偏向鎖撤銷過程

  • • 在一個安全點停下擁有鎖的執行緒;

  • • 遍歷執行緒棧,如果存在鎖記錄的話,需要修復鎖記錄和Markword,使其變成無鎖狀態;

  • • 喚醒當前執行緒,將當前鎖升級成輕量級鎖。

  • 所以,如果程式中,大部份同步程式碼塊,在大多數情況下,都會出現兩個及以上的執行緒競爭,此時偏向鎖就會是一種累贅,對於這種情況,我們可以一開始就透過 XX:-UseBiasedLocking 把偏向鎖關閉,從而做到效能上的最佳化。

    3.2.2、偏向鎖膨脹過程

    當第一個執行緒進入時,發現是匿名偏向狀態,這時會透過 cas 操作,把自己的 threadId 設定到 MarkWord 中,如果替換成功,則證明成功拿到偏向鎖,失敗則鎖膨脹。

    當執行緒第二次進入同步塊時,經過一些比較之後,如果發現自己的執行緒 id ,和物件頭中的偏向執行緒 id 一致,在當前執行緒棧的 lock record 中添加一個空的 Displaced Mark Word ,由於操作的是私有執行緒棧,所以不需要 cas 操作, synchronized 帶來的開銷基本可以忽略。

    當其他執行緒進入同步塊時,發現偏向執行緒不是自己,則進入偏向鎖撤銷的邏輯。當達到全域安全點時,如果發現偏向執行緒掛了,那就把偏向鎖撤銷,並將物件頭內的 MarkWord 修復為無鎖狀態,自己嘗試獲取偏向鎖(這個過程被稱為重新偏向)。

    可如果原本的偏向執行緒還存活,重新偏向失敗後,鎖開始膨脹為輕量級鎖,原來的執行緒仍然持有鎖,下面我們接著了解輕量級鎖。

    3.3、輕量級鎖

    倘若偏向鎖失敗, Synchronized 並不會立即升級為重量級鎖,它會先進入輕量級鎖狀態,此時 MarkWord 的結構也變為輕量級鎖的結構。輕量級鎖能提升程式效能的依據是:「對於絕大部份的鎖,在整個同步周期內都不存在競爭」,註意這是經驗數據。

    3.3.1、輕量級鎖膨脹過程

    當膨脹為輕量級鎖時,首先根據 markwork 判斷是否有執行緒持有鎖,如果有,則在當前執行緒棧中建立一個 lock record 復制 mark word ,並且透過 cas 機制,把當前執行緒棧的 lock record 地址,放到物件頭中。

    細節:之前持有偏向鎖的執行緒,會優先進行 cas ,嘗試設定 mrakword 中的鎖資訊指標。

    如果成功,則說明獲取到輕量級鎖;如果失敗,則說明鎖已經被其他持有了,此時記錄執行緒的重入次數(把 lock record markword 設定為 null ),此時執行緒會自旋(自適應自旋),確保在競爭不激烈的情況下,仍然可以不膨脹為真正意義上的「內核態重量級鎖」,從而減少消耗。

    如果自旋後還未等到鎖,則說明目前競爭較重,需要膨脹為重量級的鎖,程式碼如下:

    voidObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
    markOop mark = obj->mark();
    assert(!mark->has_bias_pattern(), "should not see bias pattern here");
    // 如果是無鎖狀態
    if (mark->is_neutral()) {
    //設定Displaced Mark Word並替換物件頭的mark word
    lock->set_displaced_header(mark);
    if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
    TEVENT (slow_enter: release stacklock) ;
    return ;
    }
    else
    if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) {
    assert(lock != mark->locker(), "must not re-lock the same lock");
    assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock");
    // 如果是重入,則設定Displaced Mark Word為null
    lock->set_displaced_header(NULL);
    return;
    }
    ...
    // 走到這一步說明已經是存在多個執行緒競爭鎖了 需要膨脹為重量級鎖
    lock->set_displaced_header(markOopDesc::unused_mark());
    ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);
    }

    不過需要了解的是,輕量級鎖適用的場景是: 執行緒交替執行同步塊的場合 ,如果同一時間存在多個執行緒存取同一把鎖,就會導致輕量級鎖膨脹為重量級鎖。但在 JDK1.4 之後,膨脹到重量級鎖階段後,最開始的重量級鎖不會直接進入內核態級別的重量鎖,而是會進入一個「自旋鎖」階段,後續被最佳化成了自適應自旋。

    3.3.2、輕量級鎖小細節

    輕量級鎖主要有自旋、自適應自旋兩種型別。

    ①自旋鎖 :所謂自旋,是指當有另外一個執行緒來競爭鎖時,這個執行緒會在原地迴圈等待,而不是把該執行緒掛起阻塞,直到那個持有鎖的執行緒釋放鎖之後,這個執行緒就可以馬上嘗試獲取鎖。

    註意,執行緒在原地自旋時,會消耗 cpu ,就相當於在執行一個啥也沒有的 for 迴圈。

    所以,輕量級鎖適用於那些同步程式碼塊執行時長很短的場景,這樣,執行緒原地等待很短的時間,就能夠獲得鎖了。經驗表明,大部份同步程式碼塊執行的時間都特別短,也正是基於這個原因,才有了輕量級鎖這麽個東西。

    不過自旋鎖會存在一些問題,如下:

  • • 如果同步程式碼塊執行的很慢,需要等待很長時間,這時其他執行緒自旋會消耗大量 CPU

  • • 本來前一個執行緒釋放鎖後,當前執行緒是能夠拿到鎖的,但假如這時有好幾個執行緒都在自旋等待這把鎖,那就有可能造成當前執行緒拿不到鎖,還得繼續原地空迴圈消耗 CPU ,甚至有可能一直獲取不到鎖;

  • 基於這些問題,我們必須透過 -XX:PreBlockSpin 給執行緒空迴圈設定一個次數,當執行緒超過了這個次數,我們就認為,繼續使用自旋鎖就不適合了,此時鎖會再次膨脹,升級為重量級鎖。預設情況下,自旋的次數為 10 次,或者自旋執行緒超過 CPU 核數一半時,會發生鎖膨脹(自旋鎖是在 JDK1.4.2 時引入的)。

    ②自適應自旋鎖 :所謂自適應自旋鎖,就是執行緒空迴圈的次數並非固定的,而是會動態根據實際情況來改變自旋等待的次數,其大概原理是這樣的(在重量級鎖階段自旋):

    假如 T1 執行緒剛剛成功拿到鎖,當它把鎖釋放後, T2 執行緒獲得該鎖,並且 T2 在執行的過程中,此時 T1 又過來拿鎖了,但 T2 還沒有釋放該鎖,所以 T1 只能阻塞等待,但是虛擬機器認為: 由於 T1 剛剛獲得過該鎖,那麽虛擬機器會覺得 T1 這次自旋,也很有可能再次成功拿到該鎖,所以會延長 T1 自旋的次數。

    另外,如果對於某一個鎖,一個執行緒自旋之後,很少成功獲得該鎖,那麽以後這個執行緒要獲取該鎖時,是有可能直接跳過自旋過程,直接走重量級鎖的邏輯,以免空迴圈等待浪費資源。

    同時,當鎖資源的競爭已經非常激烈後,自適應自旋存在的意義已經沒有必要了,因為存在大量執行緒競爭同一把鎖,就算自旋一段時間,其他執行緒還需要繼續自旋等待,此時自旋帶來的開銷,已經大於在內核態掛起執行緒的開銷了。所以,在競爭很激烈的情況下,自適應自旋的次數可能會為 0 ,也就是不再嘗試自旋,而是直接膨脹為真正意義上的「內核態重量級鎖」。

    3.4、重量級鎖

    關於重量級鎖,在前面已經詳細分析過了,重量級鎖就是傳統意義的互斥鎖了,當出現較大競爭、鎖膨脹為重量級鎖時,物件頭的 markword 指向堆中的 monitor ,此時會將執行緒封裝為一個 ObjectWaiter 物件,並插入到 monitor _cxq 佇列中,然後掛起當前執行緒。

    當持有鎖的執行緒釋放後,會把 _cxq 裏面的所有執行緒( ObjectWaiter 物件),轉移到 EntryList 中去,並且會從 EntryList 中挑選一個執行緒喚醒,被選中的執行緒叫做 Heir Presumptive 假定繼承人(應該是這樣轉譯),就是圖中的 Ready Thread ,假定繼承人被喚醒後會嘗試獲得鎖,但 synchronized 是非公平鎖,所以假定繼承人不一定能獲得鎖(這也是它叫"假定"繼承人的原因):

    Monitor物件結構

    如果執行緒獲得鎖後,呼叫 Object.wait() 方法,則會將執行緒加入到 WaitSet 中,當被 Object.notify() 喚醒後,會將執行緒從 WaitSet 移動到 _cxq EntryList 中去。

    需要註意:當呼叫一個鎖物件的 wait、notify 方法時,如當前鎖的狀態是偏向鎖或輕量級鎖,則會先膨脹成重量級鎖,因為 wait、notify 方法要依賴於 Monitor 物件實作。

    3.5、鎖狀態總結

  • • 無鎖態: JVM 啟動後四秒內的普通物件,和四秒後的匿名偏向鎖物件

  • • 偏向鎖狀態:只有一個執行緒進入臨界區

  • • 輕量級鎖狀態:多個執行緒交替進入臨界區

  • • 重量級鎖:多個執行緒同時進入臨界區

  • 下面來張圖,總結一下鎖膨脹/升級的過程:

    鎖膨脹過程

    3.6、Object物件四種鎖狀態分析

    public class ObjectHead {
    public static void main(String[] args) throws InterruptedException {
    /** 
    無鎖態:虛擬機器剛啟動時 new 出來的物件處於無鎖狀態
    **/
    Object obj = new Object();
    // 檢視物件內部資訊
    System.out.println( classLayout.parseInstance(obj).toPrintable());

    /** 
    匿名偏向鎖:休眠4S後再建立出來的物件處於匿名偏向鎖狀態
    PS:當一個執行緒在執行被synchronized關鍵字修飾的程式碼或方法時,如果看到該鎖
    物件是處於匿名偏向鎖狀態的(標誌位為偏向鎖但是物件頭中MrakWord內threadID
    為空),那麽這個執行緒將會利用cas機制把自己的執行緒ID設定到mrakword中,此後
    如果沒有其他執行緒來競爭該鎖,那麽這個執行緒再執行被需要獲取該鎖的程式碼將不需
    要經過任何獲取鎖和釋放鎖的過程。
    **/
    Thread.sleep(4000);
    Object obj1 = new Object();
    System.out.println( classLayout.parseInstance(obj1).toPrintable());
    /** 
    輕量級鎖:對於真正的無鎖態物件obj加鎖之後的物件處於輕量級鎖狀態
    **/
    synchronized (obj) {
    // 檢視物件內部資訊
    System.out.println( classLayout.parseInstance(obj).toPrintable());
    }
    /** 
    重量級鎖:呼叫wait方法之後鎖物件直接膨脹為重量級鎖狀態
    **/
    new Thread(()->{
    try {
    obj.wait();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }).start();
    Thread.sleep(1);
    synchronized (obj) {
    // 檢視物件內部資訊
    System.out.println( classLayout.parseInstance(obj).toPrintable());
    }
    }
    }
    輸出結果:
    java.lang.Object object internals: 鎖標誌位狀態:001:真正意義上無鎖狀態
     OFFSET SIZE TYPE DESCRIPTION VALUE
    0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
    4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
    12 4 (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    java.lang.Object object internals: 鎖標誌位狀態:101:匿名偏向鎖狀態
     OFFSET SIZE TYPE DESCRIPTION VALUE
    0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
    4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
    12 4 (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    java.lang.Object object internals: 鎖標誌位狀態:000:輕量級鎖狀態
     OFFSET SIZE TYPE DESCRIPTION VALUE
    0 4 (object header) 18 f5 41 01 (00011000 11110101 01000001 00000001) (21099800)
    4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
    12 4 (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    java.lang.Object object internals: 鎖標誌位狀態:010:重量級鎖狀態
     OFFSET SIZE TYPE DESCRIPTION VALUE
    0 4 (object header) 5a de db 17 (01011010 11011110 11011011 00010111) (400285274)
    4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
    8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
    12 4 (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    /**丟擲異常原因:違法的監控狀態異常。當某個執行緒試圖等待一個自己並不擁有的物件(Obj)的監控器或者通知其他執行緒等待該物件(Obj)的監控器時,丟擲該異常。**/
    Exception in thread "Thread-0": java.lang.IllegalMonitorStateException
    at java.lang.Object.wait(Native Method)
    at java.lang.Object.wait(Object.java:502)
    at com.sixstar.springbootvolatilesynchronized.Synchronized.ObjectHead.lambda$main$0(ObjectHead.java:27)
    at java.lang.Thread.run(Thread.java:748)







    四、Synchronized細節及其他特性分析

    4.1、同步消除

    同步消除是 JVM 另外一種對鎖的最佳化機制,這種最佳化更徹底, Java 虛擬機器在編譯程式碼時,透過會對執行上下文進行掃描,從而去除不可能存在共享資源競爭的鎖,透過這種方式消除沒有必要的鎖,可以節省毫無意義的獲取鎖開銷,如下:

    // 情況一:
    publicvoidappendString(String s1, String s2) {
    /*
    StringBuffer是執行緒安全,由於sb只會在append方法中使用,不可能被其他執行緒參照
    因此sb屬於不可能共享的資源,JVM會自動消除內部的鎖
    */

    StringBuffersb = newStringBuffer();
    sb.append(s1).append(s2);
    }
    // 情況二:
    StringBuffersb = newStringBuffer();
    publicsynchronizedvoidappendString(String s1, String s2) {
    /*
    StringBuffer是執行緒安全,由於sb是在appendString方法中使用,而appendString
    是被synchronized修飾的,是執行緒安全的,那麽沒有必要再這裏獲取兩把鎖
    因此JVM會自動消除內部的鎖,有些小夥伴看到這裏會疑惑,這不是鎖重入嗎?
    其實並不是,鎖重入指的是同一個鎖資源被執行緒多次獲取時直接跳過獲取鎖邏輯,稍後會分析
    */

    sb.append(s1).append(s2);
    }

    StringBuffer append 是一個同步方法,但是在 appendString 方法中, sb 屬於一個局部變量,並且不會被其他執行緒所使用,因此 sb 不可能存線上程競爭的情景,為此 JVM 會自動將其鎖消除。

    4.2、Synchronized重入性

    從互斥鎖的設計上來說,當一個執行緒試圖操作一個被其他執行緒持有鎖的臨界資源時,這時將會陷入阻塞狀態。但當一個執行緒再次請求自己持有的鎖,所保護的臨界資源時,這種情況屬於重入鎖,重入請求將會成功。

    java 中, synchronized 是基於原子性的內部鎖機制,它支持鎖的重入性,因此在一個執行緒呼叫 synchronized 方法的同時,在其方法體內部呼叫該物件另一個 synchronized 方法,也就是說:一個執行緒得到一個物件鎖後,再次請求該物件鎖,這是是允許的,這就是 synchronized 的可重入性,如下:

    public classSyncIncrDemoimplementsRunnable{
    //共享資源(臨界資源)
    staticinti = 0;
    //synchronized關鍵字修飾例項成員方法
    publicsynchronizedvoidincr(){
    i++;
    }
    @Override
    publicvoidrun() {
    synchronized(this){
    for(int j=0;j<1000;j++){
    incr();
    }
    }
    }
    publicstaticvoidmain(String[] args) throws InterruptedException {
    SyncIncrDemosyncIncrDemo = newSyncIncrDemo();
    Thread t1=newThread(syncIncrDemo);
    Thread t2=newThread(syncIncrDemo);
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(i);
    }
    }

    上述程式碼中,建立了一個 SyncIncrDemo 例項,以及啟動兩個執行緒,執行緒啟動後會去執行 run 方法,而在 run 方法內部使用了 synchronized 修飾程式碼塊,並將 this 物件作為鎖資源,那麽執行緒必須先獲取當前例項 syncIncrDemo 這把鎖,才能執行 for 迴圈程式碼。

    而當一個執行緒成功獲取到鎖時,會發現 for 迴圈內部呼叫了該類中,另外一個被 synchronized 修飾的成員例項方法 incr() ,這時難道要再去獲取一次當前例項鎖資源?我們在前面分析到,成員例項方法最終的鎖物件,還是當前 this 例項物件,而當前執行緒已經拿到了 this 鎖,所以並不需要再次獲取鎖。

    此類情況就是重入鎖最直接的體現,不過值得註意的是: synchronized 是基於 Monitor 實作的,每次重入時 monitor 中的計數器仍然會 +1 。還有一個細節需要稍微留意,就是當當子類別繼承父類時,子類別也是可以透過可重入鎖呼叫父類的同步方法。

    4.3、synchronized與執行緒等待/喚醒機制

    所謂等待喚醒機制,本篇主要指的是 notify/notifyAll wait 方法,在使用這三個方法時,必須處於 synchronized 程式碼塊或者 synchronized 方法中,否則就會丟擲 IllegalMonitorStateException 異常。

    這是因為呼叫這幾個方法前,必須拿到當前物件的監視器 monitor 物件,也就是說 notify、notifyAll、wait 方法依賴於 monitor 物件。在前面的分析中,我們知道 monitor 依靠物件頭的 MarkWord 中的指標來尋址,而 synchronized 關鍵字決定著一個 Java 物件,會不會生成 monitor 物件。

    這也就是為什麽 notify、notifyAll、wait 方法,必須在 synchronized 程式碼塊或者 synchronized 方法呼叫的原因。

    Objectobj = newObject();
    synchronized (obj) {
    obj.wait();
    obj.notify();
    obj.notifyAll();
     }

    同時,與 sleep 方法不同的是: wait 方法呼叫完成後,執行緒將被掛起,但 wait 方法將會釋放當前持有的監視器鎖( monitor ),直到有執行緒呼叫 notify/notifyAll 方法後才能繼續執行,而 sleep 方法只讓執行緒休眠並不釋放鎖(類似於 for(;;){} 死迴圈)。

    不過 notify/notifyAll 方法呼叫後,並不會馬上釋放監視器鎖,而是在相應的 monitorexit 指令執行結束後,才會自動釋放鎖。

    4.4、synchronized與執行緒中斷機制

    4.4.1、執行緒中斷

    關於 Java 執行緒物件呼叫 start() 方法後,如果想中止該執行緒可以呼叫 Thread.stop() 方法強制讓該執行緒關閉,但遺憾的是 stop() 方法的使用是強制式停止的,因此會造成很嚴重的問題,在 JDK1.2 後被遺棄。

    為此,在目前的 Java 版本中,並沒有提供「強制性停止正在執行執行緒」的方法,取而代之的是協調式的方式,在目前的 Java 版本中,提供了如下三個有關執行緒中斷的 API

    //中斷執行緒(例項方法)
    public void Thread.interrupt();
    //判斷執行緒是否被中斷(例項方法)
    public boolean Thread.isInterrupted();
    //判斷是否被中斷並清除當前中斷狀態(靜態方法)
    public static boolean Thread.interrupted();

    當一個執行緒處於被阻塞狀態,或者試圖執行一個阻塞操作時,使用 Thread.interrupt() 方式可以中斷該執行緒。註意:此時將會丟擲一個 InterruptedException 的異常,同時中斷狀態將會被復位(由中斷狀態改為非中斷狀態),如下程式碼將演示該過程:

    public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread() {
    @Override
    public void run() {
    //while在try中,透過異常中斷就可以結束run迴圈
    try {
    while (true) {
    //當前執行緒處於阻塞狀態,異常必須捕捉處理,無法往外丟擲
    TimeUnit.SECONDS.sleep(2);
    }
    } catch (InterruptedException e) {
    System.out.println("Interruted When Sleep");
    boolean interrupt = this.isInterrupted();
    //中斷狀態被復位
    System.out.println("interrupt:"+interrupt);
    }
    }
    };
    t1.start();
    TimeUnit.SECONDS.sleep(2);
    //中斷處於阻塞狀態的執行緒
    t1.interrupt();
    /**
    * 輸出結果:
    Interruted When Sleep
    interrupt:false
    */
    }

    如上述程式碼所示,我們建立一個執行緒,並線上程中呼叫了 sleep 方法,從而使用執行緒進入阻塞狀態。啟動執行緒後,呼叫執行緒的 interrupt 方法中斷阻塞異常,並丟擲 InterruptedException 異常,此時中斷狀態也將被復位。

    這裏有些人可能會詫異,為什麽不用 Thread.sleep(2000) ,而是用 TimeUnit.SECONDS.sleep(2) ?其實原因很簡單,前者並沒有明確的單位說明,而後者非常明確表達秒的單位,事實上後者的內部實作,最終還是呼叫了 Thread.sleep(2000) ,但為了編寫的程式碼語意更清晰,建議使用 TimeUnit.SECONDS.sleep(2) 的方式(註意 TimeUnit 是個列舉型別)。

    除了阻塞中斷的情景,處於執行期且非阻塞的狀態的執行緒,在這種情況下,直接呼叫 Thread.interrupt() 中斷執行緒,是不會得到任響應的,如下程式碼,將無法中斷非阻塞狀態下的執行緒:

    public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(){
    @Override
    public void run(){
    while(true){
    System.out.println("未被中斷");
    }
    }
    };
    t1.start();
    TimeUnit.SECONDS.sleep(2);
    t1.interrupt();
    /**
    * 輸出結果(無限執行):
    未被中斷
    未被中斷
    未被中斷
    ......
    */
    }

    雖然我們呼叫了 interrupt 方法,但執行緒 t1 並未被中斷,因為目前 Java 中的執行緒中斷,都是協調式的,在這裏只是由 mian 執行緒向 t1 執行緒發送一個中斷訊號,但是 t1 執行緒還在執行,那麽它並不會停止,所以對於處於非阻塞狀態的執行緒,需要我們手動進行中斷檢測並結束程式,改進後程式碼如下:

    public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(){
    @Override
    public void run(){
    while(true){
    //判斷當前執行緒是否被中斷
    if (this.isInterrupted()){
    System.out.println("執行緒中斷");
    break;
    }
    }
    System.out.println("已跳出迴圈,執行緒中斷!");
    }
    };
    t1.start();
    TimeUnit.SECONDS.sleep(2);
    t1.interrupt();
    /**
    * 輸出結果:
    執行緒中斷
    已跳出迴圈,執行緒中斷!
    */
    }

    是的,我們在程式碼中,使用了例項方法 isInterrupted 判斷執行緒是否已被中斷,如果被中斷將跳出迴圈以此結束執行緒,註意非阻塞狀態呼叫 interrupt() 並不會導致中斷狀態重設。

    綜合所述,可以簡單總結一下中斷兩種情況,一種是當執行緒處於阻塞狀態,或者試圖執行一個阻塞操作時,我們可以使用例項方法 interrupt() 進行執行緒中斷,執行中斷操作後將會丟擲 interruptException 異常(該異常必須捕捉無法向外丟擲)並將中斷狀態復位。

    另外一種是當執行緒處於執行狀態時,我們也可呼叫例項方法 interrupt() 進行執行緒中斷,但同時必須手動判斷中斷狀態,並編寫中斷執行緒的程式碼(其實就是結束run方法體的程式碼)。有時我們在編碼時可能需要兼顧以上兩種情況,那麽就可以如下編寫:

    publicvoidrun(){
    try {
    //判斷當前執行緒是否已中斷,註意interrupted方法是靜態的,
    // 執行後會對中斷狀態進行復位
    while (!Thread.interrupted()) {
    TimeUnit.SECONDS.sleep(2);
    }
    catch (InterruptedException e) {
    }
    }

    4.4.2、synchronized與執行緒中斷

    事實上,執行緒的中斷操作,對於正在等待獲取 synchronized 鎖物件的執行緒而言,並不起作用,也就是對於 synchronized 來說,如果一個執行緒在等待鎖,那麽結果只有兩種,要麽它獲得這把鎖繼續執行,要麽它就阻塞等待,即使呼叫中斷執行緒的方法,也不會生效。演示程式碼如下:

    public class SyncBlock implements Runnable{
    public synchronized void occupyLock() {
    System.out.println("Trying to call occupyLock()");
    while(true) // 從不釋放鎖
    Thread.yield();
    }
    /**
    * 在構造器中建立新執行緒並啟動獲取物件鎖
    */
    public SyncBlock() {
    //該執行緒已持有當前例項鎖
    new Thread() {
    public void run() {
    occupyLock(); // 當前執行緒獲取鎖
    }
    }.start();
    }
    public void run() {
    //中斷判斷
    while (true) {
    if (Thread.interrupted()) {
    System.out.println("中斷執行緒!!");
    break;
    } else {
    occupyLock();
    }
    }
    }
    public static void main(String[] args) throws InterruptedException {
    SyncBlock sync = new SyncBlock();
    Thread t = new Thread(sync);
    //啟動後呼叫occupyLock()方法,無法獲取當前例項鎖處於等待狀態
    t.start();
    TimeUnit.SECONDS.sleep(1);
    //中斷執行緒,無法生效
    t.interrupt();
    }
    }

    我們在 SyncBlock 建構函式中,建立一個新執行緒並啟動,然後呼叫 occupyLock() 獲取到當前例項鎖,由於 SyncBlock 自身也是執行緒,啟動後在其 run 方法中,也呼叫了 occupyLock() ,但由於物件鎖被其他執行緒占用,導致 t 執行緒只能等待鎖,此時我們呼叫了 t.interrupt() 但並不能中斷執行緒。

    4.5、為什麽synchronized不能禁止指令重排序?

    開頭我們說過一個結論:** synchronized 能保證有序性,卻不能禁止指令重排序**。

    在闡述這個問題答案前,如果有小夥伴對於指令重排序、有序性、可見性,這些概念還不太清楚,那請先移步另外一篇文章: 玩命死磕Java記憶體模型(JMM)與Volatile關鍵字底層原理。

    實際上 synchronized 關鍵字所保證的原子性、可見性、有序性,實際上都是基於一個思路: 將之前的多執行緒並列執行,變為了單執行緒的序列執行。

    Java 程式中,倘若在本執行緒內,所有操作都視為有序行為。如果是多執行緒環境下,一個執行緒中觀察另外一個執行緒,所有操作都是無序的,前半句指的是單執行緒內,保證序列語意執行的一致性,後半句則指指令重排現象,和工作記憶體與主記憶體同步延遲現象。

    那實際對於單執行緒而言,所有操作都是有序的,因此 synchronized 將之前的多執行緒並列執行,變為了單執行緒的序列執行之後,必然可以保證「有序性」。而對於單執行緒而言,指令重排是對單執行緒的執行有利的,此時就沒有必要去禁止指令重排序,禁止了反而影響單執行緒的效能。

    所以對於這個問題,為什麽 synchronized 能夠保證有序性,卻不能禁止指令重排序?那是因為 synchronized 沒有必要禁止指令重排序,否則還會影響程式效能。

    4.6、synchronized與ReentrantLock相比效能不好的原因

    synchronized 是基於進入和結束管程 Monitor 實作的,而 Monitor 底層是依賴於 OS Mutex Lock ,獲取鎖和釋放鎖都需要經過系統呼叫,而系統呼叫涉及到使用者態和內核態的切換,會經過 0x80 中斷,經過內核呼叫後再返回使用者態,因此而效率低下。

    ReentrantLock 底層實作依賴於特殊的 CPU 指令,比如發送 lock 指令和 unlock 指令,不需要使用者態和內核態的切換,所以效率高(這裏和volatile底層原理類似)。

    不過相對來說,在並行競爭不大的情況下, synchronized 的效能反而會超越 ReentrantLock ,畢竟 synchronized 有同步消除、偏向鎖這些機制,可以確保在競爭不激烈的情況下,程式效能得到很好釋放。







    ·················END·················

    看完本文有收獲?請轉發分享給更多人

    關註「哪咤編程」,提升Java技能

    點贊和在看就是最大的支持 ❤️