死磕Synchronized底層實現--概論

做個好人君發表於2018-11-28

關於synchronized的底層實現,網上有很多文章了。但是很多文章要麼作者根本沒看程式碼,僅僅是根據網上其他文章總結、照搬而成,難免有些錯誤;要麼很多點都是一筆帶過,對於為什麼這樣實現沒有一個說法,讓像我這樣的讀者意猶未盡。

本系列文章將對HotSpot的synchronized鎖實現進行全面分析,內容包括偏向鎖、輕量級鎖、重量級鎖的加鎖、解鎖、鎖升級流程的原理及原始碼分析,希望給在研究synchronized路上的同學一些幫助。主要包括以下幾篇文章:

死磕Synchronized底層實現--概論

死磕Synchronized底層實現--偏向鎖

死磕Synchronized底層實現--輕量級鎖

死磕Synchronized底層實現--重量級鎖

更多文章見個人部落格:github.com/farmerjohng…

大概花費了兩週的實現看程式碼(花費了這麼久時間有些懺愧,主要是對C++、JVM底層機制、JVM除錯以及彙編程式碼不太熟),將synchronized涉及到的程式碼基本都看了一遍,其中還包括在JVM中新增日誌驗證自己的猜想,總的來說目前對synchronized這塊有了一個比較全面清晰的認識,但水平有限,有些細節難免有些疏漏,還望請大家指正。

本篇文章將對synchronized機制做個大致的介紹,包括用以承載鎖狀態的物件頭、鎖的幾種形式、各種形式鎖的加鎖和解鎖流程、什麼時候會發生鎖升級。需要注意的是本文旨在介紹背景和概念,在講述一些流程的時候,只提到了主要case,對於實現細節、執行時的不同分支都在後面的文章中詳細分析

本人看的JVM版本是jdk8u,具體版本號以及程式碼可以在這裡看到。

synchronized簡介

Java中提供了兩種實現同步的基礎語義:synchronized方法和synchronized塊, 我們來看個demo:

public class SyncTest {
    public void syncBlock(){
        synchronized (this){
            System.out.println("hello block");
        }
    }
    public synchronized void syncMethod(){
        System.out.println("hello method");
    }
}
複製程式碼

當SyncTest.java被編譯成class檔案的時候,synchronized關鍵字和synchronized方法的位元組碼略有不同,我們可以用javap -v 命令檢視class檔案對應的JVM位元組碼資訊,部分資訊如下:

{
  public void syncBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter				 	  // monitorenter指令進入同步塊
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String hello block
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit						  // monitorexit指令退出同步塊
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit						  // monitorexit指令退出同步塊
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
 

  public synchronized void syncMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED      //新增了ACC_SYNCHRONIZED標記
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String hello method
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
 
}

複製程式碼

從上面的中文註釋處可以看到,對於synchronized關鍵字而言,javac在編譯時,會生成對應的monitorentermonitorexit指令分別對應synchronized同步塊的進入和退出,有兩個monitorexit指令的原因是:為了保證拋異常的情況下也能釋放鎖,所以javac為同步程式碼塊新增了一個隱式的try-finally,在finally中會呼叫monitorexit命令釋放鎖。而對於synchronized方法而言,javac為其生成了一個ACC_SYNCHRONIZED關鍵字,在JVM進行方法呼叫時,發現呼叫的方法被ACC_SYNCHRONIZED修飾,則會先嚐試獲得鎖。

在JVM底層,對於這兩種synchronized語義的實現大致相同,在後文中會選擇一種進行詳細分析。

因為本文旨在分析synchronized的實現原理,因此對於其使用的一些問題就不贅述了,不瞭解的朋友可以看看這篇文章

鎖的幾種形式

傳統的鎖(也就是下文要說的重量級鎖)依賴於系統的同步函式,在linux上使用mutex互斥鎖,最底層實現依賴於futex,關於futex可以看我之前的文章,這些同步函式都涉及到使用者態和核心態的切換、程式的上下文切換,成本較高。對於加了synchronized關鍵字但執行時並沒有多執行緒競爭,或兩個執行緒接近於交替執行的情況,使用傳統鎖機制無疑效率是會比較低的。

在JDK 1.6之前,synchronized只有傳統的鎖機制,因此給開發者留下了synchronized關鍵字相比於其他同步機制效能不好的印象。

在JDK 1.6引入了兩種新型鎖機制:偏向鎖和輕量級鎖,它們的引入是為了解決在沒有多執行緒競爭或基本沒有競爭的場景下因使用傳統鎖機制帶來的效能開銷問題。

在看這幾種鎖機制的實現前,我們先來了解下物件頭,它是實現多種鎖機制的基礎。

物件頭

因為在Java中任意物件都可以用作鎖,因此必定要有一個對映關係,儲存該物件以及其對應的鎖資訊(比如當前哪個執行緒持有鎖,哪些執行緒在等待)。一種很直觀的方法是,用一個全域性map,來儲存這個對映關係,但這樣會有一些問題:需要對map做執行緒安全保障,不同的synchronized之間會相互影響,效能差;另外當同步物件較多時,該map可能會佔用比較多的記憶體。

所以最好的辦法是將這個對映關係儲存在物件頭中,因為物件頭本身也有一些hashcode、GC相關的資料,所以如果能將鎖資訊與這些資訊共存在物件頭中就好了。

在JVM中,物件在記憶體中除了本身的資料外還會有個物件頭,對於普通物件而言,其物件頭中有兩類資訊:mark word和型別指標。另外對於陣列而言還會有一份記錄陣列長度的資料。

型別指標是指向該物件所屬類物件的指標,mark word用於儲存物件的HashCode、GC分代年齡、鎖狀態等資訊。在32位系統上mark word長度為32位元組,64位系統上長度為64位元組。為了能在有限的空間裡儲存下更多的資料,其儲存格式是不固定的,在32位系統上各狀態的格式如下:

image

可以看到鎖資訊也是存在於物件的mark word中的。當物件狀態為偏向鎖(biasable)時,mark word儲存的是偏向的執行緒ID;當狀態為輕量級鎖(lightweight locked)時,mark word儲存的是指向執行緒棧中Lock Record的指標;當狀態為重量級鎖(inflated)時,為指向堆中的monitor物件的指標。

重量級鎖

重量級鎖是我們常說的傳統意義上的鎖,其利用作業系統底層的同步機制去實現Java中的執行緒同步。

重量級鎖的狀態下,物件的mark word為指向一個堆中monitor物件的指標。

一個monitor物件包括這麼幾個關鍵欄位:cxq(下圖中的ContentionList),EntryList ,WaitSet,owner。

其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的連結串列結構,owner指向持有鎖的執行緒。

1517900250327

當一個執行緒嘗試獲得鎖時,如果該鎖已經被佔用,則會將該執行緒封裝成一個ObjectWaiter物件插入到cxq的佇列尾部,然後暫停當前執行緒。當持有鎖的執行緒釋放鎖前,會將cxq中的所有元素移動到EntryList中去,並喚醒EntryList的隊首執行緒。

如果一個執行緒在同步塊中呼叫了Object#wait方法,會將該執行緒對應的ObjectWaiter從EntryList移除並加入到WaitSet中,然後釋放鎖。當wait的執行緒被notify之後,會將對應的ObjectWaiter從WaitSet移動到EntryList中。

以上只是對重量級鎖流程的一個簡述,其中涉及到的很多細節,比如ObjectMonitor物件從哪來?釋放鎖時是將cxq中的元素移動到EntryList的尾部還是頭部?notfiy時,是將ObjectWaiter移動到EntryList的尾部還是頭部?

關於具體的細節,會在重量級鎖的文章中分析。

輕量級鎖

JVM的開發者發現在很多情況下,在Java程式執行時,同步塊中的程式碼都是不存在競爭的,不同的執行緒交替的執行同步塊中的程式碼。這種情況下,用重量級鎖是沒必要的。因此JVM引入了輕量級鎖的概念。

執行緒在執行同步塊之前,JVM會先在當前的執行緒的棧幀中建立一個Lock Record,其包括一個用於儲存物件頭中的 mark word(官方稱之為Displaced Mark Word)以及一個指向物件的指標。下圖右邊的部分就是一個Lock Record

img

加鎖過程

1.線上程棧中建立一個Lock Record,將其obj(即上圖的Object reference)欄位指向鎖物件。

2.直接通過CAS指令將Lock Record的地址儲存在物件頭的mark word中,如果物件處於無鎖狀態則修改成功,代表該執行緒獲得了輕量級鎖。如果失敗,進入到步驟3。

3.如果是當前執行緒已經持有該鎖了,代表這是一次鎖重入。設定Lock Record第一部分(Displaced Mark Word)為null,起到了一個重入計數器的作用。然後結束。

4.走到這一步說明發生了競爭,需要膨脹為重量級鎖。

解鎖過程

1.遍歷執行緒棧,找到所有obj欄位等於當前鎖物件的Lock Record

2.如果Lock RecordDisplaced Mark Word為null,代表這是一次重入,將obj設定為null後continue。

3.如果Lock RecordDisplaced Mark Word不為null,則利用CAS指令將物件頭的mark word恢復成為Displaced Mark Word。如果成功,則continue,否則膨脹為重量級鎖。

偏向鎖

Java是支援多執行緒的語言,因此在很多二方包、基礎庫中為了保證程式碼在多執行緒的情況下也能正常執行,也就是我們常說的執行緒安全,都會加入如synchronized這樣的同步語義。但是在應用在實際執行時,很可能只有一個執行緒會呼叫相關同步方法。比如下面這個demo:

import java.util.ArrayList;
import java.util.List;

public class SyncDemo1 {

    public static void main(String[] args) {
        SyncDemo1 syncDemo1 = new SyncDemo1();
        for (int i = 0; i < 100; i++) {
            syncDemo1.addString("test:" + i);
        }
    }

    private List<String> list = new ArrayList<>();

    public synchronized void addString(String s) {
        list.add(s);
    }

}
複製程式碼

在這個demo中為了保證對list操縱時執行緒安全,對addString方法加了synchronized的修飾,但實際使用時卻只有一個執行緒呼叫到該方法,對於輕量級鎖而言,每次呼叫addString時,加鎖解鎖都有一個CAS操作;對於重量級鎖而言,加鎖也會有一個或多個CAS操作(這裡的’一個‘、’多個‘數量詞只是針對該demo,並不適用於所有場景)。

在JDK1.6中為了提高一個物件在一段很長的時間內都只被一個執行緒用做鎖物件場景下的效能,引入了偏向鎖,在第一次獲得鎖時,會有一個CAS操作,之後該執行緒再獲取鎖,只會執行幾個簡單的命令,而不是開銷相對較大的CAS命令。我們來看看偏向鎖是如何做的。

物件建立

當JVM啟用了偏向鎖模式(1.6以上預設開啟),當新建立一個物件的時候,如果該物件所屬的class沒有關閉偏向鎖模式(什麼時候會關閉一個class的偏向模式下文會說,預設所有class的偏向模式都是是開啟的),那新建立物件的mark word將是可偏向狀態,此時mark word中的thread id(參見上文偏向狀態下的mark word格式)為0,表示未偏向任何執行緒,也叫做匿名偏向(anonymously biased)。

加鎖過程

case 1:當該物件第一次被執行緒獲得鎖的時候,發現是匿名偏向狀態,則會用CAS指令,將mark word中的thread id由0改成當前執行緒Id。如果成功,則代表獲得了偏向鎖,繼續執行同步塊中的程式碼。否則,將偏向鎖撤銷,升級為輕量級鎖。

case 2:當被偏向的執行緒再次進入同步塊時,發現鎖物件偏向的就是當前執行緒,在通過一些額外的檢查後(細節見後面的文章),會往當前執行緒的棧中新增一條Displaced Mark Word為空的Lock Record中,然後繼續執行同步塊的程式碼,因為操縱的是執行緒私有的棧,因此不需要用到CAS指令;由此可見偏向鎖模式下,當被偏向的執行緒再次嘗試獲得鎖時,僅僅進行幾個簡單的操作就可以了,在這種情況下,synchronized關鍵字帶來的效能開銷基本可以忽略。

case 3.當其他執行緒進入同步塊時,發現已經有偏向的執行緒了,則會進入到撤銷偏向鎖的邏輯裡,一般來說,會在safepoint中去檢視偏向的執行緒是否還存活,如果存活且還在同步塊中則將鎖升級為輕量級鎖,原偏向的執行緒繼續擁有鎖,當前執行緒則走入到鎖升級的邏輯裡;如果偏向的執行緒已經不存活或者不在同步塊中,則將物件頭的mark word改為無鎖狀態(unlocked),之後再升級為輕量級鎖。

由此可見,偏向鎖升級的時機為:當鎖已經發生偏向後,只要有另一個執行緒嘗試獲得偏向鎖,則該偏向鎖就會升級成輕量級鎖。當然這個說法不絕對,因為還有批量重偏向這一機制。

解鎖過程

當有其他執行緒嘗試獲得鎖時,是根據遍歷偏向執行緒的lock record來確定該執行緒是否還在執行同步塊中的程式碼。因此偏向鎖的解鎖很簡單,僅僅將棧中的最近一條lock recordobj欄位設定為null。需要注意的是,偏向鎖的解鎖步驟中並不會修改物件頭中的thread id。

下圖展示了鎖狀態的轉換流程:

img

另外,偏向鎖預設不是立即就啟動的,在程式啟動後,通常有幾秒的延遲,可以通過命令 -XX:BiasedLockingStartupDelay=0來關閉延遲。

批量重偏向與撤銷

從上文偏向鎖的加鎖解鎖過程中可以看出,當只有一個執行緒反覆進入同步塊時,偏向鎖帶來的效能開銷基本可以忽略,但是當有其他執行緒嘗試獲得鎖時,就需要等到safe point時將偏向鎖撤銷為無鎖狀態或升級為輕量級/重量級鎖。safe point這個詞我們在GC中經常會提到,其代表了一個狀態,在該狀態下所有執行緒都是暫停的(大概這麼個意思),詳細可以看這篇文章。總之,偏向鎖的撤銷是有一定成本的,如果說執行時的場景本身存在多執行緒競爭的,那偏向鎖的存在不僅不能提高效能,而且會導致效能下降。因此,JVM中增加了一種批量重偏向/撤銷的機制。

存在如下兩種情況:(見官方論文第4小節):

1.一個執行緒建立了大量物件並執行了初始的同步操作,之後在另一個執行緒中將這些物件作為鎖進行之後的操作。這種case下,會導致大量的偏向鎖撤銷操作。

2.存在明顯多執行緒競爭的場景下使用偏向鎖是不合適的,例如生產者/消費者佇列。

批量重偏向(bulk rebias)機制是為了解決第一種場景。批量撤銷(bulk revoke)則是為了解決第二種場景。

其做法是:以class為單位,為每個class維護一個偏向鎖撤銷計數器,每一次該class的物件發生偏向撤銷操作時,該計數器+1,當這個值達到重偏向閾值(預設20)時,JVM就認為該class的偏向鎖有問題,因此會進行批量重偏向。每個class物件會有一個對應的epoch欄位,每個處於偏向鎖狀態物件的mark word中也有該欄位,其初始值為建立該物件時,class中的epoch的值。每次發生批量重偏向時,就將該值+1,同時遍歷JVM中所有執行緒的棧,找到該class所有正處於加鎖狀態的偏向鎖,將其epoch欄位改為新值。下次獲得鎖時,發現當前物件的epoch值和class的epoch不相等,那就算當前已經偏向了其他執行緒,也不會執行撤銷操作,而是直接通過CAS操作將其mark word的Thread Id 改成當前執行緒Id。

當達到重偏向閾值後,假設該class計數器繼續增長,當其達到批量撤銷的閾值後(預設40),JVM就認為該class的使用場景存在多執行緒競爭,會標記該class為不可偏向,之後,對於該class的鎖,直接走輕量級鎖的邏輯。

End

Java中的synchronized有偏向鎖、輕量級鎖、重量級鎖三種形式,分別對應了鎖只被一個執行緒持有、不同執行緒交替持有鎖、多執行緒競爭鎖三種情況。當條件不滿足時,鎖會按偏向鎖->輕量級鎖->重量級鎖 的順序升級。JVM種的鎖也是能降級的,只不過條件很苛刻,不在我們討論範圍之內。該篇文章主要是對Java的synchronized做個基本介紹,後文會有更詳細的分析。

相關文章