1. 程式人生 > 程式設計 >從偏向鎖是如何升級到重量級鎖的

從偏向鎖是如何升級到重量級鎖的

簡介

在 jdk1.6 之前我們會說 synchronized 是個重量級鎖,在此之後 JVM 對其做了很多的優化,之後使用 synchronized 執行緒在獲取鎖的時候根據競爭的狀態可以是偏向鎖、輕量級鎖和重量級鎖。

而在關於鎖的技術中,又出現了一些比如鎖粗化、鎖消除、自旋鎖、自適應自旋鎖他們又是什麼,本文後續會一一說明。

注意的是我們討論的都是 synchronized 同步,即隱式加鎖。使用 Lock 加鎖的話它是另外的實現方式。

什麼是重量級鎖

要想知道 JVM 為什麼對其進行優化,我們就要先來瞭解下重量級鎖到底是什麼,為什麼要對其進行優化,我們來看一段程式碼

public synchronized
void f()
{ System.out.println("hello world"); } 複製程式碼

javap 反編譯後

public synchronized void f();
    descriptor: ()V
    flags: ACC_PUBLIC,ACC_SYNCHRONIZED
    Code:
      stack=2,locals=1,args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 3: 0 line 4: 8 複製程式碼

當某個執行緒訪問這個方法的時候,首先會去檢查是否有 ACC_SYNCHRONIZED 有的話就需要先獲得對應的監視器鎖才能執行。

當方法結束或者中間丟擲未被處理的異常的時候,監視器鎖就會被釋放。

在 Hotspot 中這些操作是通過 ObjectMonitor 來實現的,通過它提供的功能就可能做到獲取鎖,釋放鎖,阻塞中等待鎖釋放再去競爭鎖,鎖等待被喚醒等功能,我們來探討下它是如何做到的。

每個物件都持有一個 Monitor, Monitor 是一種同步機制,通過它我們就可以實現執行緒之間的互斥訪問,首先來列舉下 ObjectMonitor 的幾個我們需要討論的關鍵欄位

  • _owner,ObjectMonitor 目前被哪個執行緒持有
  • _entryList,阻塞佇列(阻塞競爭獲取鎖的一些執行緒)
  • _WaitSet,等待佇列中的執行緒需要等待被喚醒(可以通過中斷,singal,超時返回等)
  • _cxq,執行緒獲取鎖失敗放入 _cxq 佇列中
  • _recursions,執行緒重入次數,synchronized 是個可重入鎖

從一個執行緒開始競爭鎖到方法結束釋放鎖後阻塞佇列執行緒競爭鎖的執行的流程如上圖,然後來分別分析一下,在獲取鎖和釋放鎖著兩種情況。

獲取鎖的時候

釋放鎖的時候

在 jdk1.6 之前,synchronized 就直接會去呼叫 ObjectMonitor 的 enter 方法獲取鎖(第一張圖)了,然後釋放鎖的時候回去呼叫 ObjectMonitor 的 exit 方法(第二張圖)這被稱之為重量級鎖,可以看出它涉及到的操作複雜性。

那麼思考一下

如果說同一時間本身就只有一個執行緒去訪問它,那麼就算它存在共享變數,由於不會被多執行緒同時訪問也不存線上程安全問題,這個時候其實就不需要執行重量級加鎖的過程。只需要在出現競爭的時候在使用執行緒安全的操作就行了

從而就引出了偏向鎖輕量級鎖

自旋鎖

自旋鎖自 jdk1.6 開始就預設開啟。由於重量級鎖的喚醒以及掛起對都需要從使用者態轉入核心態呼叫來完成,大量併發的時候會給系統帶來比較大的壓力,所以就出現了自旋鎖,來避免頻繁的掛起以及恢復操作。

自旋鎖的意思是執行緒 A 已經獲得了鎖在執行,那麼執行緒 B 在獲取鎖的時候,不阻塞,不放棄 CPU 執行時間直接進行死迴圈(有限定次數)不斷的去爭搶鎖,如果執行緒 A 執行速度非常快的完成了,那麼執行緒 B 能夠較快的就獲得鎖物件執行,從而避免了掛起和恢復執行緒的開銷,也能進一步的提升響應時間。

自旋鎖預設的次數為 10 次可以通過 -XX:PreBlockSpin 來更改

自適應性自旋

跟自旋鎖類似,不同的是它的自旋時間和次數不再固定了。比如在同一個鎖物件上,上次自旋成功的獲得了鎖,那麼 JVM 就會認為下一次也能成功獲得鎖,進而允許自旋更長的時間去獲取鎖。如果在同一個鎖物件上,很少有自旋成功獲得過鎖,那額 JVM 可能就會直接省略掉自旋的過程。

自旋鎖和自適應鎖類似,雖然自旋等待避免了執行緒切換的開銷,但是他們都不放棄 CPU 的執行時間,如果鎖被佔用的時間很長,那麼可能就會存在大量的自旋從而浪費 CPU 的資源,所以自旋鎖是不能用來替代阻塞的,它有它適用的場景

偏向鎖

鎖會偏向於第一個執行它的執行緒,如果該鎖後續沒有其他執行緒訪問過,那我們就不需要加鎖直接執行即可。

如果後續發現了有其它執行緒正在獲取該鎖,那麼會根據之前獲得鎖的執行緒的狀態來決定要麼將鎖重新偏向新的執行緒,要麼撤銷偏向鎖升級為輕量級鎖。

Mark Word 鎖標識如下

thread ID - 是否是偏向鎖 鎖標誌位
thread ID epoch 1 01(未被鎖定)

執行緒 A - thread ID 為 100,去獲取鎖的時候,發現鎖標誌位為 01 ,偏向鎖標誌位為 1 (可以偏向),然後 CAS 將執行緒 ID 記錄在物件頭的 Mark Word,成功後

thread ID - 是否是偏向鎖 鎖標誌位
100 epoch 1 01(未被鎖定)

以後先 A 再次執行該方法的時候,只需要簡單的判斷一下物件頭的 Mark Word 中 thread ID 是否是當前執行緒即可,如果是的話就直接執行

假如此時有另外一個執行緒執行緒 B 嘗試獲取該鎖,執行緒 B - thread ID 為 101,同樣的去檢查鎖標誌位和是否可以偏向的狀態發現可以後,然後 CAS 將 Mark Word 的 thread ID 指向自己,發現失敗了,因為 thread ID 已經指向了執行緒 A ,那麼此時就會去執行撤銷偏向鎖的操作了,會在一個全域性安全點(沒有位元組碼在執行)去暫停擁有偏向鎖的執行緒(執行緒 A),然後檢查執行緒 A 的狀態,那麼此時執行緒 A 就有 2 種情況了。

第一種情況,執行緒 A 已經終止狀態,那麼將 Mark Word 的執行緒 ID 置位空後,CAS 將執行緒 ID 偏向執行緒 B 然後就又回到上述又是偏向鎖執行緒的執行狀態了

thread ID - 是否是偏向鎖 鎖標誌位
101 epoch 1 01(未被鎖定)

第二種情況,執行緒 A 處於活動狀態,那麼就會將偏向鎖升級為輕量級鎖,然後喚醒執行緒 A 執行完後續操作,執行緒 B 自旋獲取輕量級鎖。

thread ID 是否是偏向鎖 鎖標誌位
0 00(輕量級鎖定)

可以發現偏向鎖適用於從始至終都只有一個執行緒在執行的情況,省略掉了自旋獲取鎖,以及重量級鎖互斥的開銷,這種鎖的開銷最低,效能最好接近於無鎖狀態,但是如果執行緒之間存在競爭的話,就需要頻繁的去暫停擁有偏向鎖的執行緒然後檢查狀態,決定是否重新偏向還是升級為輕量級別鎖,效能就會大打折扣了,如果事先能夠知道可能會存在競爭那麼可以選擇關閉掉偏向鎖

有的小夥伴會說存在競爭不就應該立馬升級為重量級別鎖了嗎,不一定,下面講了輕量級鎖就會明白了。

輕量級鎖

如果說執行緒之間不存在競爭或者偶爾出現競爭的情況並且執行鎖裡面的程式碼的速度非常快那麼就很適合輕量級鎖的場景了,如果說偏向鎖是完全取消了同步並且也取消了 CAS 和自旋獲取鎖的流程,它是隻需要判斷 Mark Word 裡面的 thread ID 是否指向自己即可(其它時間點有少許的判斷可以忽略),那麼輕量級鎖就是使用 CAS 和自旋鎖來獲取鎖從而降低使用作業系統互斥量來完成重量級鎖的效能消耗

輕量級鎖的實現如下

JVM 會在當前執行緒的棧幀中建立用於儲存鎖記錄的空間,然後將物件頭的 Mark Word 複製到鎖記錄中,官方稱為 Displaced Mark Word 然後執行緒嘗試使用 CAS 將物件頭的 Mark Word 替換為指向鎖記錄的指標

假設執行緒 B 替換成功,表明成功獲得該鎖,然後繼續執行程式碼,此時 Mark Word 如下

執行緒棧的指標 鎖狀態
stack pointer 1 -> 指向執行緒 B 00(輕量級鎖)

此時執行緒 C 來獲取該鎖,CAS 修改物件頭的時候失敗發現已經被執行緒 B 佔用,然後它就自旋獲取鎖,結果執行緒 B 這時正好執行完成,執行緒 C 自旋獲取成功

執行緒棧的指標 鎖狀態
stack pointer 2 -> 執行緒 C 00(輕量級鎖)

此時執行緒 D 又獲取該鎖,發現被執行緒 C 佔用,然後它自旋獲取鎖,自旋預設 10 次後發現還是無法獲得對應的鎖(執行緒 C 還沒有釋放),那麼執行緒 D 就將 Mark Word 修改為重量級鎖

執行緒棧的指標 鎖狀態
stack pointer 2 -> 執行緒 C 10(重量級鎖)

然後這時執行緒 C 執行完成了,將棧幀中的 Mark Word 替換回物件頭的 Mark Word 的時候,發現有其它執行緒競爭該鎖(被執行緒 D 修改了鎖狀態)然後它釋放鎖並且喚醒在等待的執行緒,後續的執行緒操作就全部都是重量級鎖了

執行緒棧的指標 鎖狀態
10(重量量級鎖)

需要注意的是鎖一旦升級就不會降級了

鎖消除

鎖消除主要是 JIT 編譯器的優化操作,首先對於熱點程式碼 JIT 編譯器會將其編譯為機器碼,後續執行的時候就不需要在對每一條 class 位元組碼解釋為機器碼然後再執行了從而提升效率,它會根據逃逸分析來對程式碼做一定程度的優化比如鎖消除,棧上分配等等

public void f() {
    Object obj = new Object();
    synchronized(obj) {
         System.out.println(obj);
    }
}
複製程式碼

JIT 編譯器發現 f() 中的物件只會被一個執行緒訪問,那麼就會取消同步

public void f() {
    Object obj = new Object();
    System.out.println(obj);
}
複製程式碼

鎖粗化

如果在一段程式碼中連續的對同一個物件反覆加鎖解鎖,其實是相對耗費資源的,這種情況下可以適當放寬加鎖的範圍,減少效能消耗。

當 JIT 發現一系列連續的操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作出現在迴圈體中的時候,會將加鎖同步的範圍擴散到整個操作序列的外部。

for (int i = 0; i < 10000; i++) {
    synchronized(this) {
        do();
    }
}
複製程式碼

粗化後的程式碼

synchronized(this) {
    for (int i = 0; i < 10000; i++) {
        do();
    }
}
複製程式碼

參考: