詳細瞭解 synchronized 鎖升級過程

detectiveHLH發表於2022-03-08

前言

首先,synchronized 是什麼?我們需要明確的給個定義——同步鎖,沒錯,它就是把

可以用來幹嘛?鎖,當然當然是用於執行緒間的同步,以及保護臨界區內的資源。我們知道,鎖是個非常籠統的概念,像生活中有指紋鎖、密碼鎖等等多個種類,那 synchronized 代表的鎖具體是把什麼鎖呢?

答案是—— Java 內建鎖。在 Java 中,每個物件中都隱藏著一把鎖,而 synchronized 關鍵字就是啟用這把隱式鎖的把手(開關)。

先來簡單瞭解一下 synchronized,我們知道其共有 3 種使用方式:

Synchronized 的使用
Synchronized 的使用
  • 修飾靜態方法:鎖住當前 class,作用於該 class 的所有例項
  • 修飾非靜態方法:只會鎖住當前 class 的例項
  • 修飾程式碼塊:該方法接受一個物件作為引數,鎖住的即該物件

使用方法就不在這裡贅述,可自行搜尋其詳細的用法,這不是本篇文章所關心的內容。

知道了 synchronized 的概念,回頭來看標題,它說的鎖升級到底是個啥?對於不太熟悉鎖升級的人來說,可能會想:

所謂鎖,不就是啪一下鎖上就完事了嗎?升級是個什麼玩意?這跟打撲克牌也沒關係啊。

對於熟悉的人來說,可能會想:

不就是「無鎖 ==> 偏向鎖 ==> 輕量級鎖 ==> 重量級鎖 」嗎?

你可能在很多地方看到過上面描述的鎖升級過程,也能直接背下來。但你真的知道無鎖偏向鎖輕量級鎖重量級鎖到底代表著什麼嗎?這些鎖儲存在哪裡?以及什麼情況下會使得鎖向下一個 level 升級?

想知道答案,我們似乎必須先搞清楚 Java 內建鎖,其內部結構是啥樣的?內建鎖又存放在哪裡?

答案在開篇提到過——在 Java 物件中。

那麼現在的問題就從「內建鎖結構是啥」變成了「Java 物件長啥樣」。

物件結構

巨集觀上看,Java 物件的結構很簡單,分為三部分:

Java 物件結構
Java 物件結構

微觀上看,各個部分都還可以深入展開,詳見下圖:

Java 詳細物件結構
Java 詳細物件結構

接下來分別深入討論一下這三部分。

物件頭

從腦圖中可以看出,其由 Mark Word、Class Pointer、陣列長度三個欄位組成。簡單來說:

  • Mark Word:主要用於儲存自身執行時資料
  • Class Pointer:是指標,指向方法區中該 class 的物件,JVM 通過此欄位來判斷當前物件是哪個類的例項
  • 陣列長度:當且僅當物件是陣列時才會有該欄位

Class Pointer 和陣列長度沒什麼好說的,接下來重點聊聊 Mark Word。

Mark Word 所代表的「執行時資料」主要用來表示當前 Java 物件的執行緒鎖狀態以及 GC 的標誌。而執行緒鎖狀態分別就是無鎖、偏向鎖、輕量級鎖、重量級鎖。

所以前文提到的這 4 個狀態,其實就是 Java 內建鎖的不同狀態

在 JDK 1.6 之前,內建鎖都是重量級鎖,效率低下。效率低下表現在

而在 JDK 1.6 之後為了提高 synchronized 的效率,才引入了偏向鎖輕量級鎖

隨著鎖競爭逐漸激烈,其狀態會按照「無鎖 ==> 偏向鎖 ==> 輕量級鎖 ==> 重量級鎖 」這個方向逐漸升級,並且不可逆,只能進行鎖升級,而無法進行鎖降級

接下來我們思考一個問題,既然 Mark Word 可以表示 4 種不同的鎖狀態,其內部到底是怎麼區分的呢?(由於目前主流的 JVM 都是 64 位,所以我們只討論 64 位的 Mark Word)接下來我們通過圖片直觀的感受一下。

(1)無鎖

無鎖
無鎖

這個可以理解為單執行緒很快樂的執行,沒有其他的執行緒來和其競爭。

(2)偏向鎖

偏向鎖
偏向鎖

首先,什麼叫偏向鎖?舉個例子,一段同步的程式碼,一直只被執行緒 A 訪問,既然沒有其他的執行緒來競爭,每次都要獲取鎖豈不是浪費資源?所以這種情況下執行緒 A 就會自動進入偏向鎖的狀態。

後續執行緒 A 再次訪問同步程式碼時,不需要做任何的 check,直接執行(對該執行緒的「偏愛」),這樣降低了獲取鎖的代價,提升了效率。

看到這裡,你會發現無鎖、偏向鎖的 lock 標誌位是一樣的,即都是 01,這是因為無鎖、偏向鎖是靠欄位 biased_lock 來區分的,0 代表沒有使用偏向鎖,1 代表啟用了偏向鎖。為什麼要這麼搞?你可以理解為無鎖、偏向鎖在本質上都可以理解為無鎖(參考上面提到的執行緒 A 的狀態),所以 lock 的標誌位都是 01 是沒毛病的。

PS:這裡的執行緒 ID 是持有當前物件偏向鎖的執行緒

(3)輕量級鎖

輕量級鎖
輕量級鎖

但是,一旦有第二個執行緒參與競爭,就會立即膨脹為輕量級鎖。企圖搶佔的執行緒一開始會使用自旋

的方式去嘗試獲取鎖。如果迴圈幾次,其他的執行緒釋放了鎖,就不需要進行使用者態到核心態的切換。雖然如此,但自旋需要佔用很多 CPU 的資源(自行理解汽車空檔瘋狂踩油門)。如果另一個執行緒 一直不釋放鎖,難道它就在這一直空轉下去嗎?

當然不可能,JDK 1.7 之前是普通自旋,會設定一個最大的自旋次數,預設是 10 次,超過這個閾值就停止自旋。JDK 1.7 之後,引入了適應性自旋。簡單來說就是:這次自旋獲取到鎖了,自旋的次數就會增加;這次自旋沒拿到鎖,自旋的次數就會減少

(4)重量級鎖

重量級鎖
重量級鎖

上面提到,試圖搶佔的執行緒自旋達到閾值,就會停止自旋,那麼此時鎖就會膨脹成重量級鎖。當其膨脹成重量級鎖後,其他競爭的執行緒進來就不會自旋了,而是直接阻塞等待,並且 Mark Word 中的內容會變成一個監視器(monitor)物件,用來統一管理排隊的執行緒。

這個 monitor 物件,每個物件都會關聯一個。monitor 物件本質上是一個同步機制,保證了同時只有一個執行緒能夠進入臨界區,在 HotSpot 的虛擬機器中,是由 C++ 類 ObjectMonitor 實現的。

那麼 monitor 物件具體是如何來管理執行緒的?接下來我們看幾個 ObjectMonitor 類關鍵的屬性:

  • ContentionQueue:是個佇列,所有競爭鎖的執行緒都會先進入這個佇列中,可以理解為執行緒的統一入口,進入的執行緒會阻塞。
  • EntryList:ContentionQueue 中有資格的執行緒會被移動到這裡,相當於進行一輪初篩,進入的執行緒會阻塞。
  • Owner:擁有當前 monitor 物件的執行緒,即 —— 持有鎖的那個執行緒。
  • OnDeck:與 Owner 執行緒進行競爭的執行緒,同一時刻只會有一個 OnDeck 執行緒在競爭。
  • WaitSet:當 Owner 執行緒呼叫 wait() 方法被阻塞之後,會被放到這裡。當其被喚醒之後,會重新進入 EntryList 當中,這個集合的執行緒都會阻塞。
  • Count:用於實現可重入鎖,synchronized 是可重入的。

物件體

物件體包含了當前物件的欄位和值,在業務中u l是較為核心的部分。

對齊位元組

就是單純用於填充的位元組,沒有其他的業務含義。其目的是為了保證物件所佔用的記憶體大小為 8 的倍數,因為HotSpot VM 的記憶體管理要求物件的起始地址必須是 8 的倍數。

鎖升級

瞭解完 4 種鎖狀態之後,我們就可以整體的來看一下鎖升級的過程了。

執行緒 A 進入 synchronized 開始搶鎖,JVM 會判斷當前是否是偏向鎖的狀態,如果是就會根據 Mark Word 中儲存的執行緒 ID 來判斷,當前執行緒 A 是否就是持有偏向鎖的執行緒。如果是,則忽略 check,執行緒 A 直接執行臨界區內的程式碼。

但如果 Mark Word 裡的執行緒不是執行緒 A,就會通過自旋嘗試獲取鎖,如果獲取到了,就將 Mark Word 中的執行緒 ID 改為自己的;如果競爭失敗,就會立馬撤銷偏向鎖,膨脹為輕量級鎖。

後續的競爭執行緒都會通過自旋來嘗試獲取鎖,如果自旋成功那麼鎖的狀態仍然是輕量級鎖。然而如果競爭失敗,鎖會膨脹為重量級鎖,後續等待的競爭的執行緒都會被阻塞。

鎖升級過程
鎖升級過程

EOF

其實偏向鎖還有一個撤銷的過程,也是有代價的,但相比於偏向鎖帶好的好處,是能夠接受的。但我們這裡重點的還是關注鎖升級的具體邏輯和細節,關於鎖升級的過程就聊到這裡。

歡迎 wx 搜尋關注 「SH的全棧筆記」

- END -

相關文章