[Java 併發]深入淺出 synchronized 與鎖

synchronized 關鍵字

說到鎖,都會提 synchronized .這個英文單詞兒啥意思呢?翻譯成中文就是「同步」的意思
一般都是使用 synchronized 這個關鍵字來給一段代碼或者一個方法上鎖,使得這段代碼或者方法,在同一個時刻只能有一個線程來執行它.
synchronized 相比於 volatile 來說,用的比較靈活,你可以在方法上使用,可以在靜態方法上使用,也可以在代碼塊上使用.
關於 synchronized 這一塊大概就說到這裏,我想着重來說一下, synchronized 底層是怎麼實現的

JVM 是如何實現 synchronized 的?

我知道可以利用 synchronized 關鍵字來給程序進行加鎖,但是它具體怎麼實現的我不清楚呀,別急,咱們先來看個 demo :

public class demo {
    public void synchronizedDemo(Object lock){
		synchronized(lock){
			lock.hashCode();
		}
	}
}

上面是我寫的一個 demo ,然後進入到 class 文件所在的目錄下,使用 javap -v demo.class 來看一下編譯的字節碼(在這裏我截取了一部分):

  public void synchronizedDemo(java.lang.Object);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=2
         0: aload_1
         1: dup
         2: astore_2
         3: monitorenter
         4: aload_1
         5: invokevirtual #2                  // Method 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   any
            14    17    14   any

應該能夠看到當程序聲明 synchronized 代碼塊時,編譯成的字節碼會包含 monitorenter 和 monitorexit 指令,這兩種指令會消耗操作數棧上的一個引用類型的元素(也就是 synchronized 關鍵字括號裏面的引用),作爲所要加鎖解鎖的鎖對象.如果看的比較仔細的話,上面有一個 monitorenter 指令和兩個 monitorexit 指令,這是 Java 虛擬機爲了確保獲得的鎖不管是在正常執行路徑,還是在異常執行路徑上都能夠解鎖.

關於 monitorenter 和 monitorexit ,可以理解爲每個鎖對象擁有一個鎖計數器和一個指向持有該鎖的線程指針:

  • 當程序執行 monitorenter 時,如果目標鎖對象的計數器爲 0 ,說明這個時候它沒有被其他線程所佔有,此時如果有線程來請求使用, Java 虛擬機就會分配給該線程,並且把計數器的值加 1
    • 目標鎖對象計數器不爲 0 時,如果鎖對象持有的線程是當前線程, Java 虛擬機可以將其計數器加 1 ,如果不是呢?那很抱歉,就只能等待,等待持有線程釋放掉
  • 當執行 monitorexit 時, Java 虛擬機就將鎖對象的計數器減 1 ,當計數器減到 0 時,說明這個鎖就被釋放掉了,此時如果有其他線程來請求,就可以請求成功

爲什麼採用這種方式呢?是爲了允許同一個線程重複獲取同一把鎖.
比如,一個 Java 類中擁有好多個 synchronized 方法,那這些方法之間的相互調用,不管是直接的還是間接的,都會涉及到對同一把鎖的重複加鎖操作.這樣去設計的話,就可以避免這種情況.

在 Java 多線程中,所有的鎖都是基於對象的.也就是說, Java 中的每一個對象都可以作爲一個鎖.你可能會有疑惑,不對呀,不是還有類鎖嘛.但是 class 對象也是特殊的 Java 對象,所以呢,在 Java 中所有的鎖都是基於對象的
在 Java6 之前,所有的鎖都是"重量級"鎖,重量級鎖會帶來一個問題,就是如果程序頻繁獲得鎖釋放鎖,就會導致性能的極大消耗.爲了優化這個問題,引入了"偏向鎖"和"輕量級鎖"的概念.所以在 Java6 及其以後的版本,一個對象有 4 種鎖狀態:無鎖狀態,偏向鎖狀態,輕量級鎖狀態,重量級鎖狀態.

在 4 種鎖狀態中,無鎖狀態應該比較好理解,無鎖就是沒有鎖,任何線程都可以嘗試修改,所以這裏就一筆帶過了.

隨着競爭情況的出現,鎖的升級非常容易發生,但是如果想要讓鎖降級,條件非常苛刻,有種你想來可以,但是想走不行的趕腳.

在這裏囉嗦一句:很多文章說,鎖如果升級之後是不能降級的,其實在 HotSpot JVM 中,是支持鎖降級的
鎖降級發生在 Stop The World 期間,當 JVM 進入安全點的時候,會檢查有沒有閒置的鎖,如果有就會嘗試進行降級
看到 Stop The World 和 安全點 可能有人比較懵,我這裏簡單說一下,具體還需要讀者自己去探索一番.(因爲這是 JVM 的內容,這篇文章的重點不是 JVM )
在 Java 虛擬機裏面,傳統的垃圾回收算法採用的是一種簡單粗暴的方式,就是 Stop-the-world ,而這個 Stop-the-world 就是通過安全點( safepoint )機制來實現的,安全點是什麼意思呢?就是 Java 程序在執行本地代碼時,如果這段代碼不訪問 Java 對象/調用 Java 方法/返回到原來的 Java 方法,那 Java 虛擬機的堆棧就不會發生改變,這就代表執行的這段本地代碼可以作爲一個安全點.當 Java 虛擬機收到 Stop-the-world 請求時,它會等所有的線程都到達安全點之後,才允許請求 Stop-the-world 的線程進行獨佔工作

接下來就介紹一下幾種鎖和鎖升級

Java 對象頭

在剛開始就說了, Java 的鎖都是基於對象的,那是怎麼告訴程序我是個鎖呢?就不得不來說, Java 對象頭
每個 Java 對象都有對象頭,如果是非數組類型,就用 2 個字寬來存儲對象頭,如果是數組,就用 3 個字寬來存儲對象頭.在 32 位處理器中,一個字寬是 32 位;在 64 位處理器中,字寬就是 64 位咯~對象頭的內容就是下面這樣:

長度 內容 說明
32/64 bit Mark Word 存儲對象的 hashCode 或鎖信息等
32/64 bit Class Metadata Address 存儲到對象類型數據的指針
32/64 bit Array length 數組的長度(如果是數組)

咱們主要來看 Mark Word 的內容:

鎖狀態 29 bit/61 bit 1 bit 是否是偏向鎖 2 bit 鎖標誌位
無鎖 0 01
偏向鎖 線程 ID 1 01
輕量級鎖 指向棧中鎖記錄的指針 此時這一位不用於標識偏向鎖 00
重量級鎖 指向互斥量(重量級鎖)的指針 此時這一位不用於標識偏向鎖 10
GC 標記 此時這一位不用於標識偏向鎖 11

從上面表格中,應該能夠看到,是偏向鎖時, Mark Word 存儲的是偏向鎖的線程 ID ;是輕量級鎖時, Mark Word 存儲的是指向線程棧中 Lock Record 的指針;是重量級鎖時, Mark Word 存儲的是指向堆中的 monitor 對象的指針

偏向鎖

HotSpot 的作者經過大量的研究發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得
基於此,就引入了偏向鎖的概念

所以啥是偏向鎖呢?用大白話說就是,我現在給鎖設置一個變量,當一個線程請求的時候,發現這個鎖是 true ,也就是說這個時候沒有所謂的資源競爭,那也不用走什麼加鎖/解鎖的流程了,直接拿來用就行.但是如果這個鎖是 false 的話,說明存在其他線程競爭資源,那咱們再走正規的流程

看一下具體的實現原理:

當一個線程第一次進入同步塊時,會在對象頭和棧幀中的鎖記錄中存儲鎖偏向的線程 ID .當下次該線程進入這個同步塊時,會檢查鎖的 Mark Word 裏面存放的是不是自己的線程 ID.如果是,說明線程已經獲得了鎖,那麼這個線程在進入和退出同步塊時,都不需要花費 CAS 操作來加鎖和解鎖;如果不是,說明有另外一個線程來競爭這個偏向鎖,這時就會嘗試使用 CAS 來替換 Mark Word 裏面的線程 ID 爲新線程的 ID .此時會有兩種情況:

  • 替換成功,說明之前的線程不存在了,那麼 Mark Word 裏面的線程 ID 爲新線程的 ID ,鎖不會升級,此時仍然爲偏向鎖
  • 替換失敗,說明之前的線程仍然存在,那就暫停之前的線程,設置偏向鎖標識爲 0 ,並設置鎖標誌位爲 00 ,升級爲輕量級鎖,按照輕量級鎖的方式進行競爭鎖

撤銷偏向鎖

偏向鎖使用了一種等到競爭出現時才釋放鎖的機制.也就說,如果沒有人來和我競爭鎖的時候,那麼這個鎖就是我獨有的,當其他線程嘗試和我競爭偏向鎖時,我會釋放這個鎖
在偏向鎖向輕量級鎖升級時,首先會暫停擁有偏向鎖的線程,重置偏向鎖標識,看起來這個過程挺簡單的,但是開銷是很大的,因爲:

  • 首先需要在一個安全點停止擁有鎖的線程
  • 然後遍歷線程棧,如果存在鎖記錄的話,就需要修復鎖記錄和 Mark Word ,變成無鎖狀態
  • 最後喚醒被停止的線程,把偏向鎖升級成輕量級鎖

你以爲就是升級一個輕量級鎖? too young too simple
偏向鎖向輕量級鎖升級的過程中,是非常耗費資源的,如果應用程序中所有的鎖通常都處於競爭狀態,偏向鎖此時就是一個累贅,此時就可以通過 JVM 參數關閉偏向鎖: -XX:-UseBiasedLocking=false ,那麼程序默認會進入輕量級鎖狀態
最後,來張圖吧~
在這裏插入圖片描述

輕量級鎖

如果多個線程在不同時段獲取同一把鎖,也就是不存在鎖競爭的情況,那麼 JVM 就會使用輕量級鎖來避免線程的阻塞與喚醒

輕量級鎖加鎖

JVM 會爲每個線程在當前線程的棧幀中創建用於存儲鎖記錄的空間,稱之爲 Displaced Mark Word .如果一個線程獲得鎖的時候發現是輕量級鎖,就會將鎖的 Mark Word 複製到自己的 Displaced Mark Word 中.之後線程會嘗試用 CAS 將鎖的 Mark Word 替換爲指向鎖記錄的指針.
如果替換成功,當前線程獲得鎖,那麼整個狀態還是 輕量級鎖 狀態
如果替換失敗了呢?說明 Mark Word 被替換成了其他線程的鎖記錄,那就嘗試使用自旋來獲取鎖.(自旋是說,線程不斷地去嘗試獲取鎖,一般都是用循環來實現的)

自旋是耗費 CPU 的,如果一直獲取不到鎖,線程就會一直自旋, CPU 那麼寶貴的資源就這麼被白白浪費了
解決這個問題最簡單的辦法就是指定自旋的次數,比如如果沒有替換成功,那就循環 10 次,還沒有獲取到,那就進入阻塞狀態
但是 JDK 採用了一個更加巧妙的方法—適應性自旋.就是說,如果這次線程自旋成功了,那我下次自旋次數更多一些,因爲我這次自旋成功,說明我成功的概率還是挺大的,下次自旋次數就更多一些,那麼如果自旋失敗了,下次我自旋次數就減少一些,就比如,已經看到了失敗的前兆,那我就先溜,而不是非要"不撞南牆不回頭"

自旋失敗之後,線程就會阻塞,同時鎖會升級成重量級鎖

輕量級鎖釋放:

在釋放鎖時,當前線程會使用 CAS 操作將 Displaced Mark Word 中的內容複製到鎖的 Mark Word 裏面.如果沒有發生競爭,這個複製的操作就會成功;如果有其他線程因爲自旋多次導致輕量級鎖升級成了重量級鎖, CAS 操作就會失敗,此時會釋放鎖同時喚醒被阻塞的過程

同樣,來一張圖吧:
在這裏插入圖片描述

重量級鎖

重量級鎖依賴於操作系統的互斥量( mutex )來實現.但是操作系統中線程間狀態的轉換需要相對比較長的時間(因爲操作系統需要從用戶態切換到內核態,這個切換成本很高),所以重量級鎖效率很低,但是有一點就是,被阻塞的線程是不會消耗 CPU 的
每一個對象都可以當做一個鎖,那麼當多個線程同時請求某個對象鎖時,它會怎麼處理呢?
對象鎖會設置集中狀態來區分請求的線程:

Contention List:所有請求鎖的線程將被首先放置到該競爭隊列
Entry List: Contention List 中那些有資格成爲候選人的線程被移到 Entry List 中
Wait Set:調用 wait 方法被阻塞的線程會被放置到 Wait Set 中
OnDeck:任何時刻最多只能有一個線程正在競爭鎖,該線程稱爲 OnDeck
Owner:獲得鎖的線程稱爲 Owner
!Owner:釋放鎖的線程

當一個線程嘗試獲得鎖時,如果這個鎖被佔用,就會把該線程封裝成一個 ObjectWaiter 對象插入到 Contention List 隊列的隊首,然後調用 park 函數掛起當前線程
當線程釋放鎖時,會從 Contention List 或者 Entry List 中挑選一個線程進行喚醒
如果線程在獲得鎖之後,調用了 Object.wait 方法,就會將該線程放入到 WaitSet 中,當被 Object.notify 喚醒後,會將線程從 WaitSet 移動到 Contention List 或者 Entry List 中.
但是,當調用一個鎖對象的 waitnotify 方法時,如果當前鎖的狀態是偏向鎖或輕量級鎖,則會先膨脹成重量級鎖

總結:

synchronized 關鍵字是通過 monitorenter 和 monitorexit 兩種指令來保證鎖的
當一個線程準備獲取共享資源時:

  • 首先檢查 MarkWord 裏面放的是不是自己的 ThreadID ,如果是,說明當前線程處於 “偏向鎖”
  • 如果不是,鎖升級,這時使用 CAS 操作來執行切換,新的線程根據 MarkWord 裏面現有的 ThreadID 來通知之前的線程暫停,將 MarkWord 的內容置爲空
    然後,兩個線程都將鎖對象 HashCode 複製到自己新建的用於存儲鎖的記錄空間中,接着開始通過 CAS 操作,把鎖對象的 MarkWord 的內容修改爲自己新建的記錄空間地址,以這種方式競爭 MarkWord ,成功執行 CAS 的線程獲得資源,失敗的則進入自旋
    • 自旋的線程在自旋過程中,如果成功獲得資源(也就是之前獲得資源的線程執行完畢,釋放了共享資源),那麼整個狀態依然是 輕量級鎖 的狀態
    • 如果沒有獲得資源,就進入 重量級鎖 的狀態,此時,自旋的線程進行阻塞,等待之前線程執行完成並且喚醒自己

參考:

  • Java 併發編程的技術
  • 極客時間—深入拆解 Java 虛擬機

到這裏,整篇文章的內容就算是結束了.
沒想到這篇文章竟然被我寫了有 5000 多字(我不會告訴你這篇文章我從大早上八點就開始寫,寫到現在,累到虛脫的我
能夠閱讀到這裏的各位,希望能夠給你帶來一些幫助
以上,感謝您的閱讀哇~

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