07.28 深入理解synchronized底層實現原理

引言

我們都知道數據的同步需要加鎖,在JAVA領域,最常用的是使用synchronized關鍵字,那麼synchronized關鍵字在底層是如何實現同步的呢?

synchronized的使用

synchronized的使用方式有如下幾種:

  1. synchronized 加在代碼塊中
1public class SynchronizedDemo {
2
3 public void add(Object obj){
4 synchronized (obj){
5 //do something
6 }
7 }
8}

synchronized 如果鎖住的是某個obj實例,其實鎖的作用是在代碼塊中。

  1. synchronized 加在方法中
1 public synchronized void del(){
2
3 //do something
4 }

synchronized加在方法中,鎖住的是當前對象實例

  1. synchronized 加在靜態方法中
1 public static synchronized void update(){
2
3 //do something
4 }

synchronized 加在靜態代碼塊中,鎖住的便是當前class 的實例,因為 Class數據存在於永久帶(jdk1.7 是存儲在元空間),因此靜態方法鎖相當於該類的一個全局鎖.

線程狀態及狀態轉換

當多個線程同時請求某個對象監視器時,對象監視器會設置幾種狀態用來區分請求的線程:

  1. Contention List:所有請求鎖的線程將被首先放置到該競爭隊列
  2. Entry List:Contention List中那些有資格成為候選人的線程被移到Entry List
  3. Wait Set:那些調用wait方法被阻塞的線程被放置到Wait Set
  4. OnDeck:任何時刻最多隻能有一個線程正在競爭鎖,該線程稱為OnDeck
  5. Owner:獲得鎖的線程稱為Owner
  6. !Owner:釋放鎖的線程

他們轉換關係如下圖所示:

深入理解synchronized底層實現原理

Contention List 虛擬隊列

ContentionList 並不是一個真正的隊列,而只是一個虛擬隊列,原因在於ContentionList是由Node及其next指 針邏輯構成,並不存在一個Queue的數據結構。ContentionList是一個後進先出(LIFO)的隊列,每次新加入Node時都會在隊頭進行, 通過CAS改變第一個節點的的指針為新增節點,同時設置新增節點的next指向後續節點,而取得操作則發生在隊尾。顯然,該結構其實是個Lock- Free的隊列。

因為只有Owner線程才能從隊尾取元素,也即線程出列操作無爭用,當然也就避免了CAS的ABA問題。

EntryList

EntryList與ContentionList邏輯上同屬等待隊列,ContentionList會被線程併發訪問,為了降低對 ContentionList隊尾的爭用,而建立EntryList。Owner線程在unlock時會從ContentionList中遷移線程到 EntryList,並會指定EntryList中的某個線程(一般為Head)為Ready(OnDeck)線程。Owner線程並不是把鎖傳遞給 OnDeck線程,只是把競爭鎖的權利交給OnDeck,OnDeck線程需要重新競爭鎖。這樣做雖然犧牲了一定的公平性,但極大的提高了整體吞吐量,在 Hotspot中把OnDeck的選擇行為稱之為“競爭切換”。

OnDeck線程獲得鎖後即變為owner線程,無法獲得鎖則會依然留在EntryList中,考慮到公平性,在EntryList中的位置不 發生變化(依然在隊頭)。如果Owner線程被wait方法阻塞,則轉移到WaitSet隊列;如果在某個時刻被notify/notifyAll喚醒, 則再次轉移到EntryList。

自旋鎖

那些處於ContetionList、EntryList、WaitSet中的線程均處於阻塞狀態,阻塞操作由操作系統完成(在Linxu下通 過pthread_mutex_lock函數)。線程被阻塞後便進入內核(Linux)調度狀態,這個會導致系統在用戶態與內核態之間來回切換,嚴重影響 鎖的性能

緩解上述問題的辦法便是自旋,其原理是:當發生爭用時,若Owner線程能在很短的時間內釋放鎖,則那些正在爭用線程可以稍微等一等(自旋), 在Owner線程釋放鎖後,爭用線程可能會立即得到鎖,從而避免了系統阻塞。但Owner運行的時間可能會超出了臨界值,爭用線程自旋一段時間後還是無法 獲得鎖,這時爭用線程則會停止自旋進入阻塞狀態(後退)。基本思路就是自旋,不成功再阻塞,儘量降低阻塞的可能性,這對那些執行時間很短的代碼塊來說有非 常重要的性能提高。自旋鎖有個更貼切的名字:自旋-指數後退鎖,也即複合鎖。很顯然,自旋在多處理器上才有意義。

還有個問題是,線程自旋時做些啥?其實啥都不做,可以執行幾次for循環,可以執行幾條空的彙編指令,目的是佔著CPU不放,等待獲取鎖的機 會。所以說,自旋是把雙刃劍,如果旋的時間過長會影響整體性能,時間過短又達不到延遲阻塞的目的。顯然,自旋的週期選擇顯得非常重要,但這與操作系統、硬 件體系、系統的負載等諸多場景相關,很難選擇,如果選擇不當,不但性能得不到提高,可能還會下降,因此大家普遍認為自旋鎖不具有擴展性。

自旋優化策略

對自旋鎖週期的選擇上,HotSpot認為最佳時間應是一個線程上下文切換的時間,但目前並沒有做到。經過調查,目前只是通過彙編暫停了幾個CPU週期,除了自旋週期選擇,HotSpot還進行許多其他的自旋優化策略,具體如下:

如果平均負載小於CPUs則一直自旋

如果有超過(CPUs/2)個線程正在自旋,則後來線程直接阻塞

如果正在自旋的線程發現Owner發生了變化則延遲自旋時間(自旋計數)或進入阻塞

如果CPU處於節電模式則停止自旋

自旋時間的最壞情況是CPU的存儲延遲(CPU A存儲了一個數據,到CPU B得知這個數據直接的時間差)

自旋時會適當放棄線程優先級之間的差異

那synchronized實現何時使用了自旋鎖?答案是在線程進入ContentionList時,也即第一步操作前。線程在進入等待隊列時 首先進行自旋嘗試獲得鎖,如果不成功再進入等待隊列。這對那些已經在等待隊列中的線程來說,稍微顯得不公平。還有一個不公平的地方是自旋線程可能會搶佔了 Ready線程的鎖。自旋鎖由每個監視對象維護,每個監視對象一個。

synchronized原理

Java 虛擬機中的同步(Synchronization)是基於進入和退出Monitor對象實現, 無論是顯式同步(有明確的 monitorenter 和 monitorexit 指令,即同步代碼塊)還是隱式同步都是如此。在 Java 語言中,同步用的最多的地方可能是被 synchronized 修飾的同步方法。同步方法並不是由monitorenter 和 monitorexit 指令來實現同步的,而是由方法調用指令讀取運行時常量池中方法的 ACC_SYNCHRONIZED 標誌來隱式實現的,關於這點,稍後詳細分析。下面先來了解一個概念Java對象頭,這對深入理解synchronized實現原理非常關鍵。

我們對如下同步代碼塊進行javap反編譯:

1 public void add(Object obj){
2 synchronized (obj){
3 //do something
4 }
5 }

反編譯後的代碼如下:

 1public class com.wuzy.thread.SynchronizedDemo {
2 public com.wuzy.thread.SynchronizedDemo();
3 Code:
4 0: aload_0
5 1: invokespecial #1 // Method java/lang/Object."<init>":()V
6 4: return
7
8 public void add(java.lang.Object);
9 Code:
10 0: aload_1
11 1: dup
12 2: astore_2
13 3: monitorenter //注意此處,進入同步方法

14 4: aload_2
15 5: monitorexit //注意此處,退出同步方法
16 6: goto 14
17 9: astore_3
18 10: aload_2
19 11: monitorexit //注意此處,退出同步方法
20 12: aload_3
21 13: athrow
22 14: return
23 Exception table:
24 from to target type
25 4 6 9 any
26 9 12 9 any
27}
/<init>

我們看下第13行~15行代碼,發現同步代碼塊是使用monitorenter和monitorexit指令來進行代碼同步的,注意看第19行代碼,為什麼會多出一個monitorexit指令,主要是JVM為了防止代碼出現異常,也能正確退出同步方法。

接下來我們將同步整個方法進行反編譯一下:

1 public synchronized void update(){
2
3 }

反編譯後的代碼如下:

 1public class com.wuzy.thread.SynchronizedDemo {
2 public com.wuzy.thread.SynchronizedDemo();
3 Code:
4 0: aload_0
5 1: invokespecial #1 // Method java/lang/Object."<init>":()V
6 4: return
7
8 public synchronized void update();
9 Code:
10 0: return
11}

/<init>

從反編譯的代碼看,同步方法並不是用monitorenter和monitorexit指令來進行同步的,實際上同步方法會被翻譯成普通的方法調用和返回指令如:invokevirtual、areturn指令,在VM字節碼層面並沒有任何特別的指令來實現被synchronized修飾的方法,而是在Class文件的方法表中將該方法的access_flags字段中的synchronized標誌位置設為1,表示該方法是同步方法並使用調用該方法的對象或該方法所屬的Class在JVM的內部對象表示做為鎖對象。

1、Java對象頭

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

深入理解synchronized底層實現原理

這裡對這幾個概念做簡要描述:

  1. 實例變量:
  2. 存放類的屬性數據信息,包括父類的屬性信息,如果是數組的實例部分還包括數組的長度,這部分內存按4字節對齊。
  3. 填充數據:
  4. 由於虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是為了字節對齊,這點了解即可。
  5. 對象頭:
  6. 對象頭實現synchronized鎖對象的基礎,這點我們重點分析它,一般而言,synchronized使用的鎖對象是存儲在Java對象頭裡的,jvm中採用2個字來存儲對象頭(如果對象是數組則會分配3個字,多出來的1個字記錄的是數組長度),其主要結構是由Mark Word 和 Class Metadata Address 組成,其結構說明如下表:
深入理解synchronized底層實現原理

其中Mark Word在默認情況下存儲著對象的HashCode、分代年齡、鎖標記位等,以下是32位JVM的Mark Word默認存儲結構:

深入理解synchronized底層實現原理

由於對象頭的信息是與對象自身定義的數據沒有關係的額外存儲成本,因此考慮到JVM的空間效率,Mark Word 被設計成為一個非固定的數據結構,以便存儲更多有效的數據,它會根據對象本身的狀態複用自己的存儲空間,如32位JVM下,除了上述列出的Mark Word默認存儲結構外,還有如下可能變化的結構:

深入理解synchronized底層實現原理

在64位虛擬機下,Mark Word是64bit大小的,其存儲結構如下:

深入理解synchronized底層實現原理

2. Monitor Record

Monitor Record是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被鎖住的對象都會和一個monitor record關聯(對象頭的MarkWord中的LockWord指向monitor record的起始地址),同時monitor record中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用。如下所示為Monitor Record的內部結構:

深入理解synchronized底層實現原理

下面對這幾個概念做下解釋:

  1. Owner:
  2. 初始時為NULL表示當前沒有任何線程擁有該monitor record,當線程成功擁有該鎖後保存線程唯一標識,當鎖被釋放時又設置為NULL
  3. EntryQ:
  4. 關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住monitor record失敗的線程。
  5. RcThis:
  6. 表示blocked或waiting在該monitor record上的所有線程的個數。
  7. Nest:
  8. 用來實現重入鎖的計數
  9. HashCode:
  10. 保存從對象頭拷貝過來的HashCode值(可能還包含GC age)
  11. Candidate:
  12. 用來避免不必要的阻塞或等待線程喚醒,因為每一次只有一個線程能夠成功擁有鎖,如果每次前一個釋放鎖的線程喚醒所有正在阻塞或等待的線程,會引起不必要的上下文切換(從阻塞到就緒然後因為競爭鎖失敗又被阻塞)從而導致性能嚴重下降。Candidate只有兩種可能的值,0表示沒有需要喚醒的線程,1表示要喚醒一個繼任線程來競爭鎖。

JDK 對鎖進行了哪些優化?

簡單來說在JVM中monitorenter和monitorexit字節碼依賴於底層的操作系統的Mutex Lock來實現的,但是由於使用Mutex Lock需要將當前線程掛起並從用戶態切換到內核態來執行,這種切換的代價是非常昂貴的;然而在現實中的大部分情況下,同步方法是運行在單線程環境(無鎖競爭環境)如果每次都調用Mutex Lock那麼將嚴重的影響程序的性能。不過在jdk1.6中對鎖的實現引入了大量的優化,如鎖粗化(Lock Coarsening)、鎖消除(Lock Elimination)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)、適應性自旋(Adaptive Spinning)等技術來減少鎖操作的開銷。

  1. 鎖粗化(Lock Coarsening):也就是減少不必要的緊連在一起的unlock,lock操作,將多個連續的鎖擴展成一個範圍更大的鎖。
  2. 鎖消除(Lock Elimination):通過運行時JIT編譯器的逃逸分析來消除一些沒有在當前同步塊以外被其他線程共享的數據的鎖保護,通過逃逸分析也可以在線程本地Stack上進行對象空間的分配(同時還可以減少Heap上的垃圾收集開銷)。
  3. 輕量級鎖(Lightweight Locking):這種鎖實現的背後基於這樣一種假設,即在真實的情況下我們程序中的大部分同步代碼一般都處於無鎖競爭狀態(即單線程執行環境),在無鎖競爭的情況下完全可以避免調用操作系統層面的重量級互斥鎖,取而代之的是在monitorenter和monitorexit中只需要依靠一條CAS原子指令就可以完成鎖的獲取及釋放。當存在鎖競爭的情況下,執行CAS指令失敗的線程將調用操作系統互斥鎖進入到阻塞狀態,當鎖被釋放的時候被喚醒(具體處理步驟下面詳細討論)。
  4. 偏向鎖(Biased Locking):是為了在無鎖競爭的情況下避免在鎖獲取過程中執行不必要的CAS原子指令,因為CAS原子指令雖然相對於重量級鎖來說開銷比較小但還是存在非常可觀的本地延遲。
  5. 適應性自旋(Adaptive Spinning):當線程在獲取輕量級鎖的過程中執行CAS操作失敗時,在進入與monitor相關聯的操作系統重量級鎖(mutex semaphore)前會進入忙等待(Spinning)然後再次嘗試,當嘗試一定的次數後如果仍然沒有成功則調用與該monitor關聯的semaphore(即互斥鎖)進入到阻塞狀態。

鎖的升級

JDK1.6為了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,所以在JDK1.6裡鎖一共有四種狀態,無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態,它會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率.

深入理解synchronized底層實現原理

1、偏向鎖

Hotspot的作者經過以往的研究發現大多數情況下鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裡存儲鎖偏向的線程ID,以後該線程在進入和退出同步塊時不需要花費CAS操作來加鎖和解鎖,而只需簡單的測試一下對象頭的Mark Word裡是否存儲著指向當前線程的偏向鎖,如果測試成功,表示線程已經獲得了鎖,如果測試失敗,則需要再測試下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖),如果沒有設置,則使用CAS競爭鎖,如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

偏向鎖的撤銷:偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否活著,如果線程不處於活動狀態,則將對象頭設置成無鎖狀態,如果線程仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼重新偏向於其他線程,要麼恢復到無鎖或者標記對象不適合作為偏向鎖,最後喚醒暫停的線程。下圖中的線程1演示了偏向鎖初始化的流程,線程2演示了偏向鎖撤銷的流程。

深入理解synchronized底層實現原理

關閉偏向鎖:偏向鎖在Java 6和Java 7裡是默認啟用的,但是它在應用程序啟動幾秒鐘之後才激活,如有必要可以使用JVM參數來關閉延遲-XX:BiasedLockingStartupDelay = 0。如果你確定自己應用程序裡所有的鎖通常情況下處於競爭狀態,可以通過JVM參數關閉偏向鎖-XX:-UseBiasedLocking=false,那麼默認會進入輕量級鎖狀態。

膨脹過程:當前線程執行CAS獲取偏向鎖失敗(這一步是偏向鎖的關鍵),表示在該鎖對象上存在競爭並且這個時候另外一個線程獲得偏向鎖所有權。當到達全局安全點(safepoint)時獲得偏向鎖的線程被掛起,並從偏向鎖所有者的私有Monitor Record列表中獲取一個空閒的記錄,並將Object設置LightWeight Lock狀態並且Mark Word中的LockRecord指向剛才持有偏向鎖線程的Monitor record,最後被阻塞在安全點的線程被釋放,進入到輕量級鎖的執行路徑中,同時被撤銷偏向鎖的線程繼續往下執行同步代碼。

2. 輕量級鎖

輕量級鎖加鎖:線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word複製到鎖記錄中,官方稱為Displaced Mark Word。然後線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。

輕量級鎖解鎖:輕量級解鎖時,會使用原子的CAS操作來將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖。

深入理解synchronized底層實現原理

不同鎖之間的比較

  1. 偏向鎖:

優點:加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距。

缺點:如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗。

適用場景:適用於只有一個線程訪問同步塊場景。

2. 輕量級鎖:

優點:競爭的線程不會阻塞,提高了程序的響應速度。

缺點:如果始終得不到鎖競爭的線程使用自旋會消耗CPU。

適用場景:追求響應時間。同步塊執行速度非常快。

3. 重量級鎖:

優點:線程競爭不使用自旋,不會消耗CPU。

缺點:線程阻塞,響應時間緩慢。

適用場景:追求吞吐量。同步塊執行速度較長。

參考文檔:

  • JVM規範(Java SE 7)
  • Java語言規範(JAVA SE7)
  • 周志明的《深入理解Java虛擬機》
  • Java偏向鎖實現原理


分享到:


相關文章: