寫在前面
今天我們來聊聊 Synchronized 裏面的各種鎖:偏向鎖、輕量級鎖、重量級鎖,以及三個鎖之間是如何進行鎖膨脹的。先來一張圖來總結
提前瞭解知識
鎖的升級過程
鎖的狀態總共有四種:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖(但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級)
Java 對象頭
因爲在Java中任意對象都可以用作鎖,因此必定要有一個映射關係,存儲該對象以及其對應的鎖信息(比如當前哪個線程持有鎖,哪些線程在等待)。一種很直觀的方法是,用一個全局map,來存儲這個映射關係,但這樣會有一些問題:需要對map做線程安全保障,不同的synchronized之間會相互影響,性能差;另外當同步對象較多時,該map可能會佔用比較多的內存。所以最好的辦法是將這個映射關係存儲在對象頭中,因爲對象頭本身也有一些hashcode、GC相關的數據,所以如果能將鎖信息與這些信息共存在對象頭中就好了。
在JVM中,對象在內存中除了本身的數據外還會有個對象頭,對於普通對象而言,其對象頭中有兩類信息:mark word和類型指針。另外對於數組而言還會有一份記錄數組長度的數據。類型指針是指向該對象所屬類對象的指針,mark word用於存儲對象的HashCode、GC分代年齡、鎖狀態等信息。在32位系統上mark word長度爲32bit,64位系統上長度爲64bit。爲了能在有限的空間裏存儲下更多的數據,其存儲格式是不固定的,在32位系統上各狀態的格式如下:
可以看到鎖信息也是存在於對象的mark word中的。當對象狀態爲偏向鎖(biasable)時,mark word存儲的是偏向的線程ID;當狀態爲輕量級鎖(lightweight locked)時,mark word存儲的是指向線程棧中Lock Record的指針;當狀態爲重量級鎖(inflated)時,爲指向堆中的monitor對象的指針。
全局安全點(safepoint)
safepoint這個詞我們在GC中經常會提到,簡單來說就是其代表了一個狀態,在該狀態下所有線程都是暫停的。
偏向鎖
一個線程反覆的去獲取/釋放一個鎖,如果這個鎖是輕量級鎖或者重量級鎖,不斷的加解鎖顯然是沒有必要的,造成了資源的浪費。於是引入了偏向鎖,偏向鎖在獲取資源的時候會在資源對象上記錄該對象是偏向該線程的,偏向鎖並不會主動釋放,這樣每次偏向鎖進入的時候都會判斷該資源是否是偏向自己的,如果是偏向自己的則不需要進行額外的操作,直接可以進入同步操作。
偏向鎖獲取過程
- 訪問Mark Word中偏向鎖標誌位是否設置成1,鎖標誌位是否爲01——確認爲可偏向狀態。
- 如果爲可偏向狀態,則測試線程ID是否指向當前線程,如果是,進入步驟(5),否則進入步驟(3)。
- 如果線程ID並未指向當前線程,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中線程ID設置爲當前線程ID,然後執行(5);如果競爭失敗,執行(4)。
- 如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全局安全點(safepoint)時獲得偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼。
- 執行同步代碼。
偏向鎖的釋放
偏向鎖的撤銷在上述第四步驟中有提到。偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖,線程不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點safepoint,它會首先暫停擁有偏向鎖的線程A,然後判斷這個線程A,此時有兩種情況:
批量重偏向
爲什麼有批量重偏向
當只有一個線程反覆進入同步塊時,偏向鎖帶來的性能開銷基本可以忽略,但是當有其他線程嘗試獲得鎖時,就需要等到safe point時將偏向鎖撤銷爲無鎖狀態或升級爲輕量級/重量級鎖。這個過程是要消耗一定的成本的,所以如果說運行時的場景本身存在多線程競爭的,那偏向鎖的存在不僅不能提高性能,而且會導致性能下降。因此,JVM中增加了一種批量重偏向/撤銷的機制。
批量重偏向的原理
-
首先引入一個概念epoch,其本質是一個時間戳,代表了偏向鎖的有效性,epoch存儲在可偏向對象的MarkWord中。除了對象中的epoch,對象所屬的類class信息中,也會保存一個epoch值。
-
每當遇到一個全局安全點時(這裏的意思是說批量重偏向沒有完全替代了全局安全點,全局安全點是一直存在的),比如要對class C 進行批量再偏向,則首先對 class C中保存的epoch進行增加操作,得到一個新的epoch_new
-
然後掃描所有持有 class C 實例的線程棧,根據線程棧的信息判斷出該線程是否鎖定了該對象,僅將epoch_new的值賦給被鎖定的對象中,也就是現在偏向鎖還在被使用的對象纔會被賦值epoch_new。
-
退出安全點後,當有線程需要嘗試獲取偏向鎖時,直接檢查 class C 中存儲的 epoch 值是否與目標對象中存儲的 epoch 值相等, 如果不相等,則說明該對象的偏向鎖已經無效了(因爲(3)步驟裏面已經說了只有偏向鎖還在被使用的對象纔會有epoch_new,這裏不相等的原因是class C裏面的epoch值是epoch_new,而當前對象的epoch裏面的值還是epoch),此時競爭線程可以嘗試對此對象重新進行偏向操作。
輕量級鎖
輕量級鎖的獲取過程
-
在代碼進入同步塊的時候,如果同步對象鎖狀態爲偏向狀態(就是鎖標誌位爲“01”狀態,是否爲偏向鎖標誌位爲“1”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝。官方稱之爲 Displaced Mark Word(所以這裏我們認爲Lock Record和 Displaced Mark Word其實是同一個概念)。這時候線程堆棧與對象頭的狀態如圖所示:
-
拷貝對象頭中的Mark Word複製到鎖記錄中。
-
拷貝成功後,虛擬機將使用CAS操作嘗試將對象頭的Mark Word更新爲指向Lock Record的指針,並將Lock record裏的owner指針指向對象頭的mark word。如果更新成功,則執行步驟(4),否則執行步驟(5)。
-
如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,即表示此對象處於輕量級鎖定狀態,這時候線程堆棧與對象頭的狀態如下所示:
-
如果這個更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,現在是重入狀態,那麼設置Lock Record第一部分(Displaced Mark Word)爲null,起到了一個重入計數器的作用。下圖爲重入三次時的lock record示意圖,左邊爲鎖對象,右邊爲當前線程的棧幀,重入之後然後結束。接着就可以直接進入同步塊繼續執行。
如果不是說明這個鎖對象已經被其他線程搶佔了,說明此時有多個線程競爭鎖,那麼它就會自旋等待鎖,一定次數後仍未獲得鎖對象,說明發生了競爭,需要膨脹爲重量級鎖。
輕量級鎖的解鎖過程
-
通過CAS操作嘗試把線程中複製的Displaced Mark Word對象替換當前的Mark Word。
-
如果替換成功,整個同步過程就完成了。
-
如果替換失敗,說明有其他線程嘗試過獲取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被掛起的線程。
重量級鎖
重量級鎖加鎖和釋放鎖機制
- 調用
omAlloc
分配一個ObjectMonitor
對象,把鎖對象頭的mark word鎖標誌位變成 “10 ”,然後在mark word存儲指向ObjectMonitor
對象的指針 ObjectMonitor
中有兩個隊列,_WaitSet
和_EntryList
,用來保存ObjectWaiter
對象列表(每個等待鎖的線程都會被封裝成ObjectWaiter
對象),_owner
指向持有ObjectMonitor
對象的線程,當多個線程同時訪問一段同步代碼時,首先會進入_EntryList
集合,當線程獲取到對象的monitor
後進入_Owner
區域並把monitor
中的owner
變量設置爲當前線程同時monitor
中的計數器count
加1,若線程調用wait()
方法,將釋放當前持有的monitor
,owner
變量恢復爲null,count
自減1,同時該線程進入WaitSet
集合中等待被喚醒。若當前線程執行完畢也將釋放monitor
(鎖)並復位變量的值,以便其他線程進入獲取monitor
(鎖)。如下圖所示
Synchronized同步代碼塊的底層原理
同步代碼塊的加鎖、解鎖是通過 Javac 編譯器實現的,底層是藉助monitorenter
和monitorerexit
,爲了能夠保證無論代碼塊正常執行結束 or 拋出異常結束,都能正確釋放鎖,Javac 編譯器在編譯的時候,會對monitorerexit
進行特殊處理,舉例說明:
public class Hello {
public void test() {
synchronized (this) {
System.out.println("test");
}
}
}
通過 javap -c
查看其編譯後的字節碼:
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void test();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String test
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
}
從字節碼中可知同步語句塊的實現使用的是monitorenter
和monitorexit
指令,其中monitorenter
指令指向同步代碼塊的開始位置,monitorexit
指令則指明同步代碼塊的結束位置,當執行monitorenter
指令時,當前線程將試圖獲取mark word裏面存儲的monitor
,當 monitor
的進入計數器爲 0,那線程可以成功取得monitor
,並將計數器值設置爲1,取鎖成功。
如果當前線程已經擁有 monitor
的持有權,那它可以重入這個 monitor
,重入時計數器的值也會加 1。倘若其他線程已經擁有monitor
的所有權,那當前線程將被阻塞,直到正在執行線程執行完畢,即monitorexit
指令被執行,執行線程將釋放 monitor
並設置計數器值爲0 ,其他線程將有機會持有 monitor
。
值得注意的是編譯器將會確保無論方法通過何種方式完成,方法中調用過的每條 monitorenter
指令都有執行其對應 monitorexit
指令,而無論這個方法是正常結束還是異常結束。爲了保證在方法異常完成時 monitorenter
和 monitorexit
指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器聲明可處理所有的異常,它的目的就是用來執行 monitorexit
指令。從上面的字節碼中也可以看出有兩個monitorexit
指令,它就是異常結束時被執行的釋放monitor
的指令。
同步方法底層原理
同步方法的加鎖、解鎖是通過 Javac 編譯器實現的,底層是藉助ACC_SYNCHRONIZED
訪問標識符來實現的,代碼如下所示:
public class Hello {
public synchronized void test() {
System.out.println("test");
}
}
方法級的同步是隱式,即無需通過字節碼指令來控制的,它實現在方法調用和返回操作之中。JVM可以從方法常量池中的方法表結構(method_info Structure) 中的 ACC_SYNCHRONIZED
訪問標誌區分一個方法是否同步方法。當方法調用時,調用指令將會 檢查方法的 ACC_SYNCHRONIZED
訪問標誌是否被設置,如果設置了,執行線程將先持有monitor
,然後再執行方法,最後在方法完成(無論是正常完成還是非正常完成)時釋放monitor
。在方法執行期間,執行線程持有了monitor
,其他任何線程都無法再獲得同一個monitor
。如果一個同步方法執行期間拋出了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的monitor
將在異常拋到同步方法之外時自動釋放。
下面我們看看字節碼層面如何實現:
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public synchronized void test();
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String test
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
鎖的其他優化
- 適應性自旋(Adaptive Spinning):從輕量級鎖獲取的流程中我們知道,當線程在獲取輕量級鎖的過程中執行CAS操作失敗時,是要通過自旋來獲取重量級鎖的。問題在於,自旋是需要消耗CPU的,如果一直獲取不到鎖的話,那該線程就一直處在自旋狀態,白白浪費CPU資源。解決這個問題最簡單的辦法就是指定自旋的次數,例如讓其循環10次,如果還沒獲取到鎖就進入阻塞狀態。但是JDK採用了更聰明的方式——適應性自旋,簡單來說就是線程如果自旋成功了,則下次自旋的次數會更多,如果自旋失敗了,則自旋的次數就會減少。
- 鎖粗化(Lock Coarsening):鎖粗化的概念應該比較好理解,就是將多次連接在一起的加鎖、解鎖操作合併爲一次,將多個連續的鎖擴展成一個範圍更大的鎖。舉個例子:
public void lockCoarsening() {
int i=0;
synchronized (this){
i=i+1;
}
synchronized (this){
i=i+2;
}
}
上面的兩個同步代碼塊可以變成一個
public void lockCoarsening() {
int i=0;
synchronized (this){
i=i+1;
i=i+2;
}
}
- 鎖消除(Lock Elimination):鎖消除即刪除不必要的加鎖操作的代碼。比如下面的代碼,下面的for循環完全可以移出來,這樣可以減少加鎖代碼的執行過程
public void lockElimination() {
int i=0;
synchronized (this){
for(int c=0; c<1000; c++){
System.out.println(c);
}
i=i+1;
}
}