【嗅探底層】你知道Synchronized作用是同步加鎖,可你知道它在JVM中是如何實現的嗎?

石杉的架構筆記發表於2019-04-26


​本文系公眾號石杉的架構筆記的讀者投稿

作者:李瑞傑

目前任職於阿里巴巴,資深JVM研究人員


友情提示:

本文內容涉及JVM底層,文章燒腦,請謹慎閱讀!


我們可以利用synchronized關鍵字來對程式進行加鎖。它既可以用來宣告一個synchronized程式碼塊,也可以直接標記靜態方法或者例項方法。

當談到synchronized時,我們有必要了解位元組碼中的monitorenter和monitorexit指令。

這兩種指令均會消耗運算元棧上的一個引用型別的元素(也就是 synchronized關鍵字括號裡的引用),作為所要加鎖解鎖的鎖物件。

下面我們將深入瞭解Synchronized在JVM底層的實現原理。

考察以下的程式碼:

【嗅探底層】你知道Synchronized作用是同步加鎖,可你知道它在JVM中是如何實現的嗎?

檢視這個程式碼編譯後的位元組碼,我就直接用下面這張圖解釋了。

ps:截圖截得不太好,下面有點沒截到,大家湊合看看:

【嗅探底層】你知道Synchronized作用是同步加鎖,可你知道它在JVM中是如何實現的嗎?


你可能會留意到,上面的位元組碼中包含一個 monitorenter 指令以及多個 monitorexit 指令。

這是因為 Java 虛擬機器需要確保所獲得的鎖在正常執行路徑以及異常執行路徑上都能夠被解鎖。

大家可以看我的註釋,自己思考一下,應該都能看懂。

應該注意,如果用synchronized標記方法,你會看到位元組碼中方法的訪問標記包括ACC_SYNCHRONIZED。

該標記表示在進入該方法時,Java 虛擬機器需要進行monitorenter操作。

而在退出該方法時,不管是正常返回,還是向呼叫者拋異常,Java 虛擬機器均需要進行monitorexit操作。

用兩張圖一看就懂。

【嗅探底層】你知道Synchronized作用是同步加鎖,可你知道它在JVM中是如何實現的嗎?


【嗅探底層】你知道Synchronized作用是同步加鎖,可你知道它在JVM中是如何實現的嗎?


可以看到,在0號位元組碼處就返回了。

這裡有人可能問了,這裡沒有呼叫monitorenter和monitorexit指令啊?怎麼實現的加鎖?

要注意,這裡monitorenter 和 monitorexit 操作所對應的鎖物件是隱式的。

對於例項方法來說,這兩個操作對應的鎖物件是 this;對於靜態方法來說,這兩個操作對應的鎖物件則是所在類的Class例項。


我們先來介紹Synchronized的重入的實現機理。


可以認為每個鎖物件擁有一個鎖計數器和一個指向持有該鎖的執行緒的指標。

當執行monitorenter時,如果目標鎖物件的計數器為0,那麼說明它沒有被其他執行緒所持有。

Java虛擬機器會將該鎖物件的持有執行緒設定為當前執行緒,並且將其計數器加1。

在目標鎖物件的計數器不為 0 的情況下,如果鎖物件的持有執行緒是當前執行緒,那麼 Java 虛擬機器可以將其計數器加1,否則需要等待,直至持有執行緒釋放該鎖。

當執行monitorexit時,Java虛擬機器則需將鎖物件的計數器減1。計數器為0,代表鎖已被釋放。


這就是鎖的重入的實現機理。


說完了這個實現機理,我們來探究具體的鎖實現。

首先談談重量級鎖,重量級鎖是 Java 虛擬機器中最為基礎的鎖實現。

在這種狀態下,Java 虛擬機器會阻塞加鎖失敗的執行緒,並且在目標鎖被釋放的時候,喚醒這些執行緒。在Linux中,這是通過pthread庫的互斥鎖來實現的。

此外,這些操作將涉及系統呼叫,需要從作業系統的使用者態切換至核心態,其開銷非常之大。

為了儘量避免昂貴的執行緒阻塞、喚醒操作,Java虛擬機器會線上程進入阻塞狀態之前,以及被喚醒後競爭不到鎖的情況下,進入自旋狀態,在處理器上空跑並且輪詢鎖是否被釋放。

如果此時鎖恰好被釋放了,那麼當前執行緒便無須進入阻塞狀態,而是直接獲得這把鎖。

下面我將介紹自適應自旋的概念,剛才說了自旋是什麼,但是自旋很耗費資源,所以我們可以根據以往自旋等待時是否能夠獲得鎖,來動態調整自旋的時間(迴圈數目)。

所以Synchronized是否公平這個問題可以休矣,為什麼呢?

處於阻塞狀態的執行緒,並沒有辦法立刻競爭被釋放的鎖。然而,處於自旋狀態的執行緒,則很有可能優先獲得這把鎖。所以Synchronized不是公平的

我們再介紹輕量級鎖,針對多個執行緒在不同的時間段請求同一把鎖,也就是說沒有鎖競爭。

針對這種情形,Java 虛擬機器採用了輕量級鎖,來避免重量級鎖的阻塞以及喚醒。

在介紹輕量級鎖的原理之前,我們先來了解一下Java虛擬機器是怎麼區分輕量級鎖和重量級鎖的。

簡單的說,物件頭中有一個標記欄位。它的最後兩位便被用來表示該物件的鎖狀態,其中:

  • 00代表輕量級鎖

  • 01代表無鎖(或偏向鎖)

  • 10代表重量級鎖

  • 11則跟垃圾回收演算法的標記有關。


當進行加鎖操作時,Java虛擬機器會判斷是否已經是重量級鎖。

如果不是,它會在當前執行緒的當前棧楨中劃出一塊空間,作為該鎖的鎖記錄,並且將鎖物件的標記欄位複製到該鎖記錄中。

然後,Java 虛擬機器會嘗試用 CAS 操作替換鎖物件的標記欄位。

各位有興趣可以瞭解一下JVM的CAS在X86機器上的實現,是彙編指令lock cmpxhcg

這裡我簡單介紹一下,CAS 是一個原子操作,它會比較目標地址的值是否和期望值相等,如果相等,則替換為一個新的值。

假設當前鎖物件的標記欄位為 X…XYZ,Java 虛擬機器會比較該欄位是否為 X…X01。

如果是,則替換為剛才分配的鎖記錄的地址。由於記憶體對齊的緣故,它的最後兩位為 00。此時,該執行緒已成功獲得這把鎖,可以繼續執行了。

如果不是 X…X01,那麼有兩種可能:

  • 第一,該執行緒重複獲取同一把鎖。此時,Java 虛擬機器會將0加入鎖記錄,以代表該鎖被重複獲取。

  • 第二,其他執行緒持有該鎖。此時,Java 虛擬機器會將這把鎖膨脹為重量級鎖,並且阻塞當前執行緒。

你可以將一個執行緒的所有鎖記錄想象成一個棧結構,每次加鎖壓入一條鎖記錄,解鎖彈出一條鎖記錄,當前鎖記錄指的便是棧頂的鎖記錄。

當進行解鎖操作時,如果當前鎖記錄的值為 0,則代表重複進入同一把鎖,直接返回即可。

若當前鎖記錄不是0,Java 虛擬機器會嘗試用 CAS 操作,比較鎖物件的標記欄位的值是否為當前鎖記錄的地址。

如果是,則替換為鎖記錄中的值,也就是鎖物件原本的標記欄位。此時,該執行緒已經成功釋放這把鎖。

如果不是,則意味著這把鎖已經被膨脹為重量級鎖。此時,Java 虛擬機器會進入重量級鎖的釋放過程,喚醒因競爭該鎖而被阻塞了的執行緒。

下面我們介紹偏向鎖,偏向鎖針對的是從始至終只有一個執行緒請求某一把鎖。是輕量級鎖的更進一步的樂觀情況。

線上程進行加鎖時,如果該鎖物件支援偏向鎖,那麼 Java 虛擬機器會通過 CAS 操作,將當前執行緒的地址記錄在鎖物件的標記欄位之中,並且將標記欄位的最後三位設定為 101。

這裡介紹一下epoch的概念,每個類中維護一個epoch值,你可以理解為這個類所有例項物件的第幾代偏向鎖。

當設定偏向鎖時,Java 虛擬機器需要將該epoch值複製到鎖物件的標記欄位中。我們規定,你加的偏向鎖的代數高,是可以把代數低的PK下去的。


接下來我給你講的過程,你就知道為什麼要這麼設計了。


我們先從偏向鎖的撤銷講起。

當請求加鎖的執行緒和鎖物件標記欄位保持的執行緒地址不匹配時(而且epoch即代數必須相等,如若不等,那麼當前執行緒可以將該鎖重偏向至自己,因為新的epoch的代數肯定要高於以前的代數),Java 虛擬機器需要撤銷該偏向鎖。

這個撤銷過程非常麻煩,它要求持有偏向鎖的執行緒到達安全點,再將偏向鎖替換成輕量級鎖。

在宣佈某個類的偏向鎖失效時,Java 虛擬機器實則將該類的epoch值加 1,表示之前那一代的偏向鎖已經失效。而新設定的偏向鎖則需要使用類中的最新epoch代數來加鎖。

為了保證當前持有偏向鎖並且已加鎖的執行緒不至於因此丟鎖,Java虛擬機器需要遍歷所有執行緒的Java棧,找出該類已加鎖的例項,並且將它們標記欄位中的 epoch值加1。該操作需要所有執行緒處於安全點狀態。

所以有專家近年來提出,偏向鎖在鎖競爭激烈的情況下,非但不能優化效能,反而可能傷害應用效能。

如果總撤銷數超過另一個閾值(對應 Java 虛擬機器引數-XX:BiasedLockingBulkRevokeThreshold,預設值為 40),那麼 Java 虛擬機器會認為這個類已經不再適合偏向鎖。

此時,Java 虛擬機器會撤銷該類例項的偏向鎖,並且在之後的加鎖過程中直接為該類例項設定輕量級鎖。


END


歡迎長按下圖關注公眾號:石杉的架構筆記!

公眾號後臺回覆資料,獲取作者獨家祕製學習資料

石杉的架構筆記,BAT架構經驗傾囊相授

【嗅探底層】你知道Synchronized作用是同步加鎖,可你知道它在JVM中是如何實現的嗎?


相關文章