1. 程式人生 > >虛擬機器內的鎖優化(偏向鎖,輕量級鎖,自旋鎖,重量級鎖)

虛擬機器內的鎖優化(偏向鎖,輕量級鎖,自旋鎖,重量級鎖)

基礎知識之一:鎖的型別

鎖從巨集觀上分為:(1)樂觀鎖;(2)悲觀鎖。

(1)樂觀鎖

樂觀鎖是一種樂觀思想,即認為讀多寫少,遇到併發寫的可能性低,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,採取的方式是在寫時先讀出當前版本號,然後加鎖操作(比較跟上一次的版本號,如果一樣則更新),如果失敗則要重複讀-比較-寫的操作。

java中的樂觀鎖基本都是通過CAS操作實現的,CAS是一種更新的原子操作,比較當前值跟傳入值是否一樣,一樣則更新,否則失敗。

(2)悲觀鎖

悲觀鎖是就是悲觀思想,即認為寫多,遇到併發寫的可能性高,每次去拿資料的時候都認為別人會修改,所以每次在讀寫資料的時候都會上鎖,這樣別人想讀寫這個資料就會block直到拿到鎖。java中的悲觀鎖就是Synchronized,而AQS框架下的鎖則是先嚐試CAS樂觀鎖去獲取鎖,獲取不到,才會轉換為悲觀鎖,如RetreenLock。

基礎知識之二:java執行緒阻塞的代價

java的執行緒是對映到作業系統原生執行緒之上的,如果要阻塞或喚醒一個執行緒就需要作業系統介入,需要在使用者態與核心態之間切換。這種切換會消耗大量的系統資源,因為使用者態與核心態都有各自專用的記憶體空間,專用的暫存器等,使用者態切換至核心態需要傳遞給許多變數、引數給核心,核心也需要保護好使用者態在切換時的一些暫存器值、變數等,以便核心態呼叫結束後切換回使用者態繼續工作。

如果執行緒狀態切換是一個高頻操作時,這將會消耗很多CPU處理時間;
如果對於那些需要同步的簡單的程式碼塊,獲取鎖掛起操作消耗的時間比使用者程式碼執行的時間還要長,這種同步策略顯然非常糟糕的。
synchronized會導致爭用不到鎖的執行緒進入阻塞狀態,所以說它是java語言中一個重量級的同步操縱,被稱為重量級鎖,為了緩解上述效能問題,JVM從1.5開始,引入了輕量鎖與偏向鎖,預設啟用了自旋鎖,他們都屬於樂觀鎖。

明確java執行緒切換的代價,是理解java中各種鎖的優缺點的基礎之一。

基礎知識之三:markword

在介紹java鎖之前,先說下什麼是markword,markword是java物件資料結構中的一部分。

markword資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為32bit和64bit,它的最後2bit是鎖狀態標誌位,用來標記當前物件的狀態,物件的所處的狀態,決定了markword儲存的內容,如下表所示:
這裡寫圖片描述

32位虛擬機器在不同狀態下markword結構如下圖所示:
這裡寫圖片描述

瞭解了markword結構,有助於後面瞭解java鎖的加鎖解鎖過程。

偏向鎖、輕量級鎖、自旋鎖、重量級鎖

前三種是樂觀鎖,後一種是悲觀鎖。

(1)偏向鎖

Java偏向鎖(Biased Locking)是Java6引入的一項多執行緒優化。

偏向鎖,顧名思義,它會偏向於第一個訪問鎖的執行緒,如果在執行過程中,同步鎖只有一個執行緒訪問,不存在多執行緒爭用的情況,則執行緒是不需要觸發同步的,這種情況下,就會給執行緒加一個偏向鎖。

如果在執行過程中,遇到了其他執行緒搶佔鎖,則持有偏向鎖的執行緒會被掛起,JVM會消除它身上的偏向鎖,將鎖恢復到標準的輕量級鎖。

它通過消除資源無競爭情況下的同步原語,進一步提高了程式的執行效能。

偏向鎖獲取過程:

  1. 訪問Mark Word中偏向鎖的標識是否設定成1,鎖標誌位是否為01,確認為可偏向狀態。

  2. 如果為可偏向狀態,則測試執行緒ID是否指向當前執行緒,如果是,進入步驟5,否則進入步驟3。

  3. 如果執行緒ID並未指向當前執行緒,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中執行緒ID設定為當前執行緒ID,然後執行5;如果競爭失敗,執行4。

  4. 如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全域性安全點(safepoint)時獲得偏向鎖的執行緒被掛起,偏向鎖升級為輕量級鎖,然後被阻塞在安全點的執行緒繼續往下執行同步程式碼。(撤銷偏向鎖的時候會導致stop the word)

  5. 執行同步程式碼。

注意:第四步中到達安全點safepoint會導致stop the word,時間很短。

偏向鎖的釋放:

偏向鎖的撤銷在上述第四步驟中有提到。偏向鎖只有遇到其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖,執行緒不會主動去釋放偏向鎖。偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有位元組碼正在執行),它會首先暫停擁有偏向鎖的執行緒,判斷鎖物件是否處於被鎖定狀態,撤銷偏向鎖後恢復到未鎖定(標誌位為“01”)或輕量級鎖(標誌位為“00”)的狀態。

偏向鎖的適用場景:

始終只有一個執行緒在執行同步塊,在它沒有執行完釋放鎖之前,沒有其它執行緒去執行同步塊,在鎖無競爭的情況下使用,一旦有了競爭就升級為輕量級鎖,升級為輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向鎖的時候會導致stop the word操作;

在有鎖的競爭時,偏向鎖會多做很多額外操作,尤其是撤銷偏向所的時候會導致進入安全點,安全點會導致stw,導致效能下降,這種情況下應當禁用。

(2)輕量級鎖

輕量級鎖是由偏向鎖升級來的,偏向鎖執行在一個執行緒進入同步塊的情況下,當第二個執行緒加入鎖競爭的時候,偏向鎖就會升級為輕量級鎖。

輕量級鎖的加鎖過程:

  1. 在程式碼進入同步塊的時候,如果同步物件鎖狀態為無鎖狀態(鎖標誌位為“01”狀態,是否為偏向鎖為“0”),虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝,官方稱之為 Displaced Mark Word。這時候執行緒堆疊與物件頭的狀態如圖:
    這裡寫圖片描述

  2. 拷貝物件頭中的Mark Word複製到鎖記錄(Lock Record)中;

  3. 拷貝成功後,虛擬機器將使用CAS操作嘗試將鎖物件的Mark Word更新為指向Lock Record的指標,並將執行緒棧幀中的Lock Record裡的owner指標指向Object的 Mark Word。如果更新成功,則執行步驟4,否則執行步驟5。

  4. 如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位設定為“00”,即表示此物件處於輕量級鎖定狀態,這時候執行緒堆疊與物件頭的狀態如圖所示。

這裡寫圖片描述

  1. 如果這個更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行。否則說明多個執行緒競爭鎖,輕量級鎖就要膨脹為重量級鎖,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。 而當前執行緒便嘗試使用自旋來獲取鎖,自旋就是為了不讓執行緒阻塞,而採用迴圈去獲取鎖的過程。

(3)自旋鎖

當競爭存在時,因為輕量級鎖嘗試失敗,之後有可能會直接升級成重量級鎖動用作業系統層面的互斥。也有可能再嘗試一下自旋鎖。

自旋鎖原理非常簡單,如果持有鎖的執行緒能在很短時間內釋放鎖資源,那麼那些等待競爭鎖的執行緒就不需要做核心態和使用者態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),並且不停地嘗試拿到這個鎖(類似tryLock),當然迴圈的次數是有限制的,等持有鎖的執行緒釋放鎖後即可立即獲取鎖,這樣就避免使用者執行緒和核心的切換的消耗。

但是執行緒自旋是需要消耗cup的,說白了就是讓cup在做無用功,執行緒不能一直佔用cup自旋做無用功,所以需要設定一個自旋等待的最大時間。

如果持有鎖的執行緒執行的時間超過自旋等待的最大時間扔沒有釋放鎖,就會導致其它爭用鎖的執行緒在最大等待時間內還是獲取不到鎖,這時爭用執行緒會停止自旋進入阻塞狀態。

自旋鎖的優缺點:

自旋鎖儘可能的減少執行緒的阻塞,這對於鎖的競爭不激烈,且佔用鎖時間非常短的程式碼塊來說效能能大幅度的提升,因為自旋的消耗會小於執行緒阻塞掛起操作的消耗!

但是如果鎖的競爭激烈,或者持有鎖的執行緒需要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,因為自旋鎖在獲取鎖前一直都是佔用cpu做無用功,執行緒自旋的消耗大於執行緒阻塞掛起操作的消耗,其它需要cup的執行緒又不能獲取到cpu,造成cpu的浪費。

自旋鎖的開啟:

JDK1.6中-XX:+UseSpinning開啟;
JDK1.7後,去掉此引數,由jvm控制;

(4)重量級鎖 synchronized

synchronized的作用:

在JDK1.5之前都是使用synchronized關鍵字保證同步的,synchronized的作用相信大家都已經非常熟悉了;

它可以把任意一個非NULL的物件當作鎖。

作用於方法時,鎖住的是物件的例項(this)
當作用於靜態方法時,鎖住的是Class例項,又因為Class的相關資料儲存在永久帶PermGen(jdk1.8則是metaspace),永久帶是全域性共享的,因此靜態方法鎖相當於類的一個全域性鎖,會鎖所有呼叫該方法的執行緒;
synchronized作用於一個物件例項時,鎖住的是所有以該物件為鎖的程式碼塊。

synchronized的實現

實現如下圖所示;
這裡寫圖片描述

它有多個佇列,當多個執行緒一起訪問某個物件監視器的時候,物件監視器會將這些執行緒儲存在不同的容器中。

  1. Contention List:競爭佇列,所有請求鎖的執行緒首先被放在這個競爭佇列中;

  2. Entry List:Contention List中那些有資格成為候選資源的執行緒被移動到Entry List中;

  3. Wait Set:哪些呼叫wait方法被阻塞的執行緒被放置在這裡;

  4. OnDeck:任意時刻,最多隻有一個執行緒正在競爭鎖資源,該執行緒被成為OnDeck;

  5. Owner:當前已經獲取到所資源的執行緒被稱為Owner;

  6. !Owner:當前釋放鎖的執行緒。

JVM每次從佇列的尾部取出一個數據用於鎖競爭候選者(OnDeck),但是併發情況下,ContentionList會被大量的併發執行緒進行CAS訪問,為了降低對尾部元素的競爭,JVM會將一部分執行緒移動到EntryList中作為候選競爭執行緒。Owner執行緒會在unlock時,將ContentionList中的部分執行緒遷移到EntryList中,並指定EntryList中的某個執行緒為OnDeck執行緒(一般是最先進去的那個執行緒)。Owner執行緒並不直接把鎖傳遞給OnDeck執行緒,而是把鎖競爭的權利交給OnDeck,OnDeck需要重新競爭鎖。這樣雖然犧牲了一些公平性,但是能極大的提升系統的吞吐量,在JVM中,也把這種選擇行為稱之為“競爭切換”。

OnDeck執行緒獲取到鎖資源後會變為Owner執行緒,而沒有得到鎖資源的仍然停留在EntryList中。如果Owner執行緒被wait方法阻塞,則轉移到WaitSet佇列中,直到某個時刻通過notify或者notifyAll喚醒,會重新進去EntryList中。

處於ContentionList、EntryList、WaitSet中的執行緒都處於阻塞狀態,該阻塞是由作業系統來完成的(Linux核心下采用pthread_mutex_lock核心函式實現的)。

synchronized是非公平鎖。 synchronized線上程進入ContentionList時,等待的執行緒會先嚐試自旋獲取鎖,如果獲取不到就進入ContentionList,這明顯對於已經進入佇列的執行緒是不公平的,還有一個不公平的事情就是自旋獲取鎖的執行緒還可能直接搶佔OnDeck執行緒的鎖資源。