開發

死磕Synchronized底層實現,面試你還怕什么?

廣告
廣告

微信掃一掃,分享到朋友圈

死磕Synchronized底層實現,面試你還怕什么?
0 0

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

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

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

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

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

synchronized簡介

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

當SyncTest.java被編譯成class文件的時候, synchronized 關鍵字和 synchronized 方法的字節碼略有不同,我們可以用javap -v  命令查看class文件對應的JVM字節碼信息,部分信息如下:

從上面的中文注釋處可以看到,對于 synchronized 關鍵字而言, javac 在編譯時,會生成對應的 monitorenter monitorexit 指令分別對應 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 長度為32bit,64位系統上長度為64bit。為了能在有限的空間里存儲下更多的數據,其存儲格式是不固定的,在32位系統上各狀態的格式如下:

《死磕Synchronized底層實現--概論》

可以看到鎖信息也是存在于對象的 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指向持有鎖的線程。

《死磕Synchronized底層實現--概論》

當一個線程嘗試獲得鎖時,如果該鎖已經被占用,則會將該線程封裝成一個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 

《死磕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,否則膨脹為重量級鎖。

偏向鎖

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

在這個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中增加了一種批量重偏向/撤銷的機制。

存在如下兩種情況:(見官方 論文 第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 做個基本介紹,后文會有更詳細的分析。

我還沒有學會寫個人說明!

ITPUB小喇叭第六期:開著新華三的超跑,看甲骨文30年沿途風景,手捧惠普全家桶為華為點贊!

上一篇

ORACLE11.2.0.4 RAC+ ASM安裝方法 (操作系統CENTOS7.6)

下一篇

你也可能喜歡

死磕Synchronized底層實現,面試你還怕什么?

長按儲存圖像,分享給朋友

ITPUB 每周精要將以郵件的形式發放至您的郵箱


微信掃一掃

微信掃一掃
重庆时时后一8码方法