死磕Synchronized底層實現(xiàn)

來源:http://suo.im/6h5g96
關(guān)于synchronized的底層實現(xiàn),網(wǎng)上有很多文章了。但是很多文章要么作者根本沒看代碼,僅僅是根據(jù)網(wǎng)上其他文章總結(jié)、照搬而成,難免有些錯誤;要么很多點都是一筆帶過,對于為什么這樣實現(xiàn)沒有一個說法,讓像我這樣的讀者意猶未盡。
本系列文章將對HotSpot的synchronized鎖實現(xiàn)進行全面分析,內(nèi)容包括偏向鎖、輕量級鎖、重量級鎖的加鎖、解鎖、鎖升級流程的原理及源碼分析,希望給在研究synchronized路上的同學(xué)一些幫助。
大概花費了兩周的實現(xiàn)看代碼(花費了這么久時間有些懺愧,主要是對C++、JVM底層機制、JVM調(diào)試以及匯編代碼不太熟),將synchronized涉及到的代碼基本都看了一遍,其中還包括在JVM中添加日志驗證自己的猜想,總的來說目前對synchronized這塊有了一個比較全面清晰的認(rèn)識,但水平有限,有些細節(jié)難免有些疏漏,還望請大家指正。
本篇文章將對synchronized機制做個大致的介紹,包括用以承載鎖狀態(tài)的對象頭、鎖的幾種形式、各種形式鎖的加鎖和解鎖流程、什么時候會發(fā)生鎖升級。需要注意的是本文旨在介紹背景和概念,在講述一些流程的時候,只提到了主要case,對于實現(xiàn)細節(jié)、運行時的不同分支都在后面的文章中詳細分析。
本人看的JVM版本是jdk8u,具體版本號以及代碼可以在這里看到。
http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683
一、synchronized簡介
Java中提供了兩種實現(xiàn)同步的基礎(chǔ)語義: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");
? ?}
}
當(dāng)SyncTest.java被編譯成class文件的時候,synchronized關(guān)鍵字和synchronized方法的字節(jié)碼略有不同,我們可以用javap -v 命令查看class文件對應(yīng)的JVM字節(jié)碼信息,部分信息如下:
{
?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標(biāo)記
? ?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關(guān)鍵字而言,javac在編譯時,會生成對應(yīng)的monitorenter和monitorexit指令分別對應(yīng)synchronized同步塊的進入和退出,有兩個monitorexit指令的原因是:為了保證拋異常的情況下也能釋放鎖,所以javac為同步代碼塊添加了一個隱式的try-finally,在finally中會調(diào)用monitorexit命令釋放鎖。而對于synchronized方法而言,javac為其生成了一個ACCSYNCHRONIZED關(guān)鍵字,在JVM進行方法調(diào)用時,發(fā)現(xiàn)調(diào)用的方法被ACCSYNCHRONIZED修飾,則會先嘗試獲得鎖。
在JVM底層,對于這兩種synchronized語義的實現(xiàn)大致相同,在后文中會選擇一種進行詳細分析。
因為本文旨在分析synchronized的實現(xiàn)原理,因此對于其使用的一些問題就不贅述了,不了解的朋友可以看看這篇文章。
https://blog.csdn.net/luoweifu/article/details/46613015
二、鎖的幾種形式
傳統(tǒng)的鎖(也就是下文要說的重量級鎖)依賴于系統(tǒng)的同步函數(shù),在linux上使用mutex互斥鎖,最底層實現(xiàn)依賴于futex,關(guān)于futex可以看這些文章,這些同步函數(shù)都涉及到用戶態(tài)和內(nèi)核態(tài)的切換、進程的上下文切換,成本較高。對于加了synchronized關(guān)鍵字但運行時并沒有多線程競爭,或兩個線程接近于交替執(zhí)行的情況,使用傳統(tǒng)鎖機制無疑效率是會比較低的。
https://github.com/farmerjohngit/myblog/issues/8
在JDK 1.6之前,synchronized只有傳統(tǒng)的鎖機制,因此給開發(fā)者留下了synchronized關(guān)鍵字相比于其他同步機制性能不好的印象。
在JDK 1.6引入了兩種新型鎖機制:偏向鎖和輕量級鎖,它們的引入是為了解決在沒有多線程競爭或基本沒有競爭的場景下因使用傳統(tǒng)鎖機制帶來的性能開銷問題。
在看這幾種鎖機制的實現(xiàn)前,我們先來了解下對象頭,它是實現(xiàn)多種鎖機制的基礎(chǔ)。
1.對象頭
因為在Java中任意對象都可以用作鎖,因此必定要有一個映射關(guān)系,存儲該對象以及其對應(yīng)的鎖信息(比如當(dāng)前哪個線程持有鎖,哪些線程在等待)。一種很直觀的方法是,用一個全局map,來存儲這個映射關(guān)系,但這樣會有一些問題:需要對map做線程安全保障,不同的synchronized之間會相互影響,性能差;另外當(dāng)同步對象較多時,該map可能會占用比較多的內(nèi)存。
所以最好的辦法是將這個映射關(guān)系存儲在對象頭中,因為對象頭本身也有一些hashcode、GC相關(guān)的數(shù)據(jù),所以如果能將鎖信息與這些信息共存在對象頭中就好了。
在JVM中,對象在內(nèi)存中除了本身的數(shù)據(jù)外還會有個對象頭,對于普通對象而言,其對象頭中有兩類信息:mark word和類型指針。另外對于數(shù)組而言還會有一份記錄數(shù)組長度的數(shù)據(jù)。
類型指針是指向該對象所屬類對象的指針,mark word用于存儲對象的HashCode、GC分代年齡、鎖狀態(tài)等信息。在32位系統(tǒng)上mark word長度為32字節(jié),64位系統(tǒng)上長度為64字節(jié)。為了能在有限的空間里存儲下更多的數(shù)據(jù),其存儲格式是不固定的,在32位系統(tǒng)上各狀態(tài)的格式如下:

可以看到鎖信息也是存在于對象的mark word中的。當(dāng)對象狀態(tài)為偏向鎖(biasable)時,mark word存儲的是偏向的線程ID;當(dāng)狀態(tài)為輕量級鎖(lightweight locked)時,mark word存儲的是指向線程棧中Lock Record的指針;當(dāng)狀態(tài)為重量級鎖(inflated)時,為指向堆中的monitor對象的指針。
2.重量級鎖
重量級鎖是我們常說的傳統(tǒng)意義上的鎖,其利用操作系統(tǒng)底層的同步機制去實現(xiàn)Java中的線程同步。
重量級鎖的狀態(tài)下,對象的mark word為指向一個堆中monitor對象的指針。
一個monitor對象包括這么幾個關(guān)鍵字段:cxq(下圖中的ContentionList),EntryList ,WaitSet,owner。
其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的鏈表結(jié)構(gòu),owner指向持有鎖的線程。?

當(dāng)一個線程嘗試獲得鎖時,如果該鎖已經(jīng)被占用,則會將該線程封裝成一個ObjectWaiter對象插入到cxq的隊列尾部,然后暫停當(dāng)前線程。當(dāng)持有鎖的線程釋放鎖前,會將cxq中的所有元素移動到EntryList中去,并喚醒EntryList的隊首線程。
如果一個線程在同步塊中調(diào)用了Object#wait方法,會將該線程對應(yīng)的ObjectWaiter從EntryList移除并加入到WaitSet中,然后釋放鎖。當(dāng)wait的線程被notify之后,會將對應(yīng)的ObjectWaiter從WaitSet移動到EntryList中。
以上只是對重量級鎖流程的一個簡述,其中涉及到的很多細節(jié),比如ObjectMonitor對象從哪來?釋放鎖時是將cxq中的元素移動到EntryList的尾部還是頭部?notfiy時,是將ObjectWaiter移動到EntryList的尾部還是頭部?
關(guān)于具體的細節(jié),會在重量級鎖的文章中分析。
3.輕量級鎖
JVM的開發(fā)者發(fā)現(xiàn)在很多情況下,在Java程序運行時,同步塊中的代碼都是不存在競爭的,不同的線程交替的執(zhí)行同步塊中的代碼。這種情況下,用重量級鎖是沒必要的。因此JVM引入了輕量級鎖的概念。
線程在執(zhí)行同步塊之前,JVM會先在當(dāng)前的線程的棧幀中創(chuàng)建一個Lock Record,其包括一個用于存儲對象頭中的 mark word(官方稱之為Displaced Mark Word)以及一個指向?qū)ο蟮闹羔?。下圖右邊的部分就是一個Lock Record。

加鎖過程:
1.在線程棧中創(chuàng)建一個Lock Record,將其obj(即上圖的Object reference)字段指向鎖對象。
2.直接通過CAS指令將Lock Record的地址存儲在對象頭的mark word中,如果對象處于無鎖狀態(tài)則修改成功,代表該線程獲得了輕量級鎖。如果失敗,進入到步驟3。
3.如果是當(dāng)前線程已經(jīng)持有該鎖了,代表這是一次鎖重入。設(shè)置Lock Record第一部分(Displaced Mark Word)為null,起到了一個重入計數(shù)器的作用。然后結(jié)束。
4.走到這一步說明發(fā)生了競爭,需要膨脹為重量級鎖。
解鎖過程:
1.遍歷線程棧,找到所有obj字段等于當(dāng)前鎖對象的Lock Record。
2.如果Lock Record的Displaced Mark Word為null,代表這是一次重入,將obj設(shè)置為null后continue。
3.如果Lock Record的Displaced Mark Word不為null,則利用CAS指令將對象頭的mark word恢復(fù)成為Displaced Mark Word。如果成功,則continue,否則膨脹為重量級鎖。
4.偏向鎖
Java是支持多線程的語言,因此在很多二方包、基礎(chǔ)庫中為了保證代碼在多線程的情況下也能正常運行,也就是我們常說的線程安全,都會加入如synchronized這樣的同步語義。但是在應(yīng)用在實際運行時,很可能只有一個線程會調(diào)用相關(guān)同步方法。比如下面這個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的修飾,但實際使用時卻只有一個線程調(diào)用到該方法,對于輕量級鎖而言,每次調(diào)用addString時,加鎖解鎖都有一個CAS操作;對于重量級鎖而言,加鎖也會有一個或多個CAS操作(這里的’一個‘、’多個‘?dāng)?shù)量詞只是針對該demo,并不適用于所有場景)。
在JDK1.6中為了提高一個對象在一段很長的時間內(nèi)都只被一個線程用做鎖對象場景下的性能,引入了偏向鎖,在第一次獲得鎖時,會有一個CAS操作,之后該線程再獲取鎖,只會執(zhí)行幾個簡單的命令,而不是開銷相對較大的CAS命令。我們來看看偏向鎖是如何做的。
對象創(chuàng)建
當(dāng)JVM啟用了偏向鎖模式(1.6以上默認(rèn)開啟),當(dāng)新創(chuàng)建一個對象的時候,如果該對象所屬的class沒有關(guān)閉偏向鎖模式(什么時候會關(guān)閉一個class的偏向模式下文會說,默認(rèn)所有class的偏向模式都是是開啟的),那新創(chuàng)建對象的mark word將是可偏向狀態(tài),此時mark word中的thread id(參見上文偏向狀態(tài)下的mark word格式)為0,表示未偏向任何線程,也叫做匿名偏向(anonymously biased)。
加鎖過程
case 1:當(dāng)該對象第一次被線程獲得鎖的時候,發(fā)現(xiàn)是匿名偏向狀態(tài),則會用CAS指令,將mark word中的thread id由0改成當(dāng)前線程Id。如果成功,則代表獲得了偏向鎖,繼續(xù)執(zhí)行同步塊中的代碼。否則,將偏向鎖撤銷,升級為輕量級鎖。
case 2:當(dāng)被偏向的線程再次進入同步塊時,發(fā)現(xiàn)鎖對象偏向的就是當(dāng)前線程,在通過一些額外的檢查后(細節(jié)見后面的文章),會往當(dāng)前線程的棧中添加一條Displaced Mark Word為空的Lock Record中,然后繼續(xù)執(zhí)行同步塊的代碼,因為操縱的是線程私有的棧,因此不需要用到CAS指令;由此可見偏向鎖模式下,當(dāng)被偏向的線程再次嘗試獲得鎖時,僅僅進行幾個簡單的操作就可以了,在這種情況下,synchronized關(guān)鍵字帶來的性能開銷基本可以忽略。
case 3.當(dāng)其他線程進入同步塊時,發(fā)現(xiàn)已經(jīng)有偏向的線程了,則會進入到撤銷偏向鎖的邏輯里,一般來說,會在safepoint中去查看偏向的線程是否還存活,如果存活且還在同步塊中則將鎖升級為輕量級鎖,原偏向的線程繼續(xù)擁有鎖,當(dāng)前線程則走入到鎖升級的邏輯里;如果偏向的線程已經(jīng)不存活或者不在同步塊中,則將對象頭的mark word改為無鎖狀態(tài)(unlocked),之后再升級為輕量級鎖。
由此可見,偏向鎖升級的時機為:當(dāng)鎖已經(jīng)發(fā)生偏向后,只要有另一個線程嘗試獲得偏向鎖,則該偏向鎖就會升級成輕量級鎖。當(dāng)然這個說法不絕對,因為還有批量重偏向這一機制。
解鎖過程
當(dāng)有其他線程嘗試獲得鎖時,是根據(jù)遍歷偏向線程的lock record來確定該線程是否還在執(zhí)行同步塊中的代碼。因此偏向鎖的解鎖很簡單,僅僅將棧中的最近一條lock record的obj字段設(shè)置為null。需要注意的是,偏向鎖的解鎖步驟中并不會修改對象頭中的thread id。
下圖展示了鎖狀態(tài)的轉(zhuǎn)換流程:?

另外,偏向鎖默認(rèn)不是立即就啟動的,在程序啟動后,通常有幾秒的延遲,可以通過命令 -XX:BiasedLockingStartupDelay=0來關(guān)閉延遲。
批量重偏向與撤銷
從上文偏向鎖的加鎖解鎖過程中可以看出,當(dāng)只有一個線程反復(fù)進入同步塊時,偏向鎖帶來的性能開銷基本可以忽略,但是當(dāng)有其他線程嘗試獲得鎖時,就需要等到safe point時將偏向鎖撤銷為無鎖狀態(tài)或升級為輕量級/重量級鎖。safe point這個詞我們在GC中經(jīng)常會提到,其代表了一個狀態(tài),在該狀態(tài)下所有線程都是暫停的(大概這么個意思),詳細可以看這篇文章??傊?,偏向鎖的撤銷是有一定成本的,如果說運行時的場景本身存在多線程競爭的,那偏向鎖的存在不僅不能提高性能,而且會導(dǎo)致性能下降。因此,JVM中增加了一種批量重偏向/撤銷的機制。
https://blog.csdn.net/ITer_ZC/article/details/41892567
存在如下兩種情況:(見官方論文第4小節(jié)):
https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf
1.一個線程創(chuàng)建了大量對象并執(zhí)行了初始的同步操作,之后在另一個線程中將這些對象作為鎖進行之后的操作。這種case下,會導(dǎo)致大量的偏向鎖撤銷操作。
2.存在明顯多線程競爭的場景下使用偏向鎖是不合適的,例如生產(chǎn)者/消費者隊列。
批量重偏向(bulk rebias)機制是為了解決第一種場景。批量撤銷(bulk revoke)則是為了解決第二種場景。
其做法是:以class為單位,為每個class維護一個偏向鎖撤銷計數(shù)器,每一次該class的對象發(fā)生偏向撤銷操作時,該計數(shù)器+1,當(dāng)這個值達到重偏向閾值(默認(rèn)20)時,JVM就認(rèn)為該class的偏向鎖有問題,因此會進行批量重偏向。每個class對象會有一個對應(yīng)的epoch字段,每個處于偏向鎖狀態(tài)對象的mark word中也有該字段,其初始值為創(chuàng)建該對象時,class中的epoch的值。每次發(fā)生批量重偏向時,就將該值+1,同時遍歷JVM中所有線程的棧,找到該class所有正處于加鎖狀態(tài)的偏向鎖,將其epoch字段改為新值。下次獲得鎖時,發(fā)現(xiàn)當(dāng)前對象的epoch值和class的epoch不相等,那就算當(dāng)前已經(jīng)偏向了其他線程,也不會執(zhí)行撤銷操作,而是直接通過CAS操作將其mark word的Thread Id 改成當(dāng)前線程Id。
當(dāng)達到重偏向閾值后,假設(shè)該class計數(shù)器繼續(xù)增長,當(dāng)其達到批量撤銷的閾值后(默認(rèn)40),JVM就認(rèn)為該class的使用場景存在多線程競爭,會標(biāo)記該class為不可偏向,之后,對于該class的鎖,直接走輕量級鎖的邏輯。
三、總結(jié)
Java中的synchronized有偏向鎖、輕量級鎖、重量級鎖三種形式,分別對應(yīng)了鎖只被一個線程持有、不同線程交替持有鎖、多線程競爭鎖三種情況。當(dāng)條件不滿足時,鎖會按偏向鎖->輕量級鎖->重量級鎖 的順序升級。JVM種的鎖也是能降級的,只不過條件很苛刻,不在我們討論范圍之內(nèi)。該篇文章主要是對Java的synchronized做個基本介紹,后文會有更詳細的分析。


