Java併發基礎八:深入理解Synchronized

前言

synchronized,是Java中用於解決併發情況下數據同步訪問的一個很重要的關鍵字。當我們想要保證一個共享資源在同一時間只會被一個線程訪問到時,我們可以在代碼中使用synchronized關鍵字對類或者對象加鎖。在JDK1.6之前,synchronized的實現直接調用ObjectMonitorenterexit,這種鎖被稱之爲重量級鎖(用戶態和內核態切換)。JDK 1.6之後進行了一個重要改進,HotSpot虛擬機開發團隊對Java中的鎖進行優化,如適應性自旋、鎖消除、鎖粗化、輕量級鎖和偏向鎖等,提高了性能。那麼,本文來介紹一下synchronized關鍵字的實現原理是什麼。

1.synchronized使用

synchronized是Java提供的一個併發控制的關鍵字。主要有兩種用法,分別是同步方法和同步代碼塊。也就是說,synchronized既可以修飾方法也可以修飾代碼塊。代碼如下:被synchronized修飾的代碼塊及方法,在同一時間,只能被單個線程訪問。

public class SynchronizedDemo {
     //同步方法
    public synchronized void doSth(){
        System.out.println("Hello World");
    }

    //同步代碼塊
    public void doSth1(){
        synchronized (SynchronizedDemo.class){
            System.out.println("Hello World");
        }
    }
}

2.synchronized的實現原理

synchronized 是JVM實現的一種鎖,其依賴於JVM底層實現,當一個線程訪問同步代碼塊時,首先是需要得到鎖才能執行同步代碼,當退出或者拋出異常時必須要釋放鎖。Synchronized的語義底層是通過一個monitor的對象來完成,我們先來使用Javap來反編譯以上代碼,結果如下(部分無用信息過濾掉了):

public synchronized void doSth();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

  public void doSth1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #5                  // class com/hollis/SynchronizedTest
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #3                  // String Hello World
        10: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return

反編譯後,我們可以看到Java編譯器爲我們生成的字節碼。在對於doSthdoSth1的處理上稍有不同。也就是說。JVM對於同步方法和同步代碼塊的處理方式不同。

實現原理:

1、對於同步方法,JVM採用ACC_SYNCHRONIZED標記符來實現同步。

當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設置,如果設置了,執行線程將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何線程都無法再獲得同一個monitor對象。當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設置,如果設置了,執行線程將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何線程都無法再獲得同一個monitor對象。

2、對於同步代碼塊。JVM採用monitorentermonitorexit兩個指令來實現同步。

當執行同步代碼塊時,線程執行monitorenter指令時嘗試獲取monitor的所有權(即鎖定狀態),獲取成功後才能執行代碼,執行完同步代碼後,線程調用monitorexit進行釋放鎖,其他被這個monitor阻塞的線程可以嘗試去獲取這個 monitor 的所有權。

執行過程:每個對象維護着一個記錄着被鎖次數的計數器。未被鎖定的對象的該計數器爲0,當一個線程獲得鎖後,該計數器自增變爲 1 ,當同一個線程再次獲得該對象的鎖的時候,計數器再次自增。當同一個線程釋放鎖的時候,計數器再自減。當計數器爲0的時候。鎖將被釋放,其他線程便可以獲得鎖。

無論是ACC_SYNCHRONIZED還是monitorentermonitorexit都是基於Monitor實現的,在Java虛擬機(HotSpot)中,Monitor是基於C++實現的,由ObjectMonitor實現。ObjectMonitor類中提供了幾個方法,如enterexitwaitnotifynotifyAll等。sychronized加鎖的時候,會調用objectMonitor的enter方法,解鎖的時候會調用exit方法。

3.Java對象頭

在JVM中,對象在內存中的佈局分爲三塊區域:對象頭、實例數據和對齊填充。如下圖所示:

  • 實例數據:存放類的屬性數據信息,包括父類的屬性信息;
  • 對齊填充:由於虛擬機要求 對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊;
  • 對象頭

    Hotspot虛擬機的對象頭主要包括兩部分數據:Mark Word(標記字段)、Class Pointer(類型指針)。其中 Class Pointer是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例,Mark Word用於存儲對象自身的運行時數據,它是實現輕量級鎖和偏向鎖的關鍵

Mark Word用於存儲對象自身的運行時數據,如:哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等。下圖是Java對象頭 無鎖狀態下Mark Word部分的存儲結構(32位虛擬機):


synchronized實現的鎖是存儲在Java對象頭裏.

對象頭的最後兩位存儲了鎖的標誌位,01是初始狀態,未加鎖,其對象頭裏存儲的是對象本身的哈希碼,隨着鎖級別的不同,對象頭裏會存儲不同的內容。偏向鎖存儲的是當前佔用此對象的線程ID而輕量級則存儲指向線程棧中鎖記錄的指針。從這裏我們可以看到,“鎖”這個東西,可能是個鎖記錄+對象頭裏的引用指針(判斷線程是否擁有鎖時將線程的鎖記錄地址和對象頭裏的指針地址比較),也可能是對象頭裏的線程ID(判斷線程是否擁有鎖時將線程的ID和對象頭裏存儲的線程ID比較)。

Mark Word裏面存儲的數據會隨着鎖標誌位的變化而變化,Mark Word可能變化爲存儲以下5種情況:

鎖標誌位的表示意義:

  • 鎖標識lock=00標識輕量級鎖
  • 鎖標識lock=10標識重量級鎖
  • 偏向鎖標識biased_lock=1標識偏向鎖
  • 偏向鎖標識biased_lock=0標識無鎖狀態

綜上所述,synchronized(lock)中的lock可以用Java中任何一個對象來表示,而鎖標識的存儲實際上就是在lock這個對象中的對象頭內。

其實前面只提到了鎖標誌位的存儲,但是爲什麼任意一個Java對象都能成爲鎖對象呢?
 Java中的每個對象都派生自Object類,而每個Java Object在JVM內部都有一個native的C++對象oop/oopDesc進行對應。其次,線程在獲取鎖的時候,實際上就是獲得一個監視器對象(monitor),monitor可以認爲是一個同步對象,所有的Java對象是天生攜帶monitor。

oop/oopDesc解讀:

每一個Java類,在被JVM加載的時候,JVM會給這個類創建一個instanceKlass,保存在方法區,用來在JVM層表示該Java類。當我們在Java代碼中,使用new創建一個對象的時候,JVM會創建一個instanceOopDesc對象,這個對象中包含了對象頭以及實例數據。

監視器(Monitor)解讀:

任何一個對象都有一個Monitor與之關聯,當且一個Monitor被持有後,它將處於鎖定狀態。Synchronized在JVM裏的實現都是 基於進入和退出Monitor對象來實現方法同步和代碼塊同步,雖然具體實現細節不一樣,但是都可以通過成對的MonitorEnter和MonitorExit指令來實現。

  • MonitorEnter指令:插入在同步代碼塊的開始位置,當代碼執行到該指令時,將會嘗試獲取該對象Monitor的所有權,即嘗試獲得該對象的鎖;
  • MonitorExit指令:插入在方法結束處和異常處,JVM保證每個MonitorEnter必須有對應的MonitorExit;

4.鎖優化與升級

在JDK1.6之前,synchronized是一個重量級鎖,性能比較差。在JDK1.6之後,爲了減少獲得鎖和釋放鎖帶來的性能消耗,synchronized進行了優化,引入了自旋鎖、鎖消除、鎖粗化、偏向鎖輕量級的概念。

概念解讀:

①重量級鎖

Synchronized是通過對象內部的一個叫做 監視器鎖(Monitor)來實現的但是監視器鎖本質又是依賴於底層的操作系統的Mutex Lock來實現的。而操作系統實現線程之間的切換這就需要從用戶態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,因此,這種依賴於操作系統Mutex Lock所實現的鎖我們稱之爲 “重量級鎖”

爲什麼重量級鎖的開銷比較大呢?

原因是當系統檢查到是重量級鎖之後,會把等待想要獲取鎖的線程阻塞,被阻塞的線程不會消耗CPU,但是阻塞或者喚醒一個線程,都需要通過操作系統來實現,也就是相當於從用戶態轉化到內核態,而轉化狀態是需要消耗時間的。

②偏向鎖

Hotspot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低而引入了偏向鎖。偏向鎖的意思是:如果一個線程獲得了一個偏向鎖,如果在接下來的一段時間中沒有其他線程來競爭鎖,那麼持有偏向鎖的線程再次進入或者退出同一個同步代碼塊,不需要再次進行搶佔鎖和釋放鎖的操作。

③輕量級鎖

 當存在超過一個線程在競爭同一個同步代碼塊時,會發生偏向鎖的撤銷,而撤銷偏向鎖的時候就是當鎖對象不適合作爲偏向鎖的時候會被升級爲輕量級鎖,JVM同樣使用了CAS + 自旋的方式來實現輕量級鎖。

輕量級鎖加鎖

  • 線程在執行同步代碼塊之前,JVM會先在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中(Displaced Mark Word - 即被取代的Mark Word)做一份拷貝
  • 拷貝成功後,線程嘗試使用CAS將對象頭的Mark Word替換爲指向鎖記錄的指針(將對象頭的Mark Word更新爲指向鎖記錄的指針,並將鎖記錄裏的Owner指針指向Object Mark Word)
  • 如果更新成功,當前線程獲得輕量級鎖,繼續執行同步方法
  • 如果更新失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋(CAS)來獲取鎖當自旋超過指定次數(可以自定義)時仍然無法獲得鎖,此時鎖會膨脹升級爲重量級鎖

輕量級鎖解鎖

  • 嘗試CAS操作將鎖記錄中的Mark Word替換會對象頭中
  • 如果成功,表示沒有競爭發生
  • 如果失敗,表示當前鎖存在競爭,鎖會膨脹成重量級鎖

一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於重量級鎖狀態,其他線程嘗試獲取鎖時,都會被阻塞,也就是 BLOCKED狀態。當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒之後的線程會進行新一輪的競爭


④自旋鎖

自旋鎖顧名思義,就是循環嘗試去獲取鎖。想進辦法避免線程進入內核的阻塞狀態是我們去分析和理解鎖設計的關鍵鑰匙。從輕量級鎖獲取的流程中我們知道,當線程在獲取輕量級鎖的過程中執行CAS操作失敗時,是要通過自旋來獲取重量級鎖的。問題在於,自旋是需要消耗CPU的,如果一直獲取不到鎖的話,那該線程就一直處在自旋狀態,白白浪費CPU資源。其中解決這個問題最簡單的辦法就是指定自旋的次數。

⑤鎖粗化

鎖粗化的概念應該比較好理解,就是將多次連接在一起的加鎖、解鎖操作合併爲一次,將多個連續的鎖擴展成一個範圍更大的鎖。

舉個例子:

public class StringBufferTest {    StringBuffer stringBuffer = new StringBuffer();    public void append(){        stringBuffer.append("a");        stringBuffer.append("b");        stringBuffer.append("c");    }}

這裏每次調用stringBuffer.append方法都需要加鎖和解鎖,如果虛擬機檢測到有一系列連串的對同一個對象加鎖和解鎖操作,就會將其合併成一次範圍更大的加鎖和解鎖操作,即在第一次append方法時進行加鎖,最後一次append方法結束後進行解鎖。

⑥ 鎖消除​​​​​​​

鎖消除即刪除不必要的加鎖操作。根據代碼逃逸技術,如果判斷到一段代碼中,堆上的數據不會逃逸出當前線程,那麼可以認爲這段代碼是線程安全的,不必要加鎖,通俗講:JVM認爲這段代碼是安全的,即使加鎖了,也會進行消除操作。

 

文章參考

https://www.jianshu.com/p/e62fa839aa41

https://www.jianshu.com/p/f9b1159d4fde

https://mp.weixin.qq.com/s/5bp9nJaRPIqvIQ9O1gDxkA

https://mp.weixin.qq.com/s/tI_4nCIg1kkcf6_UW1aA5A

 


 

 

 

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