Java虛擬機器是怎麼實現synchronized的

_吹雪_發表於2018-10-13

synchronized位元組碼

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

當宣告 synchronized 程式碼塊時,編譯而成的位元組碼將包含 monitorenter 和 monitorexit 指令。這兩種指令均會消耗運算元棧上的一個引用型別的元素(也就是 synchronized 關鍵字括號裡的引用),作為所要加鎖解鎖的鎖物件。位元組碼中可能包含一個 monitorenter 指令以及多個 monitorexit 指令。這是因為 Java 虛擬機器需要確保所獲得的鎖在正常執行路徑,以及異常執行路徑上都能夠被解鎖。

當用 synchronized 標記方法時,你會看到位元組碼中方法的訪問標記包括 ACC_SYNCHRONIZED。該標記表示在進入該方法時,Java 虛擬機器需要進行 monitorenter 操作。而在退出該方法時,不管是正常返回,還是向呼叫者拋異常,Java 虛擬機器均需要進行 monitorexit 操作。這裡 monitorenter 和 monitorexit 操作所對應的鎖物件是隱式的。對於例項方法來說,這兩個操作對應的鎖物件是 this;對於靜態方法來說,這兩個操作對應的鎖物件則是所在類的 Class 例項。

關於 monitorenter 和 monitorexit 的作用,我們可以抽象地理解為每個鎖物件擁有一個鎖計數器和一個指向持有該鎖的執行緒的指標。

當執行 monitorenter 時,如果目標鎖物件的計數器為 0,那麼說明它沒有被其他執行緒所持有。在這個情況下,Java 虛擬機器會將該鎖物件的持有執行緒(也就是前面說的指向執行緒的指標)設定為當前執行緒,並且將其計數器加 1。

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

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

之所以採用這種計數器的方式,是為了允許同一個執行緒重複獲取同一把鎖。舉個例子,如果一個 Java 類中擁有多個 synchronized 方法,那麼這些方法之間的相互呼叫,不管是直接的還是間接的,都會涉及對同一把鎖的重複加鎖操作。因此,我們需要設計這麼一個可重入的特性,來避免程式設計裡的隱式約束。

說完抽象的鎖演算法,下面我們便來介紹HotSpot虛擬機器中具體的鎖實現

重量級鎖

重量級鎖是 Java 虛擬機器中最為基礎的鎖實現。在這種狀態下,Java 虛擬機器會阻塞加鎖失敗的執行緒,並且在目標鎖被釋放的時候,喚醒這些執行緒。

Java 執行緒的阻塞以及喚醒,都是依靠作業系統來完成的。舉例來說,對於符合 posix 介面的作業系統(如 macOS 和絕大部分的 Linux),上述操作是通過 pthread 的互斥鎖(mutex)來實現的。此外,這些操作將涉及系統呼叫,需要從作業系統的使用者態切換至核心態,其開銷非常之大。

為了儘量避免昂貴的執行緒阻塞、喚醒操作,Java 虛擬機器會線上程進入阻塞狀態之前,以及被喚醒後競爭不到鎖的情況下,進入自旋狀態,在處理器上空跑並且輪詢鎖是否被釋放。如果此時鎖恰好被釋放了,那麼當前執行緒便無須進入阻塞狀態,而是直接獲得這把鎖。

與執行緒阻塞相比,自旋狀態可能會浪費大量的處理器資源。這是因為當前執行緒仍處於執行狀況,只不過跑的是無用指令。它期望在執行無用指令的過程中,鎖能夠被釋放出來。

我們可以用等紅綠燈作為例子。Java 執行緒的阻塞相當於熄火停車,而自旋狀態相當於怠速停車。如果紅燈的等待時間非常長,那麼熄火停車相對省油一些;如果紅燈的等待時間非常短,比如說我們在 synchronized 程式碼塊裡只做了一個整型加法,那麼在短時間內鎖肯定會被釋放出來,因此怠速停車更加合適。

然而,對於 Java 虛擬機器來說,它並不能看到紅燈的剩餘時間,也就沒辦法根據等待時間的長短來選擇自旋還是阻塞。Java 虛擬機器給出的方案是自適應自旋,根據以往自旋等待時是否能夠獲得鎖,來動態調整自旋的時間(迴圈數目)

就我們的例子來說,如果之前不熄火等到了綠燈,那麼這次不熄火的時間就長一點;如果之前不熄火沒等到綠燈,那麼這次不熄火的時間就短一點。

自旋狀態還帶來另外一個副作用,那便是不公平的鎖機制。處於阻塞狀態的執行緒,並沒有辦法立刻競爭被釋放的鎖。然而,處於自旋狀態的執行緒,則很有可能優先獲得這把鎖。

輕量級鎖

你可能見到過深夜的十字路口,四個方向都閃黃燈的情況。由於深夜十字路口的車輛來往可能比較少,如果還設定紅綠燈交替,那麼很有可能出現四個方向僅有一輛車在等紅燈的情況。

因此,紅綠燈可能被設定為閃黃燈的情況,代表車輛可以自由通過,但是司機需要注意觀察(個人理解,實際意義請諮詢交警部門)。

Java 虛擬機器也存在著類似的情形:多個執行緒在不同的時間段請求同一把鎖,也就是說沒有鎖競爭。針對這種情形,Java 虛擬機器採用了輕量級鎖,來避免重量級鎖的阻塞以及喚醒。

偏向鎖是不是輕量級鎖?從下文的00和01可以看出不是。

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

在物件記憶體佈局那一篇中我曾經介紹了物件頭中的標記欄位(mark word)。它的最後兩位便被用來表示該物件的鎖狀態。其中,00 代表輕量級鎖,01 代表無鎖(或偏向鎖),10 代表重量級鎖,11 則跟垃圾回收演算法的標記有關。

當進行加鎖操作時,Java 虛擬機器會判斷是否已經是重量級鎖。如果不是,它會在當前執行緒的當前棧楨中劃出一塊空間,作為該鎖的鎖記錄,並且將鎖物件的標記欄位複製到該鎖記錄中。

然後,Java 虛擬機器會嘗試用 CAS(compare-and-swap)操作替換鎖物件的標記欄位。這裡解釋一下,CAS 是一個原子操作,它會比較目標地址的值是否和期望值相等,如果相等,則替換為一個新的值。

假設當前鎖物件的標記欄位為 X…XYZ,Java 虛擬機器會比較該欄位是否為 X…X01。如果是,則替換為剛才分配的鎖記錄的地址。由於記憶體對齊的緣故,它的最後兩位為 00。此時,該執行緒已成功獲得這把鎖,可以繼續執行了。

如果不是 X…X01,那麼有兩種可能。第一,該執行緒重複獲取同一把鎖。此時,Java 虛擬機器會將鎖記錄清零,以代表該鎖被重複獲取。第二,其他執行緒持有該鎖。此時,Java 虛擬機器會將這把鎖膨脹為重量級鎖,並且阻塞當前執行緒。

當進行解鎖操作時,如果當前鎖記錄(你可以將一個執行緒的所有鎖記錄想象成一個棧結構,每次加鎖壓入一條鎖記錄,解鎖彈出一條鎖記錄,當前鎖記錄指的便是棧頂的鎖記錄)的值為 0,則代表重複進入同一把鎖,直接返回即可。

否則,Java 虛擬機器會嘗試用 CAS 操作,比較鎖物件的標記欄位的值是否為當前鎖記錄的地址。如果是,則替換為鎖記錄中的值,也就是鎖物件原本的標記欄位。此時,該執行緒已經成功釋放這把鎖。

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

偏向鎖

如果說輕量級鎖針對的情況很樂觀,那麼接下來的 偏向鎖針對的情況則更加樂觀:從始至終只有一個執行緒請求某一把鎖

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

在接下來的執行過程中,每當有執行緒請求這把鎖,Java 虛擬機器只需判斷鎖物件標記欄位中:最後三位是否為 101,是否包含當前執行緒的地址,以及 epoch 值是否和鎖物件的類的 epoch 值相同。如果都滿足,那麼當前執行緒持有該偏向鎖,可以直接返回。

這裡的 epoch 值是一個什麼概念呢?

我們先從偏向鎖的撤銷講起。當請求加鎖的執行緒和鎖物件標記欄位保持的執行緒地址不匹配時(而且 epoch 值相等,如若不等,那麼當前執行緒可以將該鎖重偏向至自己),Java 虛擬機器需要撤銷該偏向鎖。這個撤銷過程非常麻煩,它要求持有偏向鎖的執行緒到達安全點,再將偏向鎖替換成輕量級鎖。

如果某一類鎖物件的總撤銷數超過了一個閾值(對應 Java 虛擬機器引數 -XX:BiasedLockingBulkRebiasThreshold,預設為 20),那麼 Java 虛擬機器會宣佈這個類的偏向鎖失效。

具體的做法便是在每個類中維護一個 epoch 值,你可以理解為第幾代偏向鎖。當設定偏向鎖時,Java 虛擬機器需要將該 epoch 值複製到鎖物件的標記欄位中。

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

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

如果總撤銷數超過另一個閾值(對應 Java 虛擬機器引數 -XX:BiasedLockingBulkRevokeThreshold,預設值為 40),那麼 Java 虛擬機器會認為這個類已經不再適合偏向鎖。此時,Java 虛擬機器會撤銷該類例項的偏向鎖,並且在之後的加鎖過程中直接為該類例項設定輕量級鎖。

總結與實踐

今天我介紹了 Java虛擬機器中synchronized關鍵字的實現,按照代價由高至低可分為重量級鎖、輕量級鎖和偏向鎖三種

重量級鎖會阻塞、喚醒請求加鎖的執行緒。它針對的是多個執行緒同時競爭同一把鎖的情況。Java 虛擬機器採取了自適應自旋,來避免執行緒在面對非常小的 synchronized 程式碼塊時,仍會被阻塞、喚醒的情況。

輕量級鎖採用 CAS 操作,將鎖物件的標記欄位替換為一個指標,指向當前執行緒棧上的一塊空間,儲存著鎖物件原本的標記欄位。它針對的是多個執行緒在不同時間段申請同一把鎖的情況

偏向鎖只會在第一次請求時採用 CAS 操作,在鎖物件的標記欄位中記錄下當前執行緒的地址。在之後的執行過程中,持有該偏向鎖的執行緒的加鎖操作將直接返回。它針對的是鎖僅會被同一執行緒持有的情況

相關文章