關於 Synchronized 的一個點,網上99%的文章都錯了

Synchronized 原理知道不?

而關於 Synchronized 我去年還專門翻閱 JVM HotSpot 1.8 的源碼來研究了一波,那時候我就發現有一個點,一個幾乎網上所有文章包括《Java併發編程的藝術》也是這樣說的一個點。

鎖升級想必網上有太多文章說過了,這裏提到當輕量級鎖 CAS 失敗,則當前線程會嘗試使用自旋來獲取鎖

其實起初我也是這樣認爲的,畢竟都是這樣說的,而且也很有道理。

因爲重量級鎖會阻塞線程,所以如果加鎖的代碼執行的非常快,那麼稍微自旋一會兒其他線程就不需要鎖了,就可以直接 CAS 成功了,因此不用阻塞了線程然後再喚醒。

但是我看了源碼之後發現並不是這樣的,這段代碼在 synchronizer.cpp 中。

所以 CAS 失敗了之後,並沒有什麼自旋操作,如果 CAS 成功就直接 return 了,如果失敗會執行下面的鎖膨脹方法。

我去鎖膨脹的代碼ObjectSynchronizer::inflate翻了翻,也沒看到自旋操作。

所以從源碼來看輕量級鎖 CAS 失敗並不會自旋而是直接膨脹成重量級鎖

不過爲了優化性能,自旋操作在 Synchronized 中確實卻有。

那是在已經升級成重量級鎖之後,線程如果沒有爭搶到鎖,會進行一段自旋等待鎖的釋放。

咱們還是看源碼說話,單單註釋其實就已經說得很清楚了:

畢竟阻塞線程入隊再喚醒開銷還是有點大的。

我們再來看看 TrySpin 的操作,這裏面有自適應自旋,其實從實際函數名就 TrySpin_VaryDuration 就可以反映出自旋是變化的。

至此,有關 Synchronized 自旋問題就完結了,重量級鎖競爭失敗會有自旋操作,輕量級鎖沒有這個動作(至少 1.8 源碼是這樣的),如果有人反駁你,請把這篇文章甩給他哈哈。

不過都說到這兒了,索性我就繼續講講 Synchronized 吧,畢竟這玩意出鏡率還是挺高的。

這篇文章關於 Synchronized 的深度到哪個程度呢?

之後如有面試官問你看過啥源碼?

看完這篇文章,你可以回答:我看過 JVM 的源碼

當然源碼有點多的,我把 Synchronized 相關的所有操作都過了一遍,還是有點難度的。

不過之前看過我的源碼分析的讀者就會知道,我都會畫個流程圖來整理的,所以即使代碼看不懂,流程還是可以搞清楚的!

好,發車!

從重量級鎖開始說起

Synchronized 在1.6 之前只是重量級鎖。

因爲會有線程的阻塞和喚醒,這個操作是藉助操作系統的系統調用來實現的,常見的 Linux 下就是利用 pthread 的 mutex 來實現的。

我截圖了調用線程阻塞的源碼,可以看到確實是利用了 mutex。

而涉及到系統調用就會有上下文的切換,即用戶態和內核態的切換,我們知道這種切換的開銷還是挺大的。

所以稱爲重量級鎖,也因爲這樣纔會有上面提到的自適應自旋操作,因爲不希望走到這一步呀!

我們來看看重量級鎖的實現原理

Synchronized 關鍵字可以修飾代碼塊,實例方法和靜態方法,本質上都是作用於對象上

代碼塊作用於括號裏面的對象,實例方法是當前的實例對象即 this ,而靜態方法就是當前的類。

這裏有個概念叫臨界區

我們知道,之所以會有競爭是因爲有共享資源的存在,多個線程都想要得到那個共享資源,所以就劃分了一個區域,操作共享資源資源的代碼就在區域內。

可以理解爲想要進入到這個區域就必須持有鎖,不然就無法進入,這個區域叫臨界區。

當用 Synchronized 修飾代碼塊時

此時編譯得到的字節碼會有 monitorenter 和 monitorexit 指令,我習慣按照臨界區來理解,enter 就是要進入臨界區了,exit 就是要退出臨界區了,與之對應的就是獲得鎖和解鎖。

實際上這兩個指令還是和修飾代碼塊的那個對象相關的,也就是上文代碼中的lockObject

每個對象都有一個 monitor 對象於之關聯,執行 monitorenter 指令的線程就是試圖去獲取 monitor 的所有權,搶到了就是成功獲取鎖了。

這個 monitor 下文會詳細分析,我們先看下生成的字節碼是怎樣的。

圖片上方是 lockObject 方法編譯得到的字節碼,下面就是 lockObject 方法,這樣對着看比較容易理解。

從截圖來看,執行 System.out 之前執行了 monitorenter 執行,這裏執行爭鎖動作,拿到鎖即可進入臨界區。

調用完之後有個 monitorexit 指令,表示釋放鎖,要出臨界區了。

圖中我還標了一個 monitorexit 指令時,因爲有異常的情況也需要解鎖,不然就死鎖了。

從生成的字節碼我們也可以得知,爲什麼 synchronized 不需要手動解鎖?

是有人在替我們負重前行啊!編譯器生成的字節碼都幫咱們做好了,異常的情況也考慮到了

當用 synchronized 修飾方法時

修飾方法生成的字節碼和修飾代碼塊的不太一樣,但本質上是一樣。

此時字節碼中沒有 monitorenter 和 monitorexit 指令,不過在當前方法的訪問標記上做了手腳。

我這裏用的是 idea 的插件來看字節碼,所以展示的字面結果不太一樣,不過 flag 標記是一樣的:0x0021 ,是 ACC_PUBLIC 和 ACC_SYNCHRONIZED 的結合。

原理就是修飾方法的時候在 flag 上標記 ACC_SYNCHRONIZED,在運行時常量池中通過 ACC_SYNCHRONIZED 標誌來區分,這樣 JVM 就知道這個方法是被 synchronized 標記的,於是在進入方法的時候就會進行執行爭鎖的操作,一樣只有拿到鎖才能繼續執行。

然後不論是正常退出還是異常退出,都會進行解鎖的操作,所以本質還是一樣的。

這裏還有個隱式的鎖對象就是我上面提到的,修飾實例方法就是 this,修飾類方法就是當前類(關於這點是有坑的,我寫的這篇文章分析過)。

我還記得有個面試題,好像是面字節跳動時候問的,面試官問 synchronized 修飾方法和代碼塊的時候字節碼層面有什麼區別?

怎麼說?不知不覺距離字節跳動又更近了呢。

我們再來繼續深入 synchronized

從上文我們已經知道 synchronized 是作用於對象身上的,但是沒細說,我們接下來剖析一波。

在 Java 中,對象結構分爲對象頭、實例數據和對齊填充。

而對象頭又分爲:MarkWord 、 klass pointer、數組長度(只有數組纔有),我們的重點是鎖,所以關注點只放在 MarkWord 上。

我再畫一下 64 位時 MarkWord 在不同狀態下的內存佈局(裏面的 monitor 打錯了,但是我不準備改,留個印記哈哈)。

MarkWord 結構之所以搞得這麼複雜,是因爲需要節省內存,讓同一個內存區域在不同階段有不同的用處。

記住這個圖啊,各種鎖操作都和這個 MarkWord 有很強的聯繫。

從圖中可以看到,在重量級鎖時,對象頭的鎖標記位爲 10,並且會有一個指針指向這個 monitor 對象,所以鎖對象和 monitor 兩者就是這樣關聯的。

而這個 monitor 在 HotSpot 中是 c++ 實現的,叫 ObjectMonitor,它是管程的實現,也有叫監視器的。

它長這樣,重點字段我都註釋了含義,還專門截了個頭文件的註釋:

暫時記憶一下,等下源碼和這幾個字段關聯很大。

synchronized 底層原理

先來一張圖,結合上面 monitor 的註釋,先看看,看不懂沒關係,有個大致流轉的印象即可:

好,我們繼續。

前面我們提到了 monitorenter 這個指令,這個指令會執行下面的代碼:

我們現在分析的是重量級鎖,所以不關心偏向的代碼,而 slow_enter 方法文章一開始的截圖就是了,最終會執行到 ObjectMonitor::enter 這個方法中。

可以看到重點就是通過 CAS 把 ObjectMonitor 中的 _owner 設置爲當前線程,設置成功就表示獲取鎖成功

然後通過 recursions 的自增來表示重入。

如果 CAS 失敗的話,會執行下面的一個循環:

EnterI 的代碼其實上面也已經截圖了,這裏再來一次,我把重要的入隊操作加上,並且刪除了一些不重要的代碼:

先再嘗試一下獲取鎖,不行的話就自適應自旋,還不行就包裝成 ObjectWaiter 對象加入到 _cxq 這個單向鏈表之中,掙扎一下還是沒搶到鎖的話,那麼就要阻塞了,所以下面還有個阻塞的方法。

可以看到不論哪個分支都會執行 Self->_ParkEvent->park(),這個就是上文提到的調用 pthread_mutex_lock

至此爭搶鎖的流程已經很清晰了,我再畫個圖來理一理。

接下來再看看解鎖的方法

ObjectMonitor::exit 就是解鎖時會調用的方法。

可重入鎖就是根據 _recursions 來判斷的,重入一次 _recursions++,解鎖一次 _recursions--,如果減到 0 說明需要釋放鎖了。

然後此時解鎖的線程還會喚醒之前等待的線程,這裏有好幾種模式,我們來看看。

如果 QMode == 2 && _cxq != NULL的時候:

如果QMode == 3 && _cxq != NULL的時候,我就截取了一部分代碼:

如果 QMode == 4 && _cxq != NULL的時候:

如果 QMode 不是 2 的話,最終會執行:

至此,解鎖的流程就完畢了!我再畫一波流程圖:

接下來再看看調用 wait 的方法

沒啥花頭,就是將當前線程加入到 _waitSet 這個雙向鏈表中,然後再執行 ObjectMonitor::exit 方法來釋放鎖。

接下來再看看調用 notify 的方法

也沒啥花頭,就是從 _waitSet 頭部拿節點,然後根據策略選擇是放在 cxq 還是 EntryList 的頭部或者尾部,並且進行喚醒。

至於 notifyAll 我就不分析了,一樣的,無非就是做了個循環,全部喚醒。

至此 synchronized 的幾個操作都齊活了,出去可以說自己深入研究過 synchronized 了。

現在再來看下這個圖,應該心裏很有數了。

爲什麼會有_cxq 和 _EntryList 兩個列表來放線程?

因爲會有多個線程會同時競爭鎖,所以搞了個 _cxq 這個單向鏈表基於 CAS 來 hold 住這些併發,然後另外搞一個 _EntryList 這個雙向鏈表,來在每次喚醒的時候搬遷一些線程節點,降低 _cxq 的尾部競爭。

引入自旋

synchronized 的原理大致應該都清晰了,我們也知道了底層會用到系統調用,會有較大的開銷,那思考一下該如何優化?

從小標題就已經知道了,方案就是自旋,文章開頭就已經說了,這裏再提一提。

自旋其實就是空轉 CPU,執行一些無意義的指令,目的就是不讓出 CPU 等待鎖的釋放

正常情況下鎖獲取失敗就應該阻塞入隊,但是有時候可能剛一阻塞,別的線程就釋放鎖了,然後再喚醒剛剛阻塞的線程,這就沒必要了。

所以在線程競爭不是很激烈的時候,稍微自旋一會兒,指不定不需要阻塞線程就能直接獲取鎖,這樣就避免了不必要的開銷,提高了鎖的性能。

但是自旋的次數又是一個難點,在競爭很激烈的情況,自旋就是在浪費 CPU,因爲結果肯定是自旋一會讓之後阻塞。

所以 Java 引入的是自適應自旋,根據上次自旋次數,來動態調整自旋的次數,這就叫結合歷史經驗做事

注意這是重量級鎖的步驟,別忘了文章開頭說的~

至此,synchronized 重量級鎖的原理應該就很清晰了吧? 小結一下

synchronized 底層是利用 monitor 對象,CAS 和 mutex 互斥鎖來實現的,內部會有等待隊列(cxq 和 EntryList)和條件等待隊列(waitSet)來存放相應阻塞的線程。

未競爭到鎖的線程存儲到等待隊列中,獲得鎖的線程調用 wait 後便存放在條件等待隊列中,解鎖和 notify 都會喚醒相應隊列中的等待線程來爭搶鎖。

然後由於阻塞和喚醒依賴於底層的操作系統實現,系統調用存在用戶態與內核態之間的切換,所以有較高的開銷,因此稱之爲重量級鎖。

所以又引入了自適應自旋機制,來提高鎖的性能。

現在要引入輕量級鎖了

我們再思考一下,是否有這樣的場景:多個線程都是在不同的時間段來請求同一把鎖,此時根本就用不需要阻塞線程,連 monitor 對象都不需要,所以就引入了輕量級鎖這個概念,避免了系統調用,減少了開銷。

在鎖競爭不激烈的情況下,這種場景還是很常見的,可能是常態,所以輕量級鎖的引入很有必要。

在介紹輕量級鎖的原理之前,再看看之前 MarkWord 圖。

輕量級鎖操作的就是對象頭的 MarkWord 。

如果判斷當前處於無鎖狀態,會在當前線程棧的當前棧幀中劃出一塊叫 LockRecord 的區域,然後把鎖對象的 MarkWord 拷貝一份到 LockRecord 中稱之爲 dhw(就是那個set_displaced_header 方法執行的)裏。

然後通過 CAS 把鎖對象頭指向這個 LockRecord 。

輕量級鎖的加鎖過程:

如果當前是有鎖狀態,並且是當前線程持有的,則將 null 放到 dhw 中,這是重入鎖的邏輯。

我們再看下輕量級鎖解鎖的邏輯:

邏輯還是很簡單的,就是要把當前棧幀中 LockRecord 存儲的 markword (dhw)通過 CAS 換回到對象頭中。

如果獲取到的 dhw 是 null 說明此時是重入的,所以直接返回即可,否則就是利用 CAS 換,如果 CAS 失敗說明此時有競爭,那麼就膨脹!

關於這個輕量級加鎖我再多說幾句。

每次加鎖肯定是在一個方法調用中,而方法調用就是有棧幀入棧,如果是輕量級鎖重入的話那麼此時入棧的棧幀裏面的 dhw 就是 null,否則就是鎖對象的 markword。

這樣在解鎖的時候就能通過 dhw 的值來判斷此時是否是重入的。

現在要引入偏向鎖

我們再思考一下,是否有這樣的場景:一開始一直只有一個線程持有這個鎖,也不會有其他線程來競爭,此時頻繁的 CAS 是沒有必要的,CAS 也是有開銷的。

所以 JVM 研究者們就搞了個偏向鎖,就是偏向一個線程,那麼這個線程就可以直接獲得鎖。

我們再看看這個圖,偏向鎖在第二行。

原理也不難,如果當前鎖對象支持偏向鎖,那麼就會通過 CAS 操作:將當前線程的地址(也當做唯一ID)記錄到 markword 中,並且將標記字段的最後三位設置爲 101。

之後有線程請求這把鎖,只需要判斷 markword 最後三位是否爲 101,是否指向的是當前線程的地址。

還有一個可能很多文章會漏的點,就是還需要判斷 epoch 值是否和鎖對象的中的 epoch 值相同。

如果都滿足,那麼說明當前線程持有該偏向鎖,就可以直接返回。

這 epoch 幹啥用的?

可以理解爲是第幾代偏向鎖。

偏向鎖在有競爭的時候是要執行撤銷操作的,其實就是要升級成輕量級鎖。

而當一類對象撤銷的次數過多,比如有個 Yes 類的對象作爲偏向鎖,經常被撤銷,次數到了一定閾值(XX:BiasedLockingBulkRebiasThreshold,默認爲 20 )就會把當代的偏向鎖廢棄,把類的 epoch 加一。

所以當類對象和鎖對象的 epoch 值不等的時候,當前線程可以將該鎖重偏向至自己,因爲前一代偏向鎖已經廢棄了。

不過爲保證正在執行的持有鎖的線程不能因爲這個而丟失了鎖,偏向鎖撤銷需要所有線程處於安全點,然後遍歷所有線程的 Java 棧,找出該類已加鎖的實例,並且將它們標記字段中的 epoch 值加 1。

當撤銷次數超過另一個閾值(XX:BiasedLockingBulkRevokeThreshold,默認值爲 40),則廢棄此類的偏向功能,也就是說這個類都無法偏向了。

至此整個 Synchronized 的流程應該都比較清楚了。

我是反着來講鎖升級的過程的,因爲事實上是先有的重量級鎖,然後根據實際分析優化得到的偏向鎖和輕量級鎖。

包括期間的一些細節應該也較爲清楚了,我覺得對於 Synchronized 瞭解到這份上差不多了。

我再搞了張 openjdk wiki 上的圖,看看是不是很清晰了:

最後

之所以分析源碼,是因爲看了資料,但是很多細節不清晰,然後很難受,所以沒辦法只能硬着頭皮上了。

對於我這個 c++ 基本上不會的人來說,這個確實有點難度....斷斷續續寫了一個星期。

其實沒打算寫這麼多的,就只是想寫自旋那一部分的...搞着搞着就停不下來了。

還有,如果有什麼錯誤,趕緊聯繫我

這文章代碼有點多,不知道有多少人可以耐着性子看到這裏...

我覺得看到這裏的都是高手啊!能不能扣個 1 給我看看?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章