多線程與高并發(fā)(二)—— Synchronized 加鎖解鎖流程
前言
上篇主要對 Synchronized 的鎖實現(xiàn)原理 Monitor 機制進(jìn)行了介紹,由于 Monitor 基于操作系統(tǒng)調(diào)用,上下文切換導(dǎo)致開銷大,在競爭不激烈時性能不算很好, 在 jdk6 之后進(jìn)了系列優(yōu)化。前文對優(yōu)化措施進(jìn)行了簡單介紹,下面將一一介紹這些優(yōu)化的細(xì)節(jié),行文思路大致如下:
- 從重量級鎖的優(yōu)化開始講,一是自旋鎖,二是盡量避免進(jìn)入 Monitor ,即使用輕量級鎖
- 講解輕量級鎖及加鎖解鎖流程
- 輕量級鎖在沒有競爭時,每次重入仍然需要執(zhí)行cas操作,為解決這個問題,因而產(chǎn)生了偏向鎖
- 詳細(xì)介紹偏向鎖
Synchronized 鎖的細(xì)節(jié)
一、自旋鎖
自旋鎖比較簡單,邏輯在上篇也已經(jīng)進(jìn)行過闡述,這一篇章我們著重看下它的性能如何?
在競爭度較小的時候,重量級鎖的上下文切換導(dǎo)致的開銷相對于 CPU 處理任務(wù)的時間占比較重,此種情況下,自旋鎖的性能有優(yōu)勢,因自旋而導(dǎo)致的 CPU 浪費在可接受范圍內(nèi);當(dāng)競爭激烈的時候,繼續(xù)使用自旋鎖則得不償失,性能上比直接使用重量級鎖要差,大量的等待鎖的時間被浪費。
根據(jù)任務(wù)處理時間不同,自旋鎖表現(xiàn)也不一,在任務(wù)持續(xù)時間長的情況下,自旋太久顯然是對 CPU 時間片的浪費,且因任務(wù)持續(xù)時間長,在 10 此默認(rèn)自旋次數(shù)的情況下,易出現(xiàn)自旋結(jié)束也無法獲取到鎖,那么此次空轉(zhuǎn)就是毫無收益的性能浪費。在任務(wù)處理時間較短的情況下,顯然自旋獲得鎖的幾率要大,因此如果對要執(zhí)行的任務(wù)有很明確的處理時長認(rèn)知,可以根據(jù)情況適當(dāng)?shù)恼{(diào)整初始自旋次數(shù),JVM 參數(shù)為:-XX:PreBlockSpin。
二、輕量級鎖
根據(jù)觀察,多線程中并不總是存在著競爭,使用輕量級鎖避免了鎖 Monitor 這繁重的數(shù)據(jù)結(jié)構(gòu),輕量級鎖通常只鎖一個字段(鎖記錄),在 HotSpot 中的實現(xiàn)是在當(dāng)前線程的棧幀中創(chuàng)建鎖記錄結(jié)構(gòu)(Lock Record)。
1.輕量級鎖加鎖流程
- 在當(dāng)前線程的棧幀中創(chuàng)建 Lock Record
- 構(gòu)建一個無鎖狀態(tài)的 Displaced Mark Word
- 將 Displaced Mark Word 存儲到 Lock Record 中的 _displaced_header 屬性
- CAS 更新 Displaced Mark Word 指針,注意【3】是將 Lock record 的 header 的值設(shè)置成一個 displaced mark word,【4】這一步是將當(dāng)前對象頭的 Mark Word 中的高30 位(全文都是只針對 32 位虛擬機來談)指向 Lock Record 中的 header。
4.1 CAS 成功,執(zhí)行同步代碼塊
4.2 CAS 失敗,存在兩種情況
3.2.1 判斷是否為鎖重入(關(guān)于輕量級鎖的可重入有疑問,見下文)
3.2.2 鎖被其他線程占有,需要競爭鎖,進(jìn)入鎖膨脹過程 - 加鎖成功的話,當(dāng)前對象的 Mark Word 后兩位鎖標(biāo)志位置為 00,余下高位作為指針存儲 Lock Record 的地址
輕量級鎖加鎖源碼如下:
// traditional lightweight locking
if (!success) {
// markOop就是對象頭結(jié)構(gòu), 生成對象頭,這個對象頭的狀態(tài)設(shè)置為無鎖,生成的這個對象頭就是displaced Mark word
markOop displaced = lockee->mark()->set_unlocked();
// 將 displaced Mark word 設(shè)置到 lock record 的 _displaced_header 字段
entry->lock()->set_displaced_header(displaced);
// 判斷JVM參數(shù)-XX:+UseHeavyMonitors 是否設(shè)置了只有重量級鎖
bool call_vm = UseHeavyMonitors;
// cmpxchg_ptr即 cas 交換指令,將當(dāng)前對象頭的 Mark Word 中的高30 位指向 Lock Record 中的 header 使用重量級鎖或者CAS 失敗進(jìn)入這個if塊
if (call_vm || Atomic::cmpxchg_ptr(entry, lockee->mark_addr(), displaced) != displaced) {
// Is it simple recursive case? 是否為鎖重入
if (!call_vm && THREAD->is_lock_owned((address) displaced->clear_lock_bits())) {
entry->lock()->set_displaced_header(NULL);
} else {
CALL_VM(InterpreterRuntime::monitorenter(THREAD, entry), handle_exception);
}
}
}
2.輕量級鎖重入的疑問

關(guān)于輕量級鎖的重入,實現(xiàn)方式主要有兩種,一是如 Monitor 一樣通過一個變量來計數(shù),二是每重入一次都生成一個 Lock Record,對Lock Record 的個數(shù)計數(shù)來隱士計數(shù)。在查找資料的過程中發(fā)現(xiàn),大部分的說法是 HotSpot 選擇第二種實現(xiàn)方式。
- 第一個疑問是為何會選擇第二種實現(xiàn)方式,是否對空間造成了一定的浪費,生成 Lock Record 相比整型加一操作性能消耗應(yīng)該也更大,不知道 HotSpot 作何考量選擇此種方式。下圖為此種實現(xiàn)方式下的輕量級鎖結(jié)構(gòu)。
![]()


3.輕量級鎖解鎖流程如下(基于使用lock record重入計數(shù)的情況):
- 遍歷棧的Lock Record,如果_displaced_header 為 NULL,表明鎖是可重入的,跳過不作處理
- 如果_displaced_header 不為 NULL,即最后一個鎖記錄,調(diào)用 CAS 將 _displaced_header 恢復(fù)到當(dāng)前對象頭,解鎖成功
偏向鎖
It also follows the principle of optimizing com- mon cases. The observation exploited is the biased distri- bution of lockers called thread locality. That is, for a given object, the lock tends to be dominantly acquired and re- leased by a specific thread, which is obviously the case in single-threaded applications [2]
根據(jù)觀察結(jié)果來看,多線程下很多時候會出現(xiàn)以下情況:一個線程在頻繁的釋放和加鎖;即多線程實際上已經(jīng)退化成了單線程線性運行,在這種情況下,減少 CAS 這種原子操作,也能提高性能。偏向鎖的原理是為線程保留鎖,Mark Word 中存儲 ThreadId,只有第一次需要進(jìn)行 CAS 操作將這個字段設(shè)置為當(dāng)前線程的線程ID,后續(xù)加鎖的時候只需要查看 ThreadId 是否指向自己,而輕量級鎖每次鎖字段都需要進(jìn)行 CAS 操作。
1.偏向鎖加鎖流程如下:
- 檢查鎖是否可偏向,對象頭低位倒數(shù)第三位為1(即后三位的值為 0x5)表明可偏向
- 如果可偏向,首先判斷 Mark Word 的內(nèi)容是否是當(dāng)前線程ID,
2.1 是,執(zhí)行同步代碼
2.2 不是,執(zhí)行 CAS 將 Mark Word 的高位設(shè)置為當(dāng)前線程ID, CAS 執(zhí)行分以下情況:
2.2.1 執(zhí)行成功,則加鎖成功
2.2.2 執(zhí)行失敗,說明此鎖已經(jīng)偏向了其他線程,因為產(chǎn)生了競爭所以撤銷偏向鎖,進(jìn)入輕量級鎖加鎖流程

2.偏向鎖與 HashCode 的關(guān)系
由于Hash碼必須是唯一的,即 hashcode() 方法只能被調(diào)用一次,因此產(chǎn)生了以下規(guī)則來保證hash code 的唯一性:
- HashCode 是懶加載,當(dāng)調(diào)用 hashcode() 方法的時候,生成的 hash code 才會保存到對象頭指定位置
- 當(dāng)一個對象已經(jīng)調(diào)用過 hashcode() 方法,那么偏向鎖狀態(tài)會置為0,無法進(jìn)入偏向鎖狀態(tài),直接進(jìn)入輕量級鎖
- 如果一個對象現(xiàn)在已經(jīng)處于偏向鎖狀態(tài),在同步代碼塊中需要執(zhí)行 hashcode() 方法,則偏向鎖會撤銷,進(jìn)入重量級鎖
3.偏向鎖狀態(tài)下 Mark World 的情況
在無鎖狀態(tài)下,如果沒有調(diào)用 hashcode() ,高 25 位未使用,如果調(diào)用過 hashcode(),則保存的是 hash code,只有當(dāng)高 25 位未使用時,才能進(jìn)入偏向鎖,mark word 保存獲取到鎖的線程ID,當(dāng)鎖撤銷的時候,恢復(fù)為無鎖狀態(tài),即高 25 位為 NULL。

4.偏向鎖的可重入
在一些博客中發(fā)現(xiàn)以下說法:使用偏向鎖時,每重入一次創(chuàng)建一個Lock Record。這個說法毫無疑問是錯誤的,偏向鎖在重入的時候只檢查 ThreadId,是自己的線程 Id 就可以執(zhí)行同步代碼塊;解鎖則只需要看是否是偏向模式,因此完全沒有必要進(jìn)行重入計數(shù),生成 Lock Record 來計數(shù)就更沒有這個必要了。
5.什么時候偏向鎖不可用
- 升級為輕量級鎖之后,當(dāng)一個線程持有鎖,另一個線程來競爭鎖的時候 CAS 失敗,就會將低三位 101 設(shè)置為 001,即不可偏向,這也是鎖可升級不可降級的原因。
- 發(fā)生了批量撤銷后,就不會再進(jìn)入偏向鎖了
6.JDK 15 偏向鎖已經(jīng)被禁用
JDK 15 開始默認(rèn)不使用偏向鎖,且相關(guān)的命令行指令被標(biāo)記為過時

Biased locking introduced a lot of complex code into the synchronization subsystem and is invasive to other HotSpot components as well. This complexity is a barrier to understanding various parts of the code and an impediment to making significant design changes within the synchronization subsystem. To that end we would like to disable, deprecate, and eventually remove support for biased locking.[3]
偏向鎖在同步子系統(tǒng)中引入了許多復(fù)雜的代碼,并且還侵入了其他 HotSpot 組件。這種復(fù)雜性造成了對代碼各個部分的理解障礙,也阻礙了同步子系統(tǒng)進(jìn)行重大設(shè)計更改。為此,我們希望禁用、棄用并最終移除對偏向鎖的支持。
偏向鎖的撤銷
1.何為撤銷?
撤銷是指當(dāng)對象處于偏向鎖模式的時候,不再使用偏向鎖,且標(biāo)記不可偏向(低位 001);注意撤銷不是常規(guī)意義上的解鎖,偏向鎖的解鎖是當(dāng)鎖處于偏向鎖狀態(tài)時,同步塊執(zhí)行完畢,需要對鎖進(jìn)行釋放,只需要檢查是否處于已偏向(此處檢查兩個參數(shù),一是偏向位即低位倒數(shù)第三位為1,為 1 即表示可偏向也可表示已偏向,故還需要檢查 ThreadID 不為空),如果處于偏向鎖模式,則直接 return 釋放鎖成功。撤銷與解鎖的區(qū)別是撤銷需要將偏向鎖標(biāo)識位置為0,標(biāo)記該對象不可偏向。
撤銷操作不是必須在安全點操作,首先會嘗試在不安全點使用 CAS 操作修改 Mark Word 為無鎖狀態(tài),如果嘗試失敗會等待在安全點(JVM 概念)撤銷,等待安全點的操作開銷很大,即需要STW。
2.觸發(fā)撤銷的條件
- 線程 A 首先獲取了偏向鎖,此時來了線程 B 嘗試對鎖偏向,發(fā)現(xiàn)鎖已經(jīng)被偏向 A 線程,B 線程會觸發(fā)鎖的偏向撤銷并進(jìn)一步膨脹成輕量級鎖。
- 觸發(fā)了批量撤銷
- 調(diào)用 wait()/notify() 觸發(fā)重量級鎖
3. 什么是批量重偏向
當(dāng)一個類產(chǎn)生了大量對象,在線程 A 訪問這些對象時,所有對象偏向A,線程 A 釋放鎖后,線程 B 訪問這些所有對象,每個對象都會觸發(fā)鎖的撤銷升級成輕量級鎖,這個撤銷的次數(shù)達(dá)到一定閾值(默認(rèn)20次),JVM 就會把該類產(chǎn)生的所有對象的偏向狀態(tài)偏向到 B,這就是批量重偏向。批量重偏向的重點即避免進(jìn)入輕量級鎖,由于 A B的競爭導(dǎo)致多個對象都進(jìn)入了輕量級鎖,而通過撤銷的閾值判斷發(fā)現(xiàn)大多數(shù)線程都偏向了 B,那么只需要將此類的所有對象都修改成偏向 B 就可以大概率的避免進(jìn)入輕量級鎖。
舉個例子:一個類一共生成了30個對象,A 線程訪問了 30 個,這 30 個對象都偏向 A,接著 B 只訪問了 25 個對象,前20個對象都由于競爭升級成了輕量級鎖,由于超過閾值 20 觸發(fā)了批量重偏向,后續(xù) 10 個對象的偏向線程 ID 也被修改為線程 B,線程 B 訪問第 21 個之后的對象都只需要使用偏向鎖,無需使用輕量級鎖。
4. 批量撤銷
與批量重偏向同理,都是某個類的對象頻繁撤銷鎖偏向,撤銷次數(shù)達(dá)到一定閾值(默認(rèn)40次),就會觸發(fā)以下操作:將次類的對象的偏向鎖標(biāo)記置為 0,即鎖不再可偏向,新建的對象也是不可偏向的,若再次發(fā)生鎖競爭,直接進(jìn)入輕量級鎖。
可以看到,批量重偏向和批量撤銷的操作是都是對撤銷操作的優(yōu)化,批量重偏向是第一階段的優(yōu)化,批量撤銷是在第一階段優(yōu)化沒有奏效的情況下第二階段的優(yōu)化,所以很明顯,批量撤銷的閾值應(yīng)該設(shè)置的比批量重偏向的大。
三、結(jié)語
Synchronized 的原理和優(yōu)化就暫且講到這,兩篇文章主要都是對概念的介紹、各個狀態(tài)的鎖的結(jié)構(gòu)介紹和闡述簡化后的流程。還有諸多細(xì)節(jié)沒有進(jìn)行敘述,例如重量級鎖就沒有講到重入、鎖膨脹的過程、偏向撤銷的流程、串聯(lián)起來整個加鎖的流程,如此種種細(xì)節(jié)皆略過了,一是精力有限,二是水平有限(個人拙見,要理清這些細(xì)節(jié)和流程必須自己親自看源碼,然現(xiàn)階段讀 C ++ 源碼略為吃力),故而等后續(xù)有更多的理解后再補充。文中必然有疏漏或是錯誤,若有發(fā)現(xiàn),還請海涵并指正。
Reference
[1] Evaluating and improving biased locking in the HotSpot virtual machine.
[2] Lock Reservation: Java Locks Can Mostly Do Without Atomic Operations
[3] [JEP 374: Deprecate and Disable Biased Locking]: https://openjdk.org/jeps/374


浙公網(wǎng)安備 33010602011771號