老掉牙的 synchronized 鎖優化,一次給你講清楚!

2022-07-04 09:00:23

我們都知道 synchronized 關鍵字能實現執行緒安全,但是你知道這背後的原理是什麼嗎?今天我們就來講一講 synchronized 實現執行緒同步背後的原因,以及相關的鎖優化策略吧。

synchronized 背後的原理

synchronized 關鍵字經過編譯之後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 這兩個位元組碼指令,這兩個位元組碼只需要一個指明一個要鎖定或解鎖的物件。如果 Java 程式中指明瞭物件引數,那麼就用這個物件作為鎖。

如果沒有指定,那麼就根據 synchronized 修飾的是實體方法還是類方法,去拿對應的物件範例或 Class 物件來作為鎖物件。因此我們可以知道,synchronized 關鍵字實現執行緒同步的背後,其實是 Java 虛擬機器器規範對於 monitorenter 和 monitorexit 的定義。

在 Java 虛擬機器器規範對 monitorenter 和 monitorexit 的行為描述中,有兩點需要特別注意。

  1. synchronized 同步塊對同一條執行緒是可重入的,也就是不會出現自己把自己鎖死的問題。
  2. 同步塊在已進入的執行緒執行完之前,會阻塞後面其他執行緒的進入。

synchronized 關鍵字在 JDK1.6 版本之前,是通過作業系統的 Mutex Lock 來實現同步的。而作業系統的 Mutex Lock 是作業系統級別的方法,需要切換到核心態來執行。這就需要從使用者態轉換到核心態中,因此我們說 synchronized 同步是重量級的操作。

synchronized 鎖優化

在 JDK1.6 版本中,HotSpot 虛擬機器器開發團隊花了很大的精力去實現各種鎖優化技術,如:適應性自旋、鎖消除、鎖粗話、偏向鎖、輕量級鎖等。其中最重要的是:自旋鎖、輕量級鎖、偏向鎖這三個,我們重點講這三個鎖優化。

自旋鎖與自適應自旋

對於重量級的同步操作來說,最大的消耗其實是核心態與使用者態的切換。但很多時候,對於共用資料的操作時間可能很短,比核心態切換到使用者態這個耗時還短。

於是有人就想:如果有多個執行緒並行去獲取鎖的時候,如果能讓後面那個請求鎖的執行緒「稍等一下」,不放棄 CPU 的執行時間,看看持有鎖的執行緒是否會很快釋放鎖。為了讓執行緒等待,我們只需讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖。 從理論上來看,如果所有執行緒都很快地獲取鎖、釋放鎖,那麼自旋鎖是可以帶來較大的效能提升的。自旋鎖在 JDK 1.4.2 中就已經引入,預設自旋 10 次。但自旋鎖預設是關閉的,在 JDK 1.6 中才改為預設開啟了。

自旋等待雖然避免了執行緒切換的開銷,但還是要佔用處理器的時間。如果鎖被佔用的時間段,自旋等待的效果就會非常好。但如果鎖被長時間佔用,那麼自旋的執行緒就會白白消耗處理器的資源,從而帶來效能上的浪費。

為了解決特殊情況下自旋鎖的效能消耗問題,在 JDK1.6 的時候引入了自適應的自旋鎖。 自適應意味著自旋時間不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者狀態決定。如果在同一鎖物件上,自旋等待剛剛成功獲得過鎖,那麼虛擬機器器認為這次自旋也很有可能再次成功,進而允許執行緒自旋更長時間,例如自旋 100 個迴圈。

但如果對於某個鎖,自旋很少成功獲得過。那虛擬機器器為了避免浪費 CPU 資源,有可能省略掉自旋過程。有了自旋鎖,隨著程式執行和效能監控資訊的不斷完善,虛擬機器器對鎖的狀態預測就越準,虛擬機器器也會變得越來越聰明。

輕量級鎖

輕量級鎖是 JDK1.6 加入的新型鎖機制,名字中的「輕量級」是相對於作業系統互斥量這個重量級鎖而言的。輕量級鎖誕生的原因,是由於對於絕大部分的鎖而言,整個同步週期都不存在競爭。如果沒有競爭的話,那就沒必要使用重量級鎖了,於是就誕生了輕量級鎖來提高效率。

對於輕量級鎖來說,其同步的流程如下:

  1. 在程式碼進入同步塊的時候,如果此同步物件沒有被鎖定(鎖標誌位為 01 狀態),那麼虛擬機器器會在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的 Mark Word 拷貝。
  2. 虛擬機器器將使用 CAS 操作嘗試將物件的 Mark Word 更新為指向 Lock Record 的指標。如果更新動作成功了,那麼執行緒就泳衣了該物件的鎖,並且物件 Mark Word 的鎖標誌位就變成了 00,表示此物件處於輕量級鎖定狀態。

簡單地說,輕量級鎖的同步流程可以總結為:使用 CAS 操作,線上程棧幀與鎖物件建立雙向的指標。

在沒有執行緒競爭的情況下,輕量級鎖使用 CAS 自旋操作避免了使用互斥量的開銷,提高了效率。但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了 CAS 操作。因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。

偏向鎖

偏向鎖是 JDK1.6 中引入的一項優化,它的意思是這個鎖會偏向於第一個獲得它的執行緒。如果在接下來的執行過程中,該鎖沒有被其他執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。 對於偏向鎖來說,其同步流程如下所示:

  1. 假設當前虛擬機器器啟動了偏向鎖,那麼當鎖物件第一次被執行緒獲取的時候,虛擬機器器將會把物件的鎖標誌位設定為 01,偏向鎖位設定為 1。同時使用 CAS 操作將執行緒 ID 記錄在物件的 MarkWord 之中。如果 CAS 操作成功,那麼持有偏向鎖的執行緒進入鎖對應的同步塊時,虛擬機器器將不再進行任何同步操作。
  2. 當有另外一個執行緒嘗試去獲取這個鎖時,根據鎖物件目前是否處於鎖定狀態,將其恢復到未鎖定(01)或輕量級鎖定(00)狀態。隨後的同步操作,就向上面介紹的輕量級鎖那樣執行。

可以看到偏向鎖還是需要做一些 CAS 操作,但是對比起輕量級鎖來說,其要設定的內容大大減少了,因此也提高了一些效率。偏向鎖可以提高帶有同步但無競爭的程式效能。 它同樣是一個帶有效益權衡(Trade Off)性質的優化,也就是說,它並不一定總是對程式執行有利,如果程式中大多數的鎖總是被多個不同的執行緒存取,那偏向模式就是多餘的。

優化後的鎖獲取流程

經過 JDK1.6 的優化,synchronized 同步機制的流程變成了:

  1. 首先,synchronized 會嘗試使用偏向鎖的方式去競爭鎖資源,如果能夠競爭到偏向鎖,表示加鎖成功直接返回。
  2. 如果競爭鎖失敗,說明當前鎖已經偏向了其他執行緒。需要將鎖升級到輕量級鎖,在輕量級鎖狀態下,競爭鎖的執行緒根據自適應自旋次數去嘗試搶佔鎖資源。
  3. 如果在輕量級鎖狀態下還是沒有競爭到鎖,就只能升級到重量級鎖。在重量級鎖狀態下,沒有競爭到鎖的執行緒就會被阻塞。處於鎖等待狀態的執行緒需要等待獲得鎖的執行緒來觸發喚醒。

上面的鎖獲取流程,可以用如下的示意圖來表示:

總結

本文首先簡單講解了 synchronized 關鍵字實現同步的原理,其實是通過 Java 虛擬機器器規範對於 monitorenter 和 monitorexit 的支援,從而使得 synchronized 能夠實現同步。而 synchronized 同步本質上是通過作業系統的 mutex 鎖來實現的。由於操作作業系統 mutex 鎖太過於消耗資源,因此在 JDK1.6 後 HotSpot 虛擬機器器做了一系列的鎖優化,其中最重要的便是:自旋鎖、輕量級鎖、偏向鎖。這三個鎖的誕生原因,以及提升的點如下表所示。

現狀 鎖名稱 收益 使用場景
大多數情況下,等待鎖的時間比作業系統 mutex 短得多 自旋鎖 減少核心態與使用者態切換的開銷 執行緒獲取鎖時間較短的情況
大多數情況下,鎖同步期間沒有執行緒競爭 輕量級鎖 與自旋鎖相比,減少了自旋時間 沒有執行緒競爭鎖
大多數情況下,鎖同步期間沒有執行緒競爭 偏向鎖 與輕量級鎖相比,減少了多餘的物件複製操作 沒有執行緒競爭鎖

從上面表格可以看到,自旋鎖、輕量級鎖、偏向鎖,他們的優化是逐漸深入的。

  1. 對於重量級鎖來說,自旋鎖減少了互斥量的核心、使用者態切換開銷。
  2. 輕量級鎖,是自旋鎖再 Java 記憶體模型裡的直接應用,其同樣是減少了核心態與使用者態的切換開銷。
  3. 偏向鎖,相對於輕量級鎖來說,減少了多餘的物件複製操作,因此效率更高一些。

參考資料