偏向鎖、輕量級鎖及重量級鎖

1. 重量級鎖

內置鎖是JVM提供的最便捷的線程同步工具,利用synchronized關鍵字來修飾同步代碼塊,我們稱這種鎖爲java的內置鎖(intrinsic lock)或者監視器鎖(monitor lock)。

1.1 監視器模型

首先要明確的一點是監視器模型不是Java特有的,它是操作系統層次的概念,是爲了實現線程同步而採取的技術手段,任何編程語言的併發設計中都可以出現這個概念。
JVM會爲每個對象分配一個monitor,而同時只能有一個線程可以獲得該對象monitor的所有權。在線程進入時通過monitorenter嘗試取得對象monitor所有權,退出時通過monitorexit釋放對象monitor所有權。
monitorenter與monitorexit在編譯後對稱插入代碼。

  • monitorenter: 被插入到同步代碼塊之前。
  • monitorexit: 被插到同步代碼塊之後或異常處。

監視器可以看做是經過特殊佈置的建築,這個建築有一個特殊的房間,該房間通常包含一些數據和代碼,但是一次只能一個消費者(thread)使用此房間。
在這裏插入圖片描述

當一個消費者(線程)使用了這個房間,首先他必須到一個大廳(Entry Set)等待,調度程序將基於某些標準(e.g. FIFO)將從大廳中選擇一個消費者(線程),進入特殊房間,如果這個線程因爲某些原因被“掛起”,它將被調度程序安排到“等待房間”,並且一段時間之後會被重新分配到特殊房間,按照上面的線路,這個建築物包含三個房間,分別是“特殊房間”、“大廳”以及“等待房間”。
在這裏插入圖片描述
簡單來說,監視器用來監視線程進入這個特別房間,它確保同一時間只能有一個線程可以訪問特殊房間中的數據和代碼。

那麼,鎖和監視器有什麼區別?
一言以蔽之,鎖爲實現監視器提供必要的支持的,監視器是比鎖更高層次的抽象。
鎖是存在於對象內部的數據結構,監視器是一個獨立的結構,但是和對象關聯。另外,監視器是操控線程的,它會維持一個代碼數據區和線程隊列等,保證同一時刻只有一個線程訪問代碼數據區。

1.2 侷限性

java的線程是映射到操作系統原生線程之上的,如果要阻塞或喚醒一個線程就需要操作系統介入,需要在戶態與核心態之間切換,這種切換會消耗大量的系統資源,因爲用戶態與內核態都有各自專用的內存空間,專用的寄存器等,用戶態切換至內核態需要傳遞給許多變量、參數給內核,內核也需要保護好用戶態在切換時的一些寄存器值、變量等,以便內核態調用結束後切換回用戶態繼續工作。
如果線程狀態切換是一個高頻操作時,這將會消耗很多CPU處理時間;此外,獲取鎖掛起操作消耗的時間往往比用戶代碼執行的時間還要長,這種同步策略顯然非常糟糕的。
synchronized會導致爭用不到鎖的線程進入阻塞狀態,所以被稱爲“重量級鎖”。

jvm的研究人員在花費了大量的精力去實現各種鎖優化技術,如適應性自旋(Adaptive Spinning)、鎖削除(Lock Elimination)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)等,這些技術都是爲了在線程之間更高效地共享數據,以及解決競爭問題,從而提高程序的執行效率。

2. 自旋鎖

重量級鎖的成本非常高,而且不容易優化。同時,虛擬機的開發團隊也注意到在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛起和恢復線程並不值得。如果物理機器有一個以上的處理器,能讓兩個或以上的線程同時並行執行,我們就可以讓後面請求鎖的那個線程“稍等一會”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,我們只須讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。
那麼,對於競爭這些鎖的而言,因爲鎖阻塞造成線程切換的時間與鎖持有的時間相當,減少線程阻塞造成的線程切換,能得到較大的性能提升。具體如下:

  1. 當前線程競爭鎖失敗時,打算阻塞自己
  2. 不直接阻塞自己,而是自旋(空等待,比如一個空的有限for循環)一會
  3. 在自旋的同時重新競爭鎖
  4. 如果自旋結束前獲得了鎖,那麼鎖獲取成功;否則,自旋結束後阻塞自己

如果在自旋的時間內,鎖就被舊owner釋放了,那麼當前線程就不需要阻塞自己(也不需要在未來鎖釋放時恢復),減少了一次線程切換。
“鎖的持有時間比較短”這一條件可以放寬。實際上,只要鎖競爭的時間比較短(比如線程1快釋放鎖的時候,線程2纔會來競爭鎖),就能夠提高自旋獲得鎖的概率。這通常發生在鎖持有時間長,但競爭不激烈的場景中。

2.1 自適應自旋

自旋鎖在JDK 1.4.2中就已經引入,只不過默認是關閉的,可以使用-XX:+UseSpinning參數來開啓,在JDK 1.6中就已經改爲默認開啓了。
自旋等待不能代替阻塞。
首先,單核處理器上,不存在實際的並行,當前線程不阻塞自己的話,舊owner就不能執行,鎖永遠不會釋放,此時不管自旋多久都是浪費;進而,如果線程多而處理器少,自旋也會造成不少無謂的浪費。
其次,自旋鎖要佔用CPU,如果是計算密集型任務,這一優化通常得不償失,減少鎖的使用是更好的選擇。
如果鎖競爭的時間比較長,那麼自旋通常不能獲得鎖,白白浪費了自旋佔用的CPU時間。因此自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起線程了。自旋次數的默認值是10次,用戶可以使用參數-XX:PreBlockSpin來更改(JDK1.7後,去掉此參數,由jvm控制)。

在JDK 1.6中引入了自適應的自旋鎖。自適應意味着自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認爲這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如100個循環。另一方面,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。

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

3. 輕量級鎖

自旋鎖的目標是降低線程切換的成本。如果鎖競爭激烈,我們不得不依賴於重量級鎖,讓競爭失敗的線程阻塞;如果完全沒有實際的鎖競爭,那麼申請重量級鎖都是浪費的。輕量級鎖的目標是,減少無實際競爭情況下,使用重量級鎖產生的性能消耗,包括系統調用引起的內核態與用戶態切換、線程阻塞造成的線程切換等。
顧名思義,輕量級鎖是相對於重量級鎖而言的。使用輕量級鎖時,不需要申請互斥量,僅僅將Mark Word中的部分字節CAS更新指向線程棧中的Lock Record,如果更新成功,則輕量級鎖獲取成功,記錄鎖狀態爲輕量級鎖;否則,說明已經有線程獲得了輕量級鎖,目前發生了鎖競爭(不適合繼續使用輕量級鎖),接下來膨脹爲重量級鎖。
當然,由於輕量級鎖天然瞄準不存在鎖競爭的場景,如果存在鎖競爭但不激烈,仍然可以用自旋鎖優化,自旋失敗後再膨脹爲重量級鎖。

4. 偏向鎖

在沒有實際競爭的情況下,還能夠針對部分場景繼續優化。如果不僅僅沒有實際競爭,自始至終,使用鎖的線程都只有一個,那麼,維護輕量級鎖都是浪費的。偏向鎖的目標是,減少無競爭且只有一個線程使用鎖的情況下,使用輕量級鎖產生的性能消耗。輕量級鎖每次申請、釋放鎖都至少需要一次CAS,但偏向鎖只有初始化時需要一次CAS。
“偏向”的意思是,偏向鎖假定將來只有第一個申請鎖的線程會使用鎖(不會有任何線程再來申請鎖),因此,只需要在Mark Word中CAS記錄owner(本質上也是更新,但初始值爲空),如果記錄成功,則偏向鎖獲取成功,記錄鎖狀態爲偏向鎖,以後當前線程等於owner就可以零成本的直接獲得鎖;否則,說明有其他線程競爭,膨脹爲輕量級鎖。需要注意的是,撤銷偏向鎖的時候會會導致進入安全點,安全點會導致STW,導致性能下降。
偏向鎖無法使用自旋鎖優化,因爲一旦有其他線程申請鎖,就破壞了偏向鎖的假定。
偏向鎖可以提高帶有同步但無競爭的程序性能,但是它並不一定總是對程序運行有利,如果程序中大多數的鎖都總是被多個不同的線程訪問,那偏向模式就是多餘的。在具體問題具體分析的前提下,有時候使用參數-XX:-UseBiasedLocking來禁止偏向鎖優化反而可以提升性能。

5. 鎖剔除與鎖粗化

鎖削除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行削除。鎖削除的主要判定依據來源於逃逸分析的數據支持,如果判斷到一段代碼中,在堆上的所有數據都不會逃逸出去被其他線程訪問到,那就可以把它們當作棧上數據對待,認爲它們是線程私有的,同步加鎖自然就無須進行。
但是程序員自己應該是很清楚的,怎麼會在明知道不存在數據爭用的情況下要求同步呢?答案是有許多同步措施並不是程序員自己加入的,同步的代碼在Java程序中的普遍程度也許超過了我們的想象。我們來看看下面的例子,這段非常簡單的代碼僅僅是輸出三個字符串相加的結果,無論是源碼字面上還是程序語義上都沒有同步。

public String concatString(String s1, String s2, String s3) {  
    return s1 + s2 + s3;  
}  

我們也知道,由於String是一個不可變的類,對字符串的連接操作總是通過生成新的String對象來進行的,因此Javac編譯器會對String連接做自動優化。在JDK 1.5之前,會轉化爲StringBuffer對象的連續append()操作,在JDK 1.5及以後的版本中,會轉化爲StringBuilder對象的連續append()操作。

public String concatString(String s1, String s2, String s3) {  
    StringBuffer sb = new StringBuffer();  
    sb.append(s1);  
    sb.append(s2);  
    sb.append(s3);  
    return sb.toString();  
}  

現在大家還認爲這段代碼沒有涉及同步嗎?每個StringBuffer.append()方法中都有一個同步塊,鎖就是sb對象。虛擬機觀察變量sb,很快就會發現它的動態作用域被限制在concatString()方法內部。也就是sb的所有引用永遠不會“逃逸”到concatString()方法之外,其他線程無法訪問到它,所以這裏雖然有鎖,但是可以被安全地削除掉,在即時編譯之後,這段代碼就會忽略掉所有的同步而直接執行了。

原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享數據的實際作用域中才進行同步,這樣是爲了使得需要同步的操作數量儘可能變小,如果存在鎖競爭,那等待鎖的線程也能儘快地拿到鎖。
大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個對象反覆加鎖和解鎖,甚至加鎖操作是出現在循環體中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。
上面代碼中連續的append()方法就屬於這類情況。如果虛擬機探測到有這樣一串零碎的操作都對同一個對象加鎖,將會把加鎖同步的範圍擴展到整個操作序列的外部,就是擴展到第一個append()操作之前直至最後一個append()操作之後,這樣只需要加鎖一次就可以了。即爲鎖粗化。

6. 鎖的分配和膨脹過程

6.1 對象頭

鎖的實現與對象頭密切相關。
HotSpot虛擬機中,對象在內存中存儲的佈局可以分爲三塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)
HotSpot虛擬機的對象頭包括兩部分信息。
第一部分用於存儲對象自身的運行時數據, 如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等等,這部分數據的長度在32位和64位的虛擬機(暫不考慮開啓壓縮指針的場景)中分別爲32個和64個Bits,官方稱它爲“Mark Word”。
對象需要存儲的運行時數據很多,其實已經超出了32、64位Bitmap結構所能記錄的限度,但是對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間。例如在32位的HotSpot虛擬機 中對象未被鎖定的狀態下,Mark Word的32個Bits空間中的25Bits用於存儲對象哈希碼(HashCode),4Bits用於存儲對象分代年齡,2Bits用於存儲鎖標誌 位,1Bit固定爲0,在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容如下表所示。
在這裏插入圖片描述
對象頭的另外一部分是類型指針,即是對象指向它的類的元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說查找對象的元數據信息並不一定要經過對象本身。另外,如果對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因爲虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中無法確定數組的大小。

這裏要特別關注的是鎖標誌位,鎖標誌位與是否偏向鎖對應到唯一的鎖狀態。
鎖的狀態總共有四種:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級。

6.2 偏向鎖實現原理

偏向鎖獲取過程:

  • (1)訪問Mark Word中偏向鎖的標識是否設置成1,鎖標誌位是否爲01——確認爲可偏向狀態。
  • (2)如果爲可偏向狀態,則測試線程ID是否指向當前線程,如果是,進入步驟(5),否則進入步驟(3)。
  • (3)如果線程ID並未指向當前線程,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中線程ID設置爲當前線程ID,然後執行(5);如果競爭失敗,執行(4)。
  • (4)如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全局安全點(safepoint)時獲得偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後被阻塞在安全點的線程繼續往下執行同步代碼。
  • (5)執行同步代碼。

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

6.3 輕量級鎖實現原理

輕量級鎖獲取過程:

  • (1)在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,官方稱之爲 Displaced Mark Word。這時候線程堆棧與對象頭的狀態如下圖所示。
    在這裏插入圖片描述
  • (2)拷貝對象頭中的Mark Word複製到鎖記錄中。
  • (3)拷貝成功後,虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,並將Lock record裏的owner指針指向object mark word。如果更新成功,則執行步驟(4),否則執行步驟(5)。
  • (4)如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,即表示此對象處於輕量級鎖定狀態,這時候線程堆棧與對象頭的狀態如下圖所示。
    在這裏插入圖片描述
  • (5)如果這個更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行。否則說明多個線程競爭鎖,輕量級鎖就要膨脹爲重量級鎖,鎖標誌的狀態值變爲“10”,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。 而當前線程便嘗試使用自旋來獲取鎖,自旋就是爲了不讓線程阻塞,而採用循環去獲取鎖的過程。

上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也是通過CAS操作來進行的,如果對象的Mark Word仍然指向着線程的鎖記錄,那就用CAS操作把對象當前的Mark Word和線程中複製的Displaced Mark Word替換回來,如果替換成功,整個同步過程就完成了。如果替換失敗,說明有其他線程嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的線程。
輕量級鎖能提升程序同步性能的依據是“對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”,這是一個經驗數據。如果沒有競爭,輕量級鎖使用CAS操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。

6.4 重量級鎖、輕量級鎖和偏向鎖之間轉換

詳細版:
在這裏插入圖片描述

簡化版:
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章