淺談偏向鎖、輕量級鎖、重量級鎖

monkeysayhi發表於2018-01-15

為了換取效能,JVM在內建鎖上做了非常多的優化,膨脹式的鎖分配策略就是其一。理解偏向鎖、輕量級鎖、重量級鎖的要解決的基本問題,幾種鎖的分配和膨脹過程,有助於編寫並優化基於鎖的併發程式。

內建鎖的分配和膨脹過程較為複雜,限於時間和精力,文中該部分內容是根據網上的多方資料整合而來;僅為方便查閱,後面繼續分析JVM原始碼的時候也有個參考。如果對各級鎖已經有了基本瞭解,讀者大可跳過此文。

隱藏在內建鎖下的基本問題

內建鎖是JVM提供的最便捷的執行緒同步工具,在程式碼塊或方法宣告上新增synchronized關鍵字即可使用內建鎖。使用內建鎖能夠簡化併發模型;隨著JVM的升級,幾乎不需要修改程式碼,就可以直接享受JVM在內建鎖上的優化成果。從簡單的重量級鎖,到逐漸膨脹的鎖分配策略,使用了多種優化手段解決隱藏在內建鎖下的基本問題。

重量級鎖

內建鎖在Java中被抽象為監視器鎖(monitor)。在JDK 1.6之前,監視器鎖可以認為直接對應底層作業系統中的互斥量(mutex)。這種同步方式的成本非常高,包括系統呼叫引起的核心態與使用者態切換、執行緒阻塞造成的執行緒切換等。因此,後來稱這種鎖為“重量級鎖”。

自旋鎖

首先,核心態與使用者態的切換上不容易優化。但通過自旋鎖,可以減少執行緒阻塞造成的執行緒切換(包括掛起執行緒和恢復執行緒)。

如果鎖的粒度小,那麼鎖的持有時間比較短(儘管具體的持有時間無法得知,但可以認為,通常有一部分鎖能滿足上述性質)。那麼,對於競爭這些鎖的而言,因為鎖阻塞造成執行緒切換的時間與鎖持有的時間相當,減少執行緒阻塞造成的執行緒切換,能得到較大的效能提升。具體如下:

  • 當前執行緒競爭鎖失敗時,打算阻塞自己
  • 不直接阻塞自己,而是自旋(空等待,比如一個空的有限for迴圈)一會
  • 在自旋的同時重新競爭鎖
  • 如果自旋結束前獲得了鎖,那麼鎖獲取成功;否則,自旋結束後阻塞自己

如果在自旋的時間內,鎖就被舊owner釋放了,那麼當前執行緒就不需要阻塞自己(也不需要在未來鎖釋放時恢復),減少了一次執行緒切換。

“鎖的持有時間比較短”這一條件可以放寬。實際上,只要鎖競爭的時間比較短(比如執行緒1快釋放鎖的時候,執行緒2才會來競爭鎖),就能夠提高自旋獲得鎖的概率。這通常發生在鎖持有時間長,但競爭不激烈的場景中。

缺點

  • 單核處理器上,不存在實際的並行,當前執行緒不阻塞自己的話,舊owner就不能執行,鎖永遠不會釋放,此時不管自旋多久都是浪費;進而,如果執行緒多而處理器少,自旋也會造成不少無謂的浪費。
  • 自旋鎖要佔用CPU,如果是計算密集型任務,這一優化通常得不償失,減少鎖的使用是更好的選擇。
  • 如果鎖競爭的時間比較長,那麼自旋通常不能獲得鎖,白白浪費了自旋佔用的CPU時間。這通常發生在鎖持有時間長,且競爭激烈的場景中,此時應主動禁用自旋鎖。

使用-XX:-UseSpinning引數關閉自旋鎖優化;-XX:PreBlockSpin引數修改預設的自旋次數。

自適應自旋鎖

自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定:

  • 如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100個迴圈。
  • 相反的,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能減少自旋時間甚至省略自旋過程,以避免浪費處理器資源。

自適應自旋解決的是“鎖競爭時間不確定”的問題。JVM很難感知到確切的鎖競爭時間,而交給使用者分析就違反了JVM的設計初衷。自適應自旋假定不同執行緒持有同一個鎖物件的時間基本相當,競爭程度趨於穩定,因此,可以根據上一次自旋的時間與結果調整下一次自旋的時間

缺點

然而,自適應自旋也沒能徹底解決該問題,如果預設的自旋次數設定不合理(過高或過低),那麼自適應的過程將很難收斂到合適的值

輕量級鎖

自旋鎖的目標是降低執行緒切換的成本。如果鎖競爭激烈,我們不得不依賴於重量級鎖,讓競爭失敗的執行緒阻塞;如果完全沒有實際的鎖競爭,那麼申請重量級鎖都是浪費的。輕量級鎖的目標是,減少無實際競爭情況下,使用重量級鎖產生的效能消耗,包括系統呼叫引起的核心態與使用者態切換、執行緒阻塞造成的執行緒切換等。

顧名思義,輕量級鎖是相對於重量級鎖而言的。使用輕量級鎖時,不需要申請互斥量,僅僅_將Mark Word中的部分位元組CAS更新指向執行緒棧中的Lock Record,如果更新成功,則輕量級鎖獲取成功_,記錄鎖狀態為輕量級鎖;否則,說明已經有執行緒獲得了輕量級鎖,目前發生了鎖競爭(不適合繼續使用輕量級鎖),接下來膨脹為重量級鎖

Mark Word是物件頭的一部分;每個執行緒都擁有自己的執行緒棧(虛擬機器棧),記錄執行緒和函式呼叫的基本資訊。二者屬於JVM的基礎內容,此處不做介紹。

當然,由於輕量級鎖天然瞄準不存在鎖競爭的場景,如果存在鎖競爭但不激烈,仍然可以用自旋鎖優化,自旋失敗後再膨脹為重量級鎖

缺點

同自旋鎖相似:

  • 如果鎖競爭激烈,那麼輕量級將很快膨脹為重量級鎖,那麼維持輕量級鎖的過程就成了浪費。

偏向鎖

在沒有實際競爭的情況下,還能夠針對部分場景繼續優化。如果不僅僅沒有實際競爭,自始至終,使用鎖的執行緒都只有一個,那麼,維護輕量級鎖都是浪費的。偏向鎖的目標是,減少無競爭且只有一個執行緒使用鎖的情況下,使用輕量級鎖產生的效能消耗。輕量級鎖每次申請、釋放鎖都至少需要一次CAS,但偏向鎖只有初始化時需要一次CAS。

“偏向”的意思是,偏向鎖假定將來只有第一個申請鎖的執行緒會使用鎖(不會有任何執行緒再來申請鎖),因此,只需要在Mark Word中CAS記錄owner(本質上也是更新,但初始值為空),如果記錄成功,則偏向鎖獲取成功,記錄鎖狀態為偏向鎖,以後當前執行緒等於owner就可以零成本的直接獲得鎖;否則,說明有其他執行緒競爭,膨脹為輕量級鎖

偏向鎖無法使用自旋鎖優化,因為一旦有其他執行緒申請鎖,就破壞了偏向鎖的假定。

缺點

同樣的,如果明視訊記憶體在其他執行緒申請鎖,那麼偏向鎖將很快膨脹為輕量級鎖。

不過這個副作用已經小的多。

如果需要,使用引數-XX:-UseBiasedLocking禁止偏向鎖優化(預設開啟)。

小結

偏向鎖、輕量級鎖、重量級鎖分配和膨脹的詳細過程見後。會涉及一些Mark Word與CAS的知識。

偏向鎖、輕量級鎖、重量級鎖適用於不同的併發場景:

  • 偏向鎖:無實際競爭,且將來只有第一個申請鎖的執行緒會使用鎖。
  • 輕量級鎖:無實際競爭,多個執行緒交替使用鎖;允許短時間的鎖競爭。
  • 重量級鎖:有實際競爭,且鎖競爭時間長。

另外,如果鎖競爭時間短,可以使用自旋鎖進一步優化輕量級鎖、重量級鎖的效能,減少執行緒切換。

如果鎖競爭程度逐漸提高(緩慢),那麼從偏向鎖逐步膨脹到重量鎖,能夠提高系統的整體效能。

鎖分配和膨脹過程

重申,這部分主要是根據網上的多方資料整理。核心是這位巨巨整理的流程圖,相當詳細,基本符合邏輯。

前面講述了內建鎖在使用過程中的一些基本問題和解決方案,實現原理一筆帶過。詳細的鎖分配和膨脹過程如下:

image.png

圖中有一處疑問:

按照圖中流程,如果發現鎖已經膨脹為重量級鎖,就直接使用互斥量mutex阻塞當前執行緒

然而,自旋鎖的一大好處就是減少執行緒切換的開銷。在這裡沒有必要直接阻塞當前執行緒,大可以像輕量級鎖一樣,自旋一會,失敗了再阻塞。

特別說明兩點:

  • CAS記錄owner時,expected == nullnewValue == ownerThreadId,因此,只有第一個申請偏向鎖的執行緒能夠返回成功,後續執行緒都必然失敗(部分執行緒檢測到可偏向,同時嘗試CAS記錄owner)。
  • 內建鎖只能沿著偏向鎖、輕量級鎖、重量級鎖的順序逐漸膨脹,不能“收縮”。這基於JVM的另一個假定,“一旦破壞了上一級鎖的假定,就認為該假定以後也必不成立”。

另外,當重量級鎖被解除後,需要喚醒一個被阻塞的執行緒,這部分邏輯與ReentrantLock基本相同,詳見原始碼|併發一枝花之ReentrantLock與AQS(1):lock、unlock

簡化版

上圖記載的很詳細,也有Mark Word的圖解。看懂上圖後,再來看《深入理解Java虛擬機器:JVM高階特性與最佳實踐(第2版)》中的簡化版流程圖就能看懂了:

image.png

挖坑:

簡化版中指出了重偏向過程。這一過程對於效能優化和膨脹過程都非常重要;但如果考慮重偏向的話,可能上述特別說明的內容就不成立了。要整理的筆記太多啦時間不夠啊,猴子選擇暫時放棄這個問題,,,恩,挖個坑,以後再追原始碼填坑。

重偏向和epoch的作用參考:


參考:


本文連結:淺談偏向鎖、輕量級鎖、重量級鎖
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章