天天看點

synchronized鎖更新底層原理

作者:搬山道猿

今天我們來聊聊 Synchronized 裡面的各種鎖:偏向鎖、輕量級鎖、重量級鎖,以及三個鎖之間是如何進行鎖膨脹的。先來一張圖來總結

synchronized鎖更新底層原理

提前了解知識

鎖的更新過程

鎖的狀态總共有四種:無鎖狀态、偏向鎖、輕量級鎖和重量級鎖。随着鎖的競争,鎖可以從偏向鎖更新到輕量級鎖,再更新的重量級鎖(但是鎖的更新是單向的,也就是說隻能從低到高更新,不會出現鎖的降級)

Java 對象頭

因為在Java中任意對象都可以用作鎖,是以必定要有一個映射關系,存儲該對象以及其對應的鎖資訊(比如目前哪個線程持有鎖,哪些線程在等待)。一種很直覺的方法是,用一個全局map,來存儲這個映射關系,但這樣會有一些問題:需要對map做線程安全保障,不同的synchronized之間會互相影響,性能差;另外當同步對象較多時,該map可能會占用比較多的記憶體。是以最好的辦法是将這個映射關系存儲在對象頭中,因為對象頭本身也有一些hashcode、GC相關的資料,是以如果能将鎖資訊與這些資訊共存在對象頭中就好了。

synchronized鎖更新底層原理

在JVM中,對象在記憶體中除了本身的資料外還會有個對象頭,對于普通對象而言,其對象頭中有兩類資訊:mark word和類型指針。另外對于數組而言還會有一份記錄數組長度的資料。類型指針是指向該對象所屬類對象的指針,mark word用于存儲對象的HashCode、GC分代年齡、鎖狀态等資訊。在32位系統上mark word長度為32bit,64位系統上長度為64bit。為了能在有限的空間裡存儲下更多的資料,其存儲格式是不固定的,在64位系統上各狀态的格式如下:

synchronized鎖更新底層原理

可以看到鎖資訊也是存在于對象的mark word中的。當對象狀态為偏向鎖(biasable)時,mark word存儲的是偏向的線程ID;當狀态為輕量級鎖(lightweight locked)時,mark word存儲的是指向線程棧中Lock Record的指針;當狀态為重量級鎖(inflated)時,為指向堆中的monitor對象的指針。

全局安全點(safepoint)

safepoint這個詞我們在GC中經常會提到,簡單來說就是代碼執行過程中的一些特殊位置,線程執行到這個位置時可以暫停。在該位置我們可以确定的讀取目前線程的一些資訊,也可以與其他線程共享。比如線程上下文的資訊,對象或者非對象的内部指針等。

偏向鎖

一個線程反複的去擷取/釋放一個鎖,如果這個鎖是輕量級鎖或者重量級鎖,不斷的加解鎖顯然是沒有必要的,造成了資源的浪費。于是引入了偏向鎖,偏向鎖在擷取資源的時候會在資源對象上記錄該對象是偏向該線程的,偏向鎖并不會主動釋放,這樣每次偏向鎖進入的時候都會判斷該資源是否是偏向自己的,如果是偏向自己的則不需要進行額外的操作,直接可以進入同步操作。

偏向鎖擷取過程

  1. 通路Mark Word中偏向鎖标志位是否設定成1,鎖标志位是否為01——确認為可偏向狀态。
  2. 如果為可偏向狀态,則判斷偏向的線程ID是否是目前線程,如果是,進入步驟(5),否則進入步驟(3)。
  3. 如果線程ID并未指向目前線程,則通過CAS操作競争鎖。如果競争成功,則将Mark Word中線程ID設定為目前線程ID,然後執行(5);如果競争失敗,執行(4)。
  4. 如果CAS擷取偏向鎖失敗,則表示有競争。當到達全局安全(safepoint)時獲得偏向鎖的線程被挂起,偏向鎖更新為輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼。
  5. 執行同步代碼。

偏向鎖的釋放

偏向鎖的撤銷在上述第四步中有提到。偏向鎖隻有遇到其他線程嘗試競争偏向鎖時,持有偏向鎖的線程才會釋放鎖,線程不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點safepoint,它會首先暫停擁有偏向鎖的線程A,然後判斷這個線程A,此時有兩種情況:

  1. A 線程已經退出了同步代碼塊,或者是已經不再存活了,此時就會直接撤銷偏向鎖,變成無鎖狀态。
  2. 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 值相等,如果不相等,則說明該對象的偏向鎖已經無效了(因為上一步裡面已經說了隻有偏向鎖還在被使用的對象才會有epoch_new,這裡不相等的原因是class C裡面的epoch值是epoch_new,而目前對象的epoch裡面的值還是epoch),此時競争線程可以嘗試對此對象重新進行偏向操作。

輕量級鎖

輕量級鎖的擷取過程

在代碼進入同步塊的時候,如果同步對象鎖狀态為偏向狀态(就是鎖标志位為“01”狀态,偏向鎖标志位為“1”),虛拟機首先将在目前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的Mark Word的拷貝。官方稱之為 Displaced Mark Word(是以這裡我們認為Lock Record和 Displaced Mark Word其實是同一個概念)。這時候線程堆棧與對象頭的狀态如圖所示:

synchronized鎖更新底層原理
  • 拷貝對象頭中的Mark Word複制到鎖記錄中。
  • 拷貝成功後,虛拟機将使用CAS操作嘗試将對象頭的Mark Word更新為指向Lock Record的指針,并将Lock record裡的owner指針指向對象頭的mark word。
  • 如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,并且對象Mark Word的鎖标志位設定為“00”,即表示此對象處于輕量級鎖定狀态,這時候線程堆棧與對象頭的狀态如下所示:
synchronized鎖更新底層原理
  • 如果這個更新操作失敗了,虛拟機首先會檢查對象的Mark Word是否指向目前線程的棧幀,如果是就說明目前線程已經擁有了這個對象的鎖,現在是重入狀态,那麼設定Lock Record第一部分(Displaced Mark Word)為null,起到了一個重入計數器的作用。下圖為重入三次時的lock record示意圖,左邊為鎖對象,右邊為目前線程的棧幀,重入之後然後結束。接着就可以直接進入同步塊繼續執行。
synchronized鎖更新底層原理
  • 如果不是說明這個鎖對象已經被其他線程搶占了,說明此時有多個線程競争鎖,那麼它就會自旋等待鎖,一定次數後仍未獲得鎖對象,說明發生了競争,需要膨脹為重量級鎖。

輕量級鎖的解鎖過程

通過CAS操作嘗試把線程中複制的Displaced Mark Word對象替換目前的Mark Word。

如果替換成功,整個同步過程就完成了。

如果替換失敗,說明有其他線程嘗試擷取該鎖(此時鎖已膨脹),那就要在釋放鎖的同時,喚醒被挂起的線程。

重量級鎖

重量級鎖加鎖和釋放鎖機制

如果進入重量級鎖,對象頭中還會有個Monitor對象,JVM中其實就是ObjectMonitor,他有四個比較重要的屬性:● _count:計數器。用來記錄擷取鎖的次數。該屬性主要用來實作重入鎖機制。 ● _owner:記錄着目前鎖對象的持有者線程。 ● _WaitSet:隊列。當一個線程調用了wait方法後,它會釋放鎖資源,進入WaitSet隊列等待被喚醒。(每個等待鎖的線程都會被封裝成ObjectWaiter對象) ● _EntryList:隊列。裡面存放着所有申請該鎖對象的線程。

加鎖過程如下:

  • 調用omAlloc配置設定一個ObjectMonitor對象,把鎖對象頭的mark word鎖标志位變成 “10 ”,然後在mark word存儲指向ObjectMonitor對象的指針。
  • 當多個線程同時通路一段同步代碼時,首先會進入 _EntryList 集合,當線程擷取到對象的monitor 後,把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)

鎖消除用大白話來講,就是在一段程式裡你用了鎖,但是jvm檢測到這段程式裡不存在共享資料競争問題,也就是變量沒有逃逸出方法外,這個時候jvm就會把這個鎖消除掉。我們程式員寫代碼的時候自然是知道哪裡需要上鎖,哪裡不需要,但是有時候我們雖然沒有顯示使用鎖,但是我們不小心使了一些線程安全的API時,如StringBuffer、Vector、HashTable等,這個時候會隐形的加鎖。比如下段代碼:

public void sbTest(){
StringBuffer sb= new StringBuffer();
for(int i = 0 ; i < 10 ; i++){
sb.append(i);
}

System.out.println(sb.toString());
}           

上面這段代碼,JVM可以明顯檢測到變量sb沒有逃逸出方法sbTest()之外,是以JVM可以大膽地将sbTest内部的加鎖操作消除。

繼續閱讀