本文已收錄GitHub,更有互聯網大廠面試真題,面試攻略,高效學習資料等java
在 Java 程序中,咱們能夠利用 synchronized 關鍵字來對程序進行加鎖。它既能夠用來聲明一個 synchronized 代碼塊,也能夠直接標記靜態方法或者實例方法。git
當聲明 synchronized 代碼塊時,編譯而成的字節碼將包含 monitorenter 和 monitorexit指令。這兩種指令均會消耗操做數棧上的一個引用類型的元素(也就是 synchronized 關鍵字括號裏的引用),做爲所要加鎖解鎖的鎖對象。github
public void foo(Object lock) { synchronized (lock) { lock.hashCode(); } } //上面的Java代碼將編譯爲下面的字節碼 public void foo(java.lang.Object); Code: 0: aload_1 1: dup 2: astore_2 3: monitorenter 4: aload_1 5: invokevirtual java/lang/Object.hashCode:()I 8: pop 9: aload_2 10: monitorexit 11: goto 19 14: astore_3 15: aload_2 16: monitorexit 17: aload_3 18: athrow 19: return Exception table: from to target type 4 11 14 any14 17 14 any
我在文稿中貼了一段包含 synchronized 代碼塊的 Java 代碼,以及它所編譯而成的字節碼。你可能會留意到,上面的字節碼中包含一個 monitorenter 指令以及多個 monitorexit指令。這是由於 Java 虛擬機須要確保所得到的鎖在正常執行路徑,以及異常執行路徑上都可以被解鎖。面試
你能夠根據我在介紹異常處理時介紹過的知識,對照字節碼和異常處理表來構造全部可能的執行路徑,看看在執行了 monitorenter 指令以後,是否都有執行 monitorexit 指令。算法
當用 synchronized 標記方法時,你會看到字節碼中方法的訪問標記包括ACC_SYNCHRONIZED。該標記表示在進入該方法時,Java 虛擬機須要進行monitorenter 操做。而在退出該方法時,不論是正常返回,仍是向調用者拋異常,Java 虛擬機均須要進行 monitorexit 操做。編程
public synchronized void foo(Object lock) { lock.hashCode(); } //上面的Java代碼將編譯爲下面的字節碼 public synchronized void foo(java.lang.Object); descriptor: (Ljava/lang/Object; )V flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZEDCode: stack=1, locals=2, args_size=2 0: aload_1 1: invokevirtual java/lang/Object.hashCode:()I4: pop 5: return
這裏 monitorenter 和 monitorexit 操做所對應的鎖對象是隱式的。對於實例方法來講,這兩個操做對應的鎖對象是 this;對於靜態方法來講,這兩個操做對應的鎖對象則是所在類的 Class 實例。安全
關於 monitorenter 和 monitorexit 的做用,咱們能夠抽象地理解爲每一個鎖對象擁有一個鎖計數器和一個指向持有該鎖的線程的指針。ide
當執行 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 虛擬機採用了輕量級鎖,來避免重量級鎖的阻塞以及喚醒。在介紹輕量級鎖的原理以前,咱們先來了解一下 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 操做,在鎖對象的標記字段中記錄下當前線程的地址。在以後的運行過程當中,持有該偏向鎖的線程的加鎖操做將直接返回。它針對的是鎖僅會被同一線程持有的狀況。
本文的實踐環節,咱們來驗證一個坊間傳聞:調用 Object.hashCode() 會關閉該對象的偏向鎖。
你能夠採用參數 -XX:+PrintBiasedLockingStatistics 來打印各種鎖的個數。因爲 C2 使用的是另一個參數 -XX:+PrintPreciseBiasedLockingStatistics,所以你能夠限制 Java 虛擬機僅使用 C1 來即時編譯(對應參數 -XX:TieredStopAtLevel=1)。