synchronized底層原理探究

概述

說起多線程同步,一般的方案就是加鎖,而在 java 中,提到加鎖就想起 juc 包提供的 Lock 接口實現類與默認的關鍵字 synchronized 。我們常聽到,juc 下的鎖大多基於 AQS,而 AQS 的鎖機制基於 CAS,相比起 CAS 使用的自旋鎖,Synchronized 是一種重量級的鎖實現。

實際上,在 JDK6 之後,synchronized 逐漸引入了鎖升級機制,它將會有一個從輕量級到重量級的逐步升級的過程。本文將簡單的介紹 synchronized 的底層實現原理,並且介紹 synchronized 的鎖升級機制。

一、synchronized 的底層實現

synchronized 意爲同步,它可以用於修飾靜態方法,實例方法,或者一段代碼塊。

它是一種可重入的對象鎖。當修飾靜態方法時,鎖對象爲類;當修飾實例方法時,鎖對象爲實例;當修飾代碼塊時,鎖可以是任何非 null 的對象。

由於其底層的實現機制,synchronized 的鎖又稱爲監視器鎖。

1.同步代碼塊

當我們反編譯一個含有被 synchronized 修飾的代碼塊的文件時,我們可以看到類似如下指令:

image-20210210174821495

這裏的 monitorentermonitorexit 即是線程獲取 synchronized 鎖的過程。

當線程試圖獲取對象鎖的時候,根據 monitorenter 指令:

  • 如果 Monitor 的進入數爲0,則該線程進入monitor,然後將進入數設置爲1,該線程即爲 Monitor 的所有者;
  • 如果線程已經佔有該 monitor,只是重新進入,則進入 Monitor 的進入數加1(可重入);
  • 如果其他線程已經佔用了 monitor,則該線程進入阻塞狀態,直到 Monitor 的進入數爲0,再重新嘗試獲取 Monitor 的所有權;

當線程執行完以後,根據 monitorexit 指令:

  • 當執行 monitorexit 指令後,Monitor 的進入數 -1;
  • 如果 - 1 後 Monitor 進入數爲 0,則該線程不再擁有這個鎖,退出 monitor;
  • 如果 - 1 後 Monitor 進入數仍不爲 0,則線程繼續持有這個鎖,重複上述過程直到使用完畢。

2.同步方法

而對於被 synchronized 修飾的方法,在反編譯以後我們可以看到如下指令:

image-20210210175830989

在 flags 處多了 ACC_SYNCHRONIZED標識符,如果方法擁有改標識符,則線程需要在訪問前獲取 monitor,在執行後釋放 monitor,這個過程同上文提到的代碼塊的同步。

相對代碼塊的同步,方法的同步隱式調用了 monitor,實際上二者本質並無差別,最終都要通過 JVM 調用操作系統互斥原語 mutex 實現。

二、synchronized 鎖的實現原理

synchronized 是對象鎖,在 JDK6 引入鎖升級機制後,synchronized 的鎖實際上分爲了偏向鎖、輕量級鎖和重量級鎖三種,這三者都依賴於對象頭中 MarkWord 的數據的改變。

1.對象頭

在 java 中,一個對象被分爲三部分:

  • 實例數據:存放類的屬性數據信息,包括父類的屬性信息;

  • 對象頭:用於存放哈希值或者鎖等信息。

    Java 對象頭一般佔有2個機器碼(在32位虛擬機中,1個機器碼等於4字節,也就是32 bit,在64位虛擬機中,1個機器碼是8個字節,也就是64 bit);

    但是如果對象是數組類型,則需要3個機器碼,因爲 JVM虛擬機可以通過 java 對象的元數據信息確定 Java 對象的大小,但是無法從數組的元數據來確認數組的大小,所以用一塊來記錄數組長度。

  • 對齊填充:由於虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊;

image-20210210190106478

其中,對象頭又分爲三部分:MarkWord,類型指針,數組長度(如果是數組的話)。

MarkWord 是一個比較重要的部分,它存儲了對象運行時的大部分數據,如:hashcode、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等

根據機器的不同,MarkWord 可能有 64 位或者 32 位,MarkWord 會隨着對象狀態的改變而改變,一般來說,結構是這樣的:

img

值得注意的是:

對象頭的最後兩位存儲了鎖的標誌位,01是初始狀態,未加鎖,其對象頭裏存儲的是對象本身的哈希碼,隨着鎖級別的不同,對象頭裏會存儲不同的內容。

  • 偏向鎖存儲的是當前佔用此對象的線程ID;
  • 輕量級鎖存儲的是指向線程棧中鎖記錄的指針。

鎖標誌位如下:

LockWord存儲內容 鎖標誌位 狀態
對象哈希值,GC 分代年齡 01 未鎖定
指向 LockRecord 的指針 00 輕量級鎖鎖定
指向 Monitor 的指針 10 重量級鎖鎖定
11 GC 標記
偏向線程 id,偏向時間戳,GC 分代年齡 01 可偏向

2.重量級鎖與監視器

synchronized 的對象鎖是基於監視器對象 Monitor 實現的,而根據上文,我們知道鎖信息存儲於對象自己的 MarkWord 中,那麼 Monitor 和 對象又是什麼關係呢?

實際上,在對象在創建之初就會在 MarkWord 中關聯一個 Monitor 對象 ,當鎖升級到重量級鎖時,標誌位就會變爲指向 Monitor 對象的指針。

Monitor 對象在 JVM 中基於 ObjectMonitor 實現,代碼如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 持有鎖次數
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; // 當前持有鎖的線程
    _WaitSet      = NULL; // 等待隊列,處於wait狀態的線程,會被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 阻塞隊列,處於等待鎖block狀態的線程,會被加入到該列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

ObjectMonitor 中有兩個隊列,_WaitSet_EntryList,用來保存 ObjectWaiter 對象列表( 每個等待鎖的線程都會被封裝成 ObjectWaiter 對象 ),_owner 指向持有 ObjectMonitor 對象的線程,當多個線程同時訪問一段同步代碼時:

  1. 首先會進入 _EntryList 集合,當線程獲取到對象的 Monitor 後,進入 _Owner 區域並把 monitor 中的 owner 變量設置爲當前線程,同時 monitor中的計數器 count 加1;
  2. 若線程調用 wait() 方法,將釋放當前持有的 monitor,owner 變量恢復爲 null,count 自減1,同時該線程進入 WaitSet集合中等待被喚醒;
  3. 若當前線程執行完畢,也將釋放 Monitor 並復位 count 的值,以便其他線程進入獲取 Monitor;

這也解釋了爲什麼 notify()notifyAll()wait() 方法會要求在同步塊中使用,因爲這三個方法都需要獲取 Monitor 對象,而要獲取 Monitor,就必須使用 monitorenter指令

3.輕量級鎖與鎖記錄

根據鎖標誌位,我們瞭解到 10 表示爲指向 Monitor 對象的指針,是重量級鎖,而 00 是指向 LockRecord 的指針,是輕量級鎖。那麼,這個 LockRecord 又是什麼呢?

在線程的棧中,存在名爲 LockRecord (鎖記錄)的空間,這個是線程私有的,對應的還存在一個線程共享的全局列表。當一個線程去獲取鎖的時候,會將 Mark Word 中的鎖信息拷貝到 LockRecord 列表中,並且修改 MarkWord 的鎖標誌位爲指向對應 LockRecord 的指針

其中,Lock Record 中還保存了以下信息:

Lock Record 描述
Owner 初始時爲NULL表示當前沒有任何線程擁有該 monitor record,當線程成功擁有該鎖後保存線程唯一標識,當鎖被釋放時又設置爲 null;
EntryQ 關聯一個系統互斥鎖(semaphore),阻塞所有試圖鎖住 monitor record 失敗的線程;
RcThis 表示 blocked 或 waiting 在該 monitor record 上的所有線程的個數;
Nest 用來實現重入鎖的計數;
HashCode 保存從對象頭拷貝過來的 hashcode 值(可能還包含GC age)。
Candidate 用來避免不必要的阻塞或等待線程喚醒,因爲每一次只有一個線程能夠成功擁有鎖,如果每次前一個釋放鎖的線程喚醒所有正在阻塞或等待的線程,會引起不必要的上下文切換(從阻塞到就緒然後因爲競爭鎖失敗又被阻塞)從而導致性能嚴重下降。Candidate只有兩種可能的值0表示沒有需要喚醒的線程1表示要喚醒一個繼任線程來競爭鎖。

假如當有多個線程爭奪偏向鎖,或未開啓偏向鎖的前提下由無鎖進入加鎖狀態的時候,鎖會先升級爲輕量級鎖:

  • 虛擬機現在線程棧幀中建立 LockRecord 的空間,用於存儲鎖對象 MarkWord 的拷貝;
  • 拷貝完畢後,使用 CAS 操作嘗試將 MarkWord 中的 LockWord 字段改爲指向當前線程的 LockRecord 的指針,並且將 MarkWord 中的鎖標誌位改爲 00;
  • 如果更新失敗,就先檢查 LockWord 是否已經指向當前線程的 LockRecord 了,如果是說明已經獲取到鎖了,直接重入,否則說明還在競爭鎖,此時進入自旋等待;

其實這個有個疑問,爲什麼獲得鎖成功了而CAS失敗了?

這裏其實要牽扯到CAS的具體過程:先比較某個值是不是預測的值,是的話就動用原子操作交換(或賦值),否則不操作直接返回失敗。在用CAS的時候期待的值是其原本的MarkWord。發生“重入”的時候會發現其值不是期待的原本的MarkWord,而是一個指針,所以當然就返回失敗,但是如果這個指針指向這個線程,那麼說明其實已經獲得了鎖,不過是再進入一次。

4.偏向鎖

當我們使用 synchronized 加鎖了,但是實際可能存在並沒有多個線程去競爭的情況,這種情況下加鎖和釋放鎖會消耗無意義的資源。爲此,就有了偏向鎖,

所以,當一個線程訪問同步塊並獲取鎖時,會在 MarkWord 和棧幀中的 LockRecord 裏存儲鎖偏向的線程ID,以後該線程進入和退出同步塊時不需要花費 CAS 操作來爭奪鎖資源,只需要檢查是否爲偏向鎖、鎖標識爲以及 ThreadID是否爲當前線程的 ID 即可,處理流程如下:

  1. 檢測 MarkWord 是否爲偏向狀態,即是否爲偏向鎖1,鎖標識位爲01;
  2. 若爲偏向狀態,則檢查線程 ID 是否爲當前線程 ID,是則執行代碼塊;
  3. 如果測試線程 ID 不爲當前線程 ID,則通過 CAS 操作競爭鎖,競爭成功,則將 Mark Word 的線程 ID 替換爲當前線程 ID,否則說明存在鎖競爭,當到達全局安全點,獲得偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼塊;

偏向鎖的釋放採用了 一種只有競爭纔會釋放鎖的機制,線程是不會主動去釋放偏向鎖,需要等待其他線程來競爭。偏向鎖的撤銷需要等待全局安全點(這個時間點是上沒有正在執行的代碼)。其步驟如下:

  1. 暫停擁有偏向鎖的線程;
  2. 判斷鎖對象是否還處於被鎖定狀態,否,則恢復到無鎖狀態(01),以允許其餘線程競爭。是,則掛起持有鎖的當前線程,並將指向當前線程的鎖記錄地址的指針放入對象頭 Mark Word,升級爲輕量級鎖狀態(00),然後恢復持有鎖的當前線程,進入輕量級鎖的競爭模式;

三、鎖升級

在 JDK5 之前,synchronized 無論如何都會直接加 Monitor 鎖,實際上針對無鎖情況或者鎖競爭不激烈的情況,這樣會比較消耗性能,因此,在 JDK6 引入了鎖升級的概念,即:無鎖狀態-》偏向鎖狀態-》輕量級鎖狀態-》重量級鎖狀態的鎖升級的過程。

在 JVM 中,鎖升級是不可逆的,即一旦鎖被升級爲下一個級別的鎖,就無法再降級。

首先默認的無鎖狀態,當我們加鎖以後,可能並沒有多個線程去競爭鎖,此時我們可以默認爲只有一個線程要獲取鎖,即偏向鎖,當鎖轉爲偏向鎖以後,被偏向的線程在獲取鎖的時候就不需要競爭,可以直接執行。

當確實存在少量線程競爭鎖的情況時,偏向鎖顯然不能再繼續使用了,但是如果直接調用重量級鎖在輕量鎖競爭的情況下並不划算,因爲競爭壓力不大,所以往往需要頻繁的阻塞和喚醒線程,這個過程需要調用操作系統的函數去切換 CPU 狀態從用戶態轉爲核心態。因此,可以直接令等待的線程自旋,避免頻繁的阻塞喚醒。

當競爭加大時,線程往往要等待比較長的時間才能獲得鎖,此時在等待期間保持自旋會白白佔用 CPU 時間,此時就需要升級爲重量級鎖,即 Monitor 鎖,JVM 通過指令調用操作系統函數阻塞和喚醒線程。

四、鎖優化

我們瞭解了重量級鎖,輕量級鎖,偏向鎖的實現機制,實際上,除了鎖升級的過程,synchronized 還增加了其他針對鎖的優化操作。

1.自適應自旋鎖

自旋鎖依賴於 CAS,我們可以手動的設置 JVM 的自旋鎖自旋次數,但是往往很難確定適當的自旋次數,如果自旋次數太少,那麼可能會引起不必要的鎖升級,而自旋次數太長,又會影響性能。在 JDK6 中,引入了自適應自旋鎖的機制,對於同一把鎖,當線程通過自旋獲取鎖成功了,那麼下一次自旋次數就會增加,而相反,如果自旋鎖獲取失敗了,那麼下一次在獲取鎖的時候就會減少自旋次數。

2.鎖消除

在一些方法中,有些加鎖的代碼實際上是永遠不會出現鎖競爭的,比如 Vector 和 Hashtable 等類的方法都使用 synchronized 修飾,但是實際上在單線程程序中調用方法,JVM 會檢查是否存在可能的鎖競爭,如果不存在,會自動消除代碼中的加鎖操作。

3.鎖粗化

我們常說,鎖的粒度往往越細越好,但是一些不恰當的範圍可能反而引起更頻繁的加鎖解鎖操作,比如在迭代中加鎖,JVM 會檢測同一個對象是否在同一段代碼中被頻繁加鎖解鎖,從而主動擴大鎖範圍,避免這種情況的發生。

總結

synchronized 在 JDK6 以後,根據鎖升級機制分爲四種狀態:無鎖狀態,偏向鎖狀態,輕量級鎖狀態,重量級鎖狀態。這三種鎖都與鎖對象的對象頭中的 MarkWord 有關,不同的鎖狀態會在 MarkWord 有對應的不同的鎖標誌位。

偏向鎖的鎖標誌位爲 01,通過將 MarkWord 中的線程 ID 改爲偏向線程 ID 實現。

輕量級鎖基於自旋鎖,通過拷貝 MarkWord 到線程私有的 LockRecord 中,並且 CAS 改變對象的 LockWord 爲指向線程 LockRecord 的指針來實現。

重量級鎖即原本的監視器鎖,基於 JVM 的 Monitor 對象實現,通過將對象的 LockWord 指向對應的 ObjectMonitor 對象,並且通過 ObjectMonitor 中的阻塞隊列,等待隊列以及當前持有鎖的線程指針等參數來實現。

除了鎖升級以外,JVM 還會引入了自適應自旋鎖,鎖消除,鎖粗化等鎖優化機制。

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