概述
說起多線程同步,一般的方案就是加鎖,而在 java 中,提到加鎖就想起 juc 包提供的 Lock 接口實現類與默認的關鍵字 synchronized 。我們常聽到,juc 下的鎖大多基於 AQS,而 AQS 的鎖機制基於 CAS,相比起 CAS 使用的自旋鎖,Synchronized 是一種重量級的鎖實現。
實際上,在 JDK6 之後,synchronized 逐漸引入了鎖升級機制,它將會有一個從輕量級到重量級的逐步升級的過程。本文將簡單的介紹 synchronized 的底層實現原理,並且介紹 synchronized 的鎖升級機制。
一、synchronized 的底層實現
synchronized 意爲同步,它可以用於修飾靜態方法,實例方法,或者一段代碼塊。
它是一種可重入的對象鎖。當修飾靜態方法時,鎖對象爲類;當修飾實例方法時,鎖對象爲實例;當修飾代碼塊時,鎖可以是任何非 null 的對象。
由於其底層的實現機制,synchronized 的鎖又稱爲監視器鎖。
1.同步代碼塊
當我們反編譯一個含有被 synchronized 修飾的代碼塊的文件時,我們可以看到類似如下指令:
這裏的 monitorenter 與 monitorexit 即是線程獲取 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 修飾的方法,在反編譯以後我們可以看到如下指令:
在 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字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊;
其中,對象頭又分爲三部分:MarkWord,類型指針,數組長度(如果是數組的話)。
MarkWord 是一個比較重要的部分,它存儲了對象運行時的大部分數據,如:hashcode、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等。
根據機器的不同,MarkWord 可能有 64 位或者 32 位,MarkWord 會隨着對象狀態的改變而改變,一般來說,結構是這樣的:
值得注意的是:
對象頭的最後兩位存儲了鎖的標誌位,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 對象的線程,當多個線程同時訪問一段同步代碼時:
- 首先會進入
_EntryList
集合,當線程獲取到對象的 Monitor 後,進入 _Owner 區域並把 monitor 中的 owner 變量設置爲當前線程,同時 monitor中的計數器 count 加1;- 若線程調用 wait() 方法,將釋放當前持有的 monitor,owner 變量恢復爲 null,count 自減1,同時該線程進入
WaitSet
集合中等待被喚醒;- 若當前線程執行完畢,也將釋放 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 即可,處理流程如下:
- 檢測 MarkWord 是否爲偏向狀態,即是否爲偏向鎖1,鎖標識位爲01;
- 若爲偏向狀態,則檢查線程 ID 是否爲當前線程 ID,是則執行代碼塊;
- 如果測試線程 ID 不爲當前線程 ID,則通過 CAS 操作競爭鎖,競爭成功,則將 Mark Word 的線程 ID 替換爲當前線程 ID,否則說明存在鎖競爭,當到達全局安全點,獲得偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼塊;
偏向鎖的釋放採用了 一種只有競爭纔會釋放鎖的機制,線程是不會主動去釋放偏向鎖,需要等待其他線程來競爭。偏向鎖的撤銷需要等待全局安全點(這個時間點是上沒有正在執行的代碼)。其步驟如下:
- 暫停擁有偏向鎖的線程;
- 判斷鎖對象是否還處於被鎖定狀態,否,則恢復到無鎖狀態(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 還會引入了自適應自旋鎖,鎖消除,鎖粗化等鎖優化機制。