Synchronized 輕量級鎖會自旋?好像並不是這樣的。

yes的練級攻略發表於2021-03-03

本來是在寫麵霸系列的,寫著寫著就寫到了這一題:

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 給我看看?

更多內容,歡迎關注我的公眾號【yes的練級攻略】,每週保證至少分享一篇原創技術文。

巨人的肩膀

《深入拆解Java虛擬機器》鄭雨迪
https://wiki.openjdk.java.net/display/HotSpot/Synchronization
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.monitorenter

更多文章可看我的文章彙總:https://github.com/yessimida/yes 歡迎 star !


我是 yes,從一點點到億點點,歡迎在看、轉發、留言,我們下篇見。

相關文章