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 record的obj字段設置為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底層實現–重量級鎖


