synchronized 底層原理(嚼碎了喂版)
先說一下心得吧,我們知道硬軟不分家,在學(xué)習(xí)底層原理的時候我們不需要死扣到底,沒必要把硬件方面全吃透,點到為止,學(xué)到能夠幫助理解代碼即可,我們的目標是寫出高性能的代碼,而不是創(chuàng)造出硬軟一體化高性能套件。不要一學(xué)底層就一股子牛勁死磕,至少我們現(xiàn)在不應(yīng)該這樣,莫要本末倒置。(好吧其實是我在學(xué)的時候有點轉(zhuǎn)牛角尖了,一直問ai問題,仿佛是想把整個計算機領(lǐng)域吃透一般)希望這篇文章對大家有幫助,認真看完哦!歡迎指出理解有誤的地方!??!
先看看 Java 中 new 一個對象會有哪些信息被創(chuàng)建出來
在 HotSpot 虛擬機中,一個對象在堆內(nèi)存的存儲布局可以劃分為三部分(以 64 位操作系統(tǒng)為例,不用在意 32 位下的情況,他會被淘汰..) :
對象頭:
實例數(shù)據(jù):存類中聲明的成員信息,如 int a = 2(占 4 字節(jié))
對其填充:對象存儲必須按 8 字節(jié)對齊(64 位系統(tǒng)的默認配置),如果 對象頭+實例數(shù)據(jù) 所占的 bit 不是 8 的倍數(shù),如為 65bit(隨便舉例),那么只這個部分會填充 7bit,變?yōu)?72bit(9 字節(jié))。目的是:提高內(nèi)存訪問效率:CPU 讀取內(nèi)存時,對齊的數(shù)據(jù)能減少總線周期(如 64 位 CPU 一次讀 8 字節(jié)),避免偽共享(False Sharing):對齊后,不同對象不會共享同一緩存行。(硬件知識)
再來看看 synchronized 到底是什么模樣
人人都說 sy 重,重在哪里呢?
重量級鎖的實現(xiàn)是基于 monitor 機制的,那就先談?wù)?Monitor(管程):
他是操作系統(tǒng)層面的一個東西,提供了一種結(jié)構(gòu)化的方式來管理共享數(shù)據(jù)和并發(fā)訪問,其核心組成為 互斥鎖與條件變量,是由操作系統(tǒng)的一些指令來控制的(這里不過多展開,因為我不會)
再說說 JVM 層面的具體實現(xiàn):
JVM 中的 Monitor 是通過 C++實現(xiàn)的 ObjectMonitor 對象,當升級為重量級鎖時底層會創(chuàng)建這個對象,并把它的地址放在對應(yīng) Java 對象的 mark word 中(再去看看上面的圖),根據(jù)這個地址進行之后的一系列操作,字段有:
-
_owner
: 指向當前持有鎖的線程。 -
_count
: 記錄鎖的重入次數(shù)。 -
_EntryList
: 等待獲取鎖的線程隊列(阻塞隊列)。 -
_WaitSet
: 調(diào)用wait()
方法后進入等待狀態(tài)的線程隊列。
再來說說為什么 sy 重:
在獲取、釋放等鎖相關(guān)的操作時,本質(zhì)上都是操作這個 ObjectMonitor 對象,線程的阻塞、喚醒、調(diào)度都是操作系統(tǒng)的職責(zé),必須通過操作系統(tǒng)內(nèi)核來完成(調(diào)用內(nèi)核中的底層方法)這就牽扯到了從用戶態(tài)到內(nèi)核態(tài)的切換
用戶態(tài):
-
用戶態(tài)的程序沒有權(quán)限直接操作其他線程的狀態(tài)(如從運行狀態(tài)切換到阻塞狀態(tài))。
-
用戶態(tài)程序也無法直接訪問和修改操作系統(tǒng)的線程調(diào)度隊列。
-
這些操作涉及到對底層系統(tǒng)資源的訪問和管理,是操作系統(tǒng)的核心功能。
而這個切換是非常銷毀后資源的,會執(zhí)行很多指令來完成這項操作
可以粗略的認為:用戶態(tài)是 CPU 執(zhí)行應(yīng)用程序代碼(如 Java 字節(jié)碼、Python 解釋器代碼),而內(nèi)核態(tài)是 CPU 執(zhí)行操作系統(tǒng)內(nèi)核代碼(如文件讀寫,內(nèi)存分配等底層操作),但要注意的是 用戶態(tài)和內(nèi)核態(tài)的切換本質(zhì)是 CPU 特權(quán)級別的變化,而不是僅僅是“誰在運行代碼”,所以說可以“粗略認為”,幫助理解即可
-
系統(tǒng)調(diào)用是用戶態(tài)程序進入內(nèi)核態(tài)的唯一方式。
-
進入內(nèi)核態(tài)需要保存當前用戶線程的上下文(寄存器狀態(tài)、程序計數(shù)器等,記錄執(zhí)行到了哪里方便下次回來接著執(zhí)行),然后切換到內(nèi)核的代碼執(zhí)行。
-
從內(nèi)核態(tài)返回用戶態(tài)時,需要恢復(fù)用戶線程的上下文。
-
這個保存和恢復(fù)上下文的過程就是 上下文切換 (Context Switch),它是有開銷的,通常比用戶態(tài)的指令執(zhí)行慢幾個數(shù)量級。(注意這里討論的是用戶態(tài)和內(nèi)核態(tài)的上下文切換,有人可能會想到線程的上下文切換,他們不是同一個概念,但相同點是都會造成額外的開銷)
再來說說重量級鎖的操作流程:
當一個線程嘗試獲取一個對象的 Monitor 時:
-
如果
_owner
為空,線程成功獲取鎖,設(shè)置_owner
為自身,_count
為 1。 -
如果
_owner
是當前線程,_count
加 1(重入)。 -
如果
_owner
是其他線程,當前線程進入_EntryList
阻塞等待。
當一個線程釋放 Monitor 時:
-
_count
減 1。 -
如果
_count
變?yōu)?0,釋放鎖,_owner
置空。 -
然后從
_EntryList
或_WaitSet
中喚醒一個或多個線程,讓它們有機會競爭鎖。
這里插播一下線程的幾個重要的狀態(tài)(操作系統(tǒng)層面)
BLOCKED、WAITTING(TIME)都不會消耗 CPU 資源,在進入這個狀態(tài)時會自動釋放占用的資源
只有 RUNNING 才會消耗資源
而 RUNNABLE 知識代表這個線程可以開始干活了,但是還沒有活干,等待 CPU 時間片分給他活,是不消耗資源的
而在 Java 層面
沒有 RUNNING 狀態(tài)
RUNNABLE 狀態(tài)就包含了 RUNNABLE 與 RUNNING
所以說,sy 重的核心原因是:線程的阻塞、喚醒和調(diào)度是操作系統(tǒng)的職責(zé),必須通過內(nèi)核來完成。
既然我們知道了導(dǎo)致 sy 重的原因是線程阻塞引起的,解決的方法當然就是不讓他阻塞咯,那么怎么讓他不阻塞捏?
無鎖化編程 CAS 應(yīng)運而生,挑起了重擔(dān),他通過讓線程自旋嘗試獲取鎖的方法來規(guī)避去阻塞等待的方式。
有人可能會問:CAS 自旋不是會造成 CPU 空轉(zhuǎn)嗎?這不也在浪費資源嗎?
是的,CAS 自旋消耗 CPU 資源,用戶態(tài)與內(nèi)核態(tài)之間的切換亦會浪費資源。但仍選擇優(yōu)化為 CAS 自旋的核心目的是 在“短時間鎖競爭”和“長時間鎖競爭”之間找到性能平衡(你想,如果在一個線程第一次嘗試獲取鎖失敗之后鎖立馬被釋放了,然而他卻去了阻塞隊列....這得多造孽呀,如果再堅持一下的話....或許我和她的結(jié)果就會不一樣了....??,這樣看適當自旋一下還是非常好的)
sy 采用的是先自旋,再阻塞的策略。(她一直不搭理我,我也不能一直舔吧....我也是有尊嚴的?。。?
“先自旋后阻塞”是一種 折中策略,通過 動態(tài)適應(yīng)鎖競爭情況,在 低延遲 和 高吞吐 之間取得平衡。
-
自旋:為短期鎖競爭優(yōu)化響應(yīng)速度。
-
阻塞:為長期鎖競爭優(yōu)化系統(tǒng)資源利用率。
一句話,先自旋,不行再阻塞(翻譯:先舔舔,不行咱就走唄,等著找下家~**)**
了解了整體思路,最后來看看 sy 中 偏向鎖、輕量級鎖以及重量級鎖的具體實現(xiàn):
按照我們上面的分析,產(chǎn)物應(yīng)該就是輕量級鎖(CAS)咯,我猜官方的想法是既然要走不阻塞這條路那就干脆極端一點來個無鎖判斷(偏向)得了,所以就又加了偏向鎖,干脆 CAS 操作都不做了,很徹底!
偏向鎖:
單線程競爭,當線程 A 第一次競爭到鎖時,通過修改 MarkWord 中的偏向線程 ID、偏向模式。如果不存在其他線程競爭,那么持有偏向鎖的線程將永遠不需要進行同步(JVM 不會執(zhí)行任何額外的同步操作(如 CAS、系統(tǒng)調(diào)用、內(nèi)核態(tài)切換等),而是直接允許線程訪問臨界區(qū)。) .
什么時候升級為輕量級鎖呢?
-
調(diào)用了對象的 hashCode,但偏向鎖的對象 MarkWord 中存儲的是線程 id,如果調(diào)用 hashCode 會導(dǎo)致偏向鎖被撤銷,(輕量級鎖會在鎖記錄中記錄 hashCode,重量級鎖會在 Monitor 中記錄 hashCode)
-
當有另外一個線程逐步來競爭鎖的時候,就不能再使用偏向鎖了,要升級為輕量級鎖,使用的是等到競爭出現(xiàn)才釋放鎖的機制
-
競爭線程嘗試 CAS 更新對象頭失敗,會等到全局安全點(此時偏向鎖對應(yīng)的 ThreadID 線程不會執(zhí)行任何代碼)撤銷偏向鎖,同時檢查持有偏向鎖的線程是否還在執(zhí)行:
-
第一個線程正在執(zhí)行 Synchronized 方法(處于同步塊),它還沒有執(zhí)行完,其他線程來搶奪,該偏向鎖會被取消掉并出現(xiàn)鎖升級,此時輕量級鎖由原來持有偏向鎖的線程持有,繼續(xù)執(zhí)行同步代碼塊,而正在競爭的線程會自動進入自旋等待獲得該輕量級鎖
-
第一個線程執(zhí)行完 Synchronized(退出同步塊),則將對象頭設(shè)置為無鎖狀態(tài)并撤銷偏向鎖,重新偏向。
-
題外話:Java15 以后逐步廢棄偏向鎖,需要手動開啟------->維護成本高
輕量級鎖
JVM 會為每個線程在當前線程的棧幀中創(chuàng)建用于存儲鎖記錄(Lock Record)的空間,官方稱為 DisplacedMarkWord。若一個線程獲得鎖時發(fā)現(xiàn)是輕量級鎖,會把鎖的 MarkWord 復(fù)制到自己的 DisplacedMarkWord 里面。然后線程嘗試用 CAS 將鎖的 MarkWord 替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示 MarkWord 已經(jīng)被替換成了其他線程的鎖記錄,說明在與其它線程競爭鎖,當前線程就嘗試使用自旋來獲取鎖。
如果是自己執(zhí)行了 synchronized 鎖重入,那么再添加一條 Lock Record 作為重入的計數(shù)
自旋 CAS:不斷嘗試去獲取鎖,能不升級就不往上捅,盡量不要阻塞(升級為重量級鎖)
輕量級鎖的釋放
在釋放鎖時,當前線程會使用 CAS 操作將 Displaced MarkWord 的內(nèi)容復(fù)制回鎖的 MarkWord 里面。如果沒有發(fā)生競爭,那么這個復(fù)制的操作會成功。如果有其他線程因為自旋多次導(dǎo)致輕量級鎖升級成了重量級鎖,那么 CAS 操作會失敗,此時會進入重量級鎖解鎖流程
自旋一定程度和次數(shù)(Java8 之后是自適應(yīng)自旋鎖------意味著自旋的次數(shù)不是固定不變的):
-
線程如果自旋成功了,那下次自旋的最大次數(shù)會增加,因為 JVM 認為既然上次成功了,那么這一次也大概率會成功
-
如果很少會自選成功,那么下次會減少自旋的次數(shù)甚至不自旋,避免 CPU 空轉(zhuǎn)
輕量鎖和偏向鎖的區(qū)別:
-
爭奪輕量鎖失敗時,自旋嘗試搶占鎖
-
輕量級鎖每次退出同步塊都需要釋放鎖,而偏向鎖是在競爭發(fā)生時才釋放鎖
重量級鎖
當線程嘗試獲取輕量級鎖失敗后,進入鎖膨脹,創(chuàng)建 ObjectMonitor 對象并將鎖中的 mark word 字段移入到該 Monitor 對象中,將該對象地址放入 mark word 中,接下來的流程在上文已經(jīng)講過了
鎖釋放時通過 Monitor 地址找到對象,將 owner 設(shè)置為 null,喚醒 EntyList 中 BLOCKED 線程去搶鎖
補充一下 wait/notify 原理:
·Owner 線程發(fā)現(xiàn)條件不滿足,調(diào)用 wait 方法,即可進入 WaitSet 變?yōu)?WAITING 狀態(tài)
·BLOCKED 和 WAITING 的線程都處于阻塞狀態(tài),不占用 CPU 時間片
·BLOCKED 線程會在 Owner 線程釋放鎖時喚醒
·WAITING 線程會在 Owner 線程調(diào)用 notify 或 notifyAll 時喚醒,但喚醒后并不意味著立刻獲得鎖,仍需進入
EntryList 重新競爭
完結(jié)撒花??
#synchronized##java#