synchronized—深入總結

  • 2020 年 1 月 16 日
  • 筆記

synchronized

傳統的鎖(也就是下文要說的重量級鎖)依賴於系統的同步函數,在linux上使用mutex互斥鎖,這些同步函數都涉及到用戶態和內核態的切換、進程的上下文切換,成本較高。對於加了synchronized關鍵字但運行時並沒有多執行緒競爭,或兩個執行緒接近於交替執行的情況,使用傳統鎖機制無疑效率是會比較低的。

在JDK 1.6之前,synchronized只有傳統的鎖機制,因此給開發者留下了synchronized關鍵字相比於其他同步機制性能不好的印象。在JDK 1.6引入了兩種新型鎖機制:偏向鎖和輕量級鎖,它們的引入是為了解決在沒有多執行緒競爭的場景下因使用傳統鎖機制帶來的性能開銷問題。

記憶體語義

關於鎖我們知道它可以讓臨界區互斥,但它還有另一個重要功能,鎖的記憶體語義。

當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數刷新到主記憶體中。 當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。

執行緒A釋放一個鎖,實質上是執行緒A向接下來將要獲取這個鎖的某個執行緒發出了(執行緒A對共享變數所做修改的)消息。 執行緒B獲取一個鎖,實質上是執行緒B接收了之前某個執行緒發出的(在釋放這個鎖之前對共享變數所做修改的)消息。 執行緒A釋放鎖,隨後執行緒B獲取這個鎖,這個過程實質上是執行緒A通過主記憶體向執行緒B發送消息。

每個對象作為鎖

java中每個對象都可以作為鎖。

  • 對於普通方法:鎖是當前實例對象
  • 對於靜態方法:鎖是當前類的class對象
  • 對於同步方法塊:鎖是synchronized括弧裡面的對象

實現原理

JVM基於進入和退出Monitor(監視器)對象來實現方法同步和程式碼塊同步,但兩者的實現細節不一樣。

程式碼塊的同步是使用monitorenter和monitorexit指令實現的,而方法的同步是依靠方法修飾符上的ACC_SYNCHRONIZED來完成的。無論採取哪種方式,其本質都是對一個對象的監視器進行獲取,而這個獲取過程是排他的,也就是同一時刻只能有一個執行緒獲取到synchronized所保護對象的監視器。

monitorenter指令是在編譯後插入到程式碼塊的起始位置,monitorexit指令插入到程式碼塊結束和異常的位置。

JVM保證monitorenter一定會有一個monitorexist對應,不然就死鎖了。

當執行緒執行到monitorenter指令時,將會嘗試獲取對象關聯的monitor(監視器)的所有權,即嘗試獲取對象的鎖。

每一個對象都有一個monitor(監視器)與之關聯。當一個monitor被持有後,它將處於鎖定狀態。當執行緒執行到monitorenter,執行緒將嘗試獲取對象關聯的monitor的所有權,即嘗試獲取對象的鎖。

執行緒獲取到對象的監視器,才能進入同步程式碼塊,而沒有獲取到監視器的執行緒則被阻塞到同步程式碼塊入口處,進入BLOCKED狀態。

下圖描述了對象、對象的監視器、同步隊列、執行執行緒之間的關係:

從圖中可以看到,任意執行緒對Object對象的訪問,首先需要獲取Object的監視器,如果獲取失敗,執行緒進入同步隊列,執行緒狀態變為BLOCKED。當上一個獲取了鎖的執行緒,釋放了鎖,則該釋放操作會喚醒在同步隊列中的執行緒,使其重新嘗試對監視器的獲取。

Java對象頭

在JVM中,對象在記憶體中除了本身的數據外還會有個對象頭,對於普通對象而言,其對象頭中有兩類資訊:mark word和類型指針。另外對於數組而言還會有一份記錄數組長度的數據。

synchronize使用的鎖是存儲在java對象頭裡面的。

  • 如果對象是數組類型,則虛擬機用3字寬存儲對象頭(多出的1字寬用於存儲數組的長度)
  • 如果對象是非數組類型,則虛擬機使用2字寬存儲對象頭。

在32位虛擬機中,1字寬等於4位元組。

java對象頭的mark world默認存儲對象的HashCode、分代年齡、鎖標誌位。32位的JVM的mark world存儲結構如圖:

在運行期間,mark world裡面存儲的數據會隨著鎖標誌位的變化而變化,如圖:

在64位虛擬機下,Mark World是64bit大小,其存儲結構如下:

鎖升級與對比

Java SE1.6為了減少獲得鎖和釋放鎖帶來的性能損耗,引入了「偏向鎖」和「輕量級」鎖。在java SE 1.6中,鎖一共有4種狀態,級別從高到底依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態。這幾個狀態會隨著鎖競爭加劇而逐漸升級。

**鎖可以升級,但不能降級。**意味著偏向鎖升級為輕量級鎖之後,不能降級為偏向鎖。為什麼這樣子做呢?這樣子是為了提高獲取鎖和釋放鎖的效率。 其實很好理解,如果鎖升級了,證明這塊同步區域將來也很有可能面臨鎖競爭,達到這個鎖的級別,如果目前沒有競爭,就把鎖降級的話,將來產生同樣的鎖競爭,又將進行鎖升級,就會降低獲取鎖的效率。

偏向鎖

HotSpot的作者經研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,並且總是由同一個執行緒獲得。這樣子每次獲取鎖都進行同步,代價也太大了。為了讓執行緒獲取鎖的代價更低而引入了偏向鎖。

對象創建

當JVM啟用了偏向鎖模式(1.6以上默認開啟),新創建一個對象的時候,那新創建對象的mark word將是可偏向狀態,此時mark word中的thread id為0,表示未偏向任何執行緒,也叫做匿名偏向(anonymously biased)。

偏向鎖的獲取

  • 當該對象第一次被執行緒獲得鎖的時候,發現是匿名偏向狀態(鎖沒有偏向哪個執行緒),則會用CAS指令,將mark word中的thread id由0改成當前執行緒Id。如果成功,則代表獲得了偏向鎖,繼續執行同步塊中的程式碼。否則,將偏向鎖撤銷,升級為輕量級鎖。
  • 當被偏向的執行緒再次進入同步塊時,發現鎖對象偏向的就是當前執行緒,會往當前執行緒的的棧幀中添加一條Displaced Mark Word為空的Lock Record,並且將Lock Record的obj欄位指向鎖對象,然後繼續執行同步塊的程式碼,因為操縱的是執行緒私有的棧,因此不需要用到CAS指令;由此可見偏向鎖模式下,當被偏向的執行緒再次嘗試獲得鎖時,僅僅進行幾個簡單的操作就可以了,在這種情況下,synchronized關鍵字帶來的性能開銷基本可以忽略。
  • 當其他執行緒嘗試進入同步塊時,發現鎖對象已經有偏向的執行緒了,則會進入到撤銷偏向鎖的邏輯里,一般來說,會在safepoint中去查看偏向的執行緒是否還存活,
    • 如果存活且還在同步塊中則將鎖升級為輕量級鎖,原偏向的執行緒繼續擁有鎖,當前執行緒則走入到鎖升級的邏輯里;
    • 如果偏向的執行緒已經不存活或者不在同步塊中,則將對象頭的mark word改為無鎖狀態(unlocked),之後再升級為輕量級鎖。

由此可見,偏向鎖升級的時機為:當鎖已經發生偏向後,只要有另一個執行緒嘗試獲得偏向鎖,則該偏向鎖就會升級成輕量級鎖。當然這個說法不絕對,因為還有批量重偏向這一機制。

釋放鎖過程

釋放鎖指的是執行緒退出同步塊的時候,釋放偏向鎖。

當有其他執行緒嘗試獲得鎖時,是根據遍歷偏向執行緒的lock record來確定該執行緒是否還在執行同步塊中的程式碼

,如果lock record中obj欄位指向鎖對象,那麼其他執行緒就認為該執行緒還在執行同步塊程式碼。

因此偏向鎖的釋放(解鎖)很簡單,僅僅將棧中的最近一條lock recordobj欄位設置為null。需要注意的是,偏向鎖的解鎖步驟中並不會修改對象頭中的thread id。

偏向鎖的撤銷

偏向鎖的撤銷指執行緒在獲取偏向鎖的時候失敗了,導致要將鎖對象改為非偏向鎖狀態,升級為輕量級鎖

  • 會在safepoint中去查看偏向的執行緒是否還存活
    • 如果存活且還在同步塊中則將鎖升級為輕量級鎖,原偏向的執行緒繼續擁有鎖,當前執行緒則走入到鎖升級的邏輯里(當先執行緒會嘗試獲取輕量級鎖,如果獲取不成功,就只能獲取重量級鎖導致阻塞)
    • 如果偏向的執行緒已經不存活或者不在同步塊中,則將對象頭的mark word改為無鎖狀態(unlocked),之後再升級為輕量級鎖。

下圖是發生競爭的情況下,進行偏向鎖的撤銷:

  • 判斷Mark Word中是否有指向自己的執行緒ID
  • 否,則判斷當前鎖是否為偏向鎖
    • 是。那麼執行緒2會用CAS替換來嘗試獲取鎖。 CAS替換Mark Word成功表示獲取偏向鎖成功,這裡由於對象頭中Mark Word已經指向了執行緒1,所以替換失敗,需要進行撤銷操作
  • 撤銷的時候需要暫停執行緒1。
    • 如果執行緒1已經終止了,則將鎖對象的對象頭設置為無鎖狀態
    • 如果執行緒1還未終止,喚醒執行緒1

關閉偏向鎖

偏向鎖是默認開啟的,而且開始時間一般是比應用程式啟動慢幾秒,如果不想有這個延遲,那麼可以使用-XX:BiasedLockingStartUpDelay=0; 如果不想要偏向鎖,那麼可以通過-XX:-UseBiasedLocking = false來設置;

輕量級鎖

輕量級加鎖

  • 執行緒在執行同步塊之前,JVM會先在當前執行緒的棧楨中創建一個用於存儲鎖記錄Lock Record的空間,並將對象頭中的Mark Word拷貝到鎖記錄中,官方稱為Displaced Mark Word。然後執行緒嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。
    • Lock Record包含用於存儲對象頭的mark work,並且另外還有一個指向對象的指針
  • 如果當前鎖的狀態是無鎖狀態,則CAS成功,當前執行緒獲得鎖
  • 如果當前鎖的狀態不是無鎖狀態,則CAS失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。
  • 如果使用自旋鎖也獲取不到鎖,那麼它就會修改markword,標識為重量級鎖,表示該升級為重量鎖了。

等待輕量鎖的執行緒不會阻塞,它會自旋等待一段時間。這就是自旋鎖。

嘗試獲取鎖的執行緒,在沒有獲得鎖的時候,不被掛起,而轉而去執行一個空循環,即自旋。在若干個自旋後,如果還沒有獲得鎖,則才被掛起。

獲得鎖,則執行程式碼。雖然自旋可以防止阻塞,節省從內核態到用戶態的開銷,但是如果長時間自旋,則會導致CPU長時間做一個同樣的無用循環操作。浪費CPU的資源。這時候引入了自適應自旋。

#####自適應自旋鎖

此操作為了防止長時間的自旋,在自旋操作上加了一些限制條件。

  • 比如一開始給執行緒自旋的時間是10秒,如果執行緒在這個時間內獲得了鎖,那麼就認為這個執行緒比較容易獲得鎖,就會適當的加長它的自旋時間。
  • 如果這個執行緒在規定時間內沒有獲得到鎖,並且阻塞了。那麼就認為這個執行緒不容易獲得鎖,下次當這個執行緒進行自旋的時候會減少它的自旋時間

輕量級解鎖

輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭。

  • 如果成功,則表示沒有競爭發生。成功替換,等待下一個執行緒獲取鎖。
  • 如果失敗,表示當前鎖存在競爭,鎖就會升級為重量級鎖。

因為自旋會消耗CPU,為了避免過多的自旋,一旦鎖升級成重量級鎖,就不會再 恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他執行緒試圖獲取鎖時,都會被阻塞住,當持有鎖的執行緒釋放鎖之後 會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的奪鎖之爭。

下圖是兩個鎖同時競爭,導致鎖升級的流程圖:

因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的執行緒被阻塞住了),一旦鎖升級成重量級鎖,就不會再 恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他執行緒試圖獲取鎖時,都會被阻塞住,當持有鎖的執行緒釋放鎖之後 會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的奪鎖之爭。

重量級鎖

重量級鎖的狀態下,對象的mark word為指向一個monitor對象的指針。

一個monitor對象包括這麼幾個關鍵欄位:cxq(下圖中的ContentionList),EntryList ,WaitSet,owner。

其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的鏈表結構,owner指向持有鎖的執行緒。

  • 重量級鎖是JVM中為基礎的鎖實現。在這種狀態下,JVM虛擬機會阻塞加鎖失敗的執行緒,並且在目標鎖被釋放的時候,喚醒這些執行緒。
  • Java執行緒的阻塞以及喚醒,都是依靠作業系統來完成的。舉例來說,對於符合posix介面的作業系統(如macOS和絕大 部分的Linux),上述操作通過pthread的互斥鎖(mutex)來實現的。此外,這些操作將涉及系統調用,需要從作業系統的用戶態切換至內核態,其開銷非常之大。
  • 為了盡量避免昂貴的執行緒阻塞、喚醒操作,JVM會在執行緒進入阻塞狀態之前,以及被喚醒之後競爭不到鎖的情況 下,進入自旋狀態,在處理器上空跑並且輪詢鎖是否被釋放。如果此時鎖恰好被釋放了,那麼當前執行緒便無須進入阻塞狀態,而是直接獲得這把鎖。

鎖的對比

偏向鎖

  • 優點:
    • 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級別的差距
  • 缺點:
    • 如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗
  • 適用場景
    • 適用於只有一個執行緒訪問同步塊的場景

輕量級鎖

  • 優點:
    • 競爭的執行緒不會阻塞,提高了程式的響應速度
  • 缺點:
    • 自旋時間過長,會消耗CPU
  • 適用場景:
    • 適用於同步塊執行速度非常快的場景
    • 追求響應時間

重量級鎖

  • 優點:
    • 執行緒競爭不會自旋,不消耗CPU
  • 缺點:
    • 執行緒阻塞,響應時間緩慢
  • 適用場景:
    • 適用於同步塊執行速度較慢的場景

ReentrantLock的區別

  • ReentrantLock支援等待超時,可以有效避免死鎖
  • ReentrantLock支援中斷
  • ReentrantLock支援公平鎖,也就是按照FIFO的順序獲取鎖
  • ReentrantLock支援綁定Condition對象
  • 在資源競爭不是很激烈的情況下,synchronize使用的是偏向鎖,效率較高。而ReentrantLock總是會阻塞執行緒。

總結

Java虛擬機中synchronized關鍵字的實現,按照代價由高到低可以分為重量級鎖、輕量鎖和偏向鎖三種。

  • 重量級鎖會阻塞、喚醒請求加鎖的執行緒。它針對的是多個執行緒同時競爭同一把鎖的情況。JVM採用了自適應自旋,來避免執行緒在面對非常小的synchronized程式碼塊時,仍會被阻塞、喚醒的情況。
  • 輕量級鎖採用CAS操作,將鎖對象的mark world替換為一個指針,指向當前執行緒棧上的一塊空間(鎖記錄),存儲著鎖對象原本的mark world。它針對的是多個執行緒在不同時間段申請同一把鎖的情況
  • 偏向鎖只會在第一次請求時採用CAS操作,在鎖對象的標記欄位中記錄下當前執行緒的記憶體地址。在之後的運行過程中,持有該偏向鎖的執行緒的加鎖操作將直接返回。它針對的是鎖僅會被同一執行緒持有的情況

問題

輕量級鎖為什麼要使用自旋鎖?

為了避免調用系統的同步函數,造成用戶態和內核態的切花

自旋鎖適用於什麼場景?

執行緒的任務執行時間比較短的場景

參考

《並發編程藝術》

死磕Synchronized底層實現–概論

死磕Synchronized底層實現–偏向鎖

synchronzied和ReentrantLock區別

[死磕Synchronized底層實現–重量級鎖