理解JVM(六):執行緒安全和鎖優化

Joepis發表於2018-06-27

執行緒安全的實現方法

互斥同步

互斥是因,同步是果;互斥是方法,同步是目的。

synchronized關鍵字

  • synchronized關鍵字是基本的互斥同步手段。它在編譯後會在同步程式碼塊前後加入2條位元組碼指令:monitorentermonitorexit
  • 這兩個位元組碼都需要一個reference型別的引數來指明要鎖定和解鎖的物件。如果Java程式中的synchronized指定了物件引數,那就是這個物件的reference;如果沒有指定,就根據synchronized修飾的是例項方法還是類方法,去取對應的物件例項或Class物件來作為鎖物件。
  • 執行monitorenter指令時,首先要嘗試獲取物件的鎖。如果這個物件沒被鎖定,或當前執行緒已經擁有了那個物件的鎖,把鎖的計數器加1;在執行monitorexit指令時會將鎖計數器減1。當計數器為0時,鎖就被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到物件鎖被另外一個執行緒釋放為止。
  • synchronized同步塊對同一條執行緒來說是可重入的,不會出現自己把自己鎖死的問題。
  • 同步塊在已進入的執行緒執行完之前,會阻塞後面其他執行緒的進入。
  • Java的執行緒是對映到作業系統的原生執行緒之上的,如果要阻塞或喚醒一個執行緒,都需要作業系統來完成,這就需要從使用者態轉換到核心態中,因此狀態轉換需要耗費很多的處理器時間,所以synchronized是Java語言中一個重量級的操作。不過虛擬機器會有一些優化措施,比如自旋等待。

ReentrantLock重入鎖

重入鎖位於java.util.concurrent包。基本用法和synchronized相似,只是程式碼寫法有區別:synchronized是原生語法層面的實現。ReentrantLock是API層面,使用lock()unlock()方法配合try/finally語句塊來實現。

重入鎖有3個高階特性:

  • 等待可中斷:當持有鎖的執行緒長期不釋放鎖時,正在等待的執行緒可以選擇放棄等待,改為處理其他事情。可中斷特性對處理執行時間非常長的同步塊很有幫助。
  • 可實現公平鎖:公平鎖是指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock預設情況下也是非公平的,但可以通過帶布林值的建構函式要求使用公平鎖。
  • 鎖可以繫結多個條件:一個ReentrantLock物件可以同時繫結多個Condition物件,而在synchronized中,鎖物件的wait()notify()notifyAll()方法可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外地新增一個鎖,而ReentrantLock則無須這樣做,只需要多次呼叫newCondition()方法即可。

效能比較

  • JDK1.6之前,在多執行緒環境下,synchronized的吞吐量隨著處理器數量增加而下降得非常嚴重。
  • JDK1.6之後,虛擬機器做了優化,2種方式效能差不多。推薦優先使用synchronized方式。

非阻塞同步

互斥同步最主要的問題就是進行執行緒阻塞和喚醒所帶來的效能問題,因此這種同步也稱為阻塞同步(Blocking Synchronization)。

按處理問題的方式來說:

  • 互斥同步是悲觀併發策略:無論是否產生共享資料爭用,都會做同步措施(加鎖,使用者態核心態轉換等)。
  • 非阻塞同步是一種樂觀併發策略:它基於衝突檢測。通俗的說,就是先執行程式碼,若沒有發生共享資料爭用,就成功執行;若發生共享資料爭用,就採取補償措施(比如不斷重試,直到成功),這種策略不會導致執行緒阻塞。

CAS操作: CAS指令需要有3個運算元,分別是記憶體位置(在Java中可以簡單理解為變數的記憶體地址,用V表示)、舊的預期值(用A表示)和新值(用B表示)。CAS指令執行時,當且僅當V符合舊預期值A時,處理器用新值B更新V的值,否則它就不執行更新,但是無論是否更新了V的值,都會返回V的舊值,這個處理過程是個原子操作。

ABA問題: 如果一個變數V初次讀取的時候是A值,並且在準備賦值的時候檢查到它仍然為A值,那我們就能說它的值沒有被其他執行緒改變過了嗎?如果在這段期間它的值曾經被改成了B,後來又被改回為A,那CAS操作就會誤認為它從來沒有被改變過。

無同步方案

如果一個方法本來就不涉及共享資料,那它就無須任何同步措施去保證正確性。

  • 可重入程式碼:這種程式碼也叫做純程式碼(Pure Code),可以在程式碼執行的任何時刻中斷它,轉而去執行另外一段程式碼(包括遞迴呼叫它本身),而在控制權返回後,原來的程式不會出現任何錯誤。
  • 執行緒本地儲存:一段程式碼中所需要的資料必須與其他程式碼共享,並且可以把共享資料的可見範圍限制在同一個執行緒之內,這樣,無須同步也能保證執行緒之間不出現資料爭用的問題。
    • Java語言中,如果一個變數要被多執行緒訪問,可以使用volatile關鍵字宣告它為“易變的”;如果一個變數要被某個執行緒獨享,Java中就沒有類似C++中__declspec(thread) 這樣的關鍵字,不過還是可以通過java.lang.ThreadLocal類來實現執行緒本地儲存的功能。每一個執行緒的Thread物件中都有一個ThreadLocalMap物件,這個物件儲存了一組以ThreadLocal.threadLocalHashCode為鍵,以本地執行緒變數為值的K-V值對,ThreadLocal物件就是當前執行緒的ThreadLocalMap的訪問入口,每一個ThreadLocal物件都包含了一個獨一無二的threadLocalHashCode值,使用這個值就可以線上程K-V值對中找回對應的本地執行緒變數。

鎖優化

適應性自旋(Adaptive Spinning)

執行緒阻塞的時候,讓等待的執行緒不放棄cpu執行時間,而是執行一個自旋(一般是空迴圈),這叫做自旋鎖。

自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,因此,如果鎖被佔用的時間很短,自旋等待的效果就非常好,反之,如果鎖被佔用的時間很長,那麼自旋的執行緒只會白白消耗處理器資源,帶來效能上的浪費。

因此,自旋等待的時間必須要有一定的限度。如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒了。自旋次數的預設值是10次,使用者可以使用引數-XX:PreBlockSpin來更改。

JDK1.6引入了自適應的自旋鎖。自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。比如前一次自旋了3次就獲得了一個鎖,那麼下一次虛擬機器會允許他自旋更多次來獲得這個鎖。如果一個鎖很少能通過自旋成功獲得,那麼之後再遇到這個情況就會省略自旋過程了。

鎖消除(Lock Elimination)

虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。一般根據逃逸分析的資料支援來作為判定依據。

鎖粗化(Lock Coarsening)

原則上,我們在編寫程式碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享資料的實際作用域中才進行同步,這樣是為了使需要同步的運算元量儘可能變小,如果存在鎖競爭,那等待鎖的執行緒也能儘快拿到鎖。

但如果一系列操作頻繁對同一個物件加鎖解鎖,或者加鎖操作再迴圈體內,會耗費效能,這時虛擬機器會擴大加鎖範圍。

輕量級鎖(Lightweight Locking)

輕量級鎖是JDK 1.6之中加入的新型鎖機制。它的作用是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。

HotSpot虛擬機器的物件頭(Object Header)分為兩部分資訊,第一部分用於儲存物件自身的執行時資料,這部分稱為Mark Word。還有一部分儲存指向方法區物件型別資料的指標。

加鎖

在程式碼進入同步塊的時候,如果此同步物件沒有被鎖定(鎖標誌位為“01”狀態),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced字首,即Displaced Mark Word)。然後,虛擬機器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標。如果這個更新動作成功,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位(Mark Word的最後2bit)將轉變為“00”,即表示此物件處於輕量級鎖定狀態。如果這個更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行,否則說明這個鎖物件已經被其他執行緒搶佔了。如果有兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。

解鎖

解鎖過程也是通過CAS操作來進行的。如果物件的Mark Word仍然指向著執行緒的鎖記錄,那就用CAS操作把物件當前的Mark Word和執行緒中複製的Displaced Mark Word替換回來,如果替換成功,整個同步過程就完成了。如果替換失敗,說明有其他執行緒嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的執行緒。

效能

沒有鎖競爭時,輕量級鎖用CAS操作替代互斥量的開銷,效能較優。有鎖競爭時,除了互斥量開銷,還有CAS操作開銷,所以效能較差。但是,一般情況下,在整個同步週期內都是不存在競爭的”,這是一個經驗資料。

偏向鎖(Biased Locking)

偏向鎖也是JDK1.6中引入的鎖優化,它的目的是消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連CAS操作都不做了。

當鎖物件第一次被執行緒獲取的時候,虛擬機器將會把物件頭中的標誌位設為“01”,即偏向模式。同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的Mark Word之中,如果CAS操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作。當有另外一個執行緒去嘗試獲取這個鎖時,偏向模式結束。

偏向鎖可以提高帶有同步但無競爭的程式效能,但並不一定總是對程式執行有利。如果程式中大多數的鎖總是被多個不同的執行緒訪問,那偏向模式就是多餘的。在具體問題具體分析的前提下,有時候使用引數-XX:-UseBiasedLocking來禁止偏向鎖優化反而可以提升效能。

相關文章