天天看點

面試官:Synchronized 底層實作是怎樣的?我一臉懵逼。。

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

本系列文章将對HotSpot的synchronized鎖實作進行全面分析,内容包括偏向鎖、輕量級鎖、重量級鎖的加鎖、解鎖、鎖更新流程的原理及源碼分析,希望給在研究synchronized路上的同學一些幫助。

大概花費了兩周的實作看代碼(花費了這麼久時間有些忏愧,主要是對C++、JVM底層機制、JVM調試以及彙編代碼不太熟),将synchronized涉及到的代碼基本都看了一遍,其中還包括在JVM中添加日志驗證自己的猜想,總的來說目前對synchronized這塊有了一個比較全面清晰的認識,但水準有限,有些細節難免有些疏漏,還望請大家指正。

本篇文章将對synchronized機制做個大緻的介紹,包括用以承載鎖狀态的對象頭、鎖的幾種形式、各種形式鎖的加鎖和解鎖流程、什麼時候會發生鎖更新。需要注意的是本文旨在介紹背景和概念,在講述一些流程的時候,隻提到了主要case,對于實作細節、運作時的不同分支都在後面的文章中詳細分析。

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

http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683

一、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在編譯時,會生成對應的monitorenter和monitorexit指令分别對應synchronized同步塊的進入和退出,有兩個monitorexit指令的原因是:為了保證抛異常的情況下也能釋放鎖,是以javac為同步代碼塊添加了一個隐式的try-finally,在finally中會調用monitorexit指令釋放鎖。

而對于synchronized方法而言,javac為其生成了一個ACCSYNCHRONIZED關鍵字,在JVM進行方法調用時,發現調用的方法被ACCSYNCHRONIZED修飾,則會先嘗試獲得鎖。

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

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

https://blog.csdn.net/luoweifu/article/details/46613015

二、鎖的幾種形式

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

https://github.com/farmerjohngit/myblog/issues/8

在JDK 1.6之前,synchronized隻有傳統的鎖機制,是以給開發者留下了synchronized關鍵字相比于其他同步機制性能不好的印象。

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

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

1.對象頭

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

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

在JVM中,對象在記憶體中除了本身的資料外還會有個對象頭,對于普通對象而言,其對象頭中有兩類資訊:mark word和類型指針。另外對于數組而言還會有一份記錄數組長度的資料。

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

面試官:Synchronized 底層實作是怎樣的?我一臉懵逼。。

可以看到鎖資訊也是存在于對象的mark word中的。當對象狀态為偏向鎖(biasable)時,mark word存儲的是偏向的線程ID;當狀态為輕量級鎖(lightweight locked)時,mark word存儲的是指向線程棧中Lock Record的指針;當狀态為重量級鎖(inflated)時,為指向堆中的monitor對象的指針。

2.重量級鎖

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

重量級鎖的狀态下,對象的mark word為指向一個堆中monitor對象的指針。

一個monitor對象包括這麼幾個關鍵字段:cxq(下圖中的ContentionList),EntryList ,WaitSet,owner。

其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的連結清單結構,owner指向持有鎖的線程。

面試官:Synchronized 底層實作是怎樣的?我一臉懵逼。。

當一個線程嘗試獲得鎖時,如果該鎖已經被占用,則會将該線程封裝成一個ObjectWaiter對象插入到cxq的隊列尾部,然後暫停目前線程。當持有鎖的線程釋放鎖前,會将cxq中的所有元素移動到EntryList中去,并喚醒EntryList的隊首線程。

如果一個線程在同步塊中調用了Object#wait方法,會将該線程對應的ObjectWaiter從EntryList移除并加入到WaitSet中,然後釋放鎖。當wait的線程被notify之後,會将對應的ObjectWaiter從WaitSet移動到EntryList中。

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

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

3.輕量級鎖

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

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

面試官:Synchronized 底層實作是怎樣的?我一臉懵逼。。

加鎖過程:

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 Record的Displaced Mark Word為null,代表這是一次重入,将obj設定為null後continue。

3.如果Lock Record的Displaced Mark Word不為null,則利用CAS指令将對象頭的mark word恢複成為Displaced Mark Word。如果成功,則continue,否則膨脹為重量級鎖。

4.偏向鎖

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 record的obj字段設定為null。需要注意的是,偏向鎖的解鎖步驟中并不會修改對象頭中的thread id。

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

面試官:Synchronized 底層實作是怎樣的?我一臉懵逼。。

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

批量重偏向與撤銷

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

https://blog.csdn.net/ITer_ZC/article/details/41892567

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

https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

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的鎖,直接走輕量級鎖的邏輯。

三、總結

Java中的synchronized有偏向鎖、輕量級鎖、重量級鎖三種形式,分别對應了鎖隻被一個線程持有、不同線程交替持有鎖、多線程競争鎖三種情況。當條件不滿足時,鎖會按偏向鎖->輕量級鎖->重量級鎖 的順序更新。

JVM種的鎖也是能降級的,隻不過條件很苛刻,不在我們讨論範圍之内。該篇文章主要是對Java的synchronized做個基本介紹,後文會有更詳細的分析。另外,關注公衆号Java技術棧,在背景回複:面試,可以擷取我整理的 Java 系列面試題和答案,非常齊全。