第十二章 多線程編程的硬件基礎與java內存模型--《java多線程編程實戰指南-核心篇》

java虛擬機對內部鎖的優化

自java6/7開始,java虛擬機對內部鎖的實現進行了一些優化。這些優化主要包括鎖消除、鎖粗話、偏向鎖以及適應性鎖。

鎖消除

鎖消除是JIT編譯器對內部鎖的具體實現所做的一種優化,在動態編譯同步塊的時候,JIT編譯器可以藉助一種被稱爲逃逸分析的技術來判斷同步塊所使用的鎖對象是否只能夠被一個線程訪問而沒有被髮布到其他線程。如果同步塊所使用的鎖對象通過這種分析被正是指能夠被同一個線程訪問,那麼JIT編譯器在編譯這個同步塊的時候並不生成synchronized所表示的鎖的申請與釋放對應的機器碼,而僅生成原臨界區代碼所對應的機器碼,這就造成了被動態編譯器的字節碼就像是不包含monitorenter(申請鎖)和monitorexit(釋放鎖)這兩個字節碼指令一樣,即消除了鎖的使用。這種編譯器優化就被稱爲鎖消除,它使得他定情況下我們可以完全消除鎖的開銷。

java標準庫中有一些類(比如StringBuffer)雖然是線程安全的,但是在實際使用中我們往往不在多個線程間共享這些類的實例。而這些類在實現線程安全的時候往往藉助於內部鎖。因此,這些類是鎖消除優化的常見目標。

鎖消除優化告訴我們在該使用鎖的情況下必須使用鎖,而不必過多在意鎖的開銷。開發人員應該在代碼的邏輯層面考慮是否需要加鎖,而至於代碼運行層面上某個鎖是否真的有必要使用則由JIT編譯器來決定。鎖消除優化並不表示開發人員在編寫代碼的時候可以隨意使用內部鎖(在不需要加鎖的情況下加鎖),因爲鎖消除是JIT編譯器而不是javac所做的一種優化。也就是說在JIT編譯器優化介入之前,只要源代碼中使用了內部鎖,那麼這個鎖的開銷就會存在。另外,JIT編譯器鎖執行的內聯優化、逃逸分析以及鎖消除優化本身都是有其開銷的。

鎖粗化

鎖粗化是JIT編譯器對內部鎖的具體實現所做的一種優化,對於相鄰的幾個同步塊,如果這些同步塊使用的是同一個鎖實例,那麼JIT編譯器會將這些同步塊合併爲一個大同步塊,從而避免了一個線程反覆申請、釋放同一個鎖所導致的開銷。然而,鎖粗化可能導致一個線程持續持有一個鎖的時間邊長,從而使得同步在該鎖上的其他線程在申請鎖的等待時間邊長。

相鄰的兩個同步塊之間如果存在其他語句,也不一定就會阻礙JIT編譯器執行鎖粗化優化,這是因爲JIT編譯器可能在執行鎖粗化優化前將這些語句挪到(即指令重排序)後一個同步塊的臨界區之中(當然,JIT編譯器並不會將臨界區內的代碼挪到臨界區之外)。

偏向鎖

偏向鎖是java虛擬機對鎖的實現所做的一種優化。這種優化基於這樣的觀測結果:大多數所並沒有被爭用,並且這些鎖在其整個生命週期內至多隻會被一個線程持有。然而,java虛擬機在實現monitorenter字節碼(申請鎖)和monitorexit字節碼(釋放鎖)時需要藉助一個原子變量(CAS操作),這個操作代價相對來說比較昂貴。一次,java虛擬機會爲每個對象維護一個偏好,即一個對象相應的內部鎖第一次被一個線程獲取,那麼這個線程就會被記錄爲該對象的偏好線程。這個線程後續無論是再次申請該鎖還是釋放該鎖,都無需藉助原先(指未實施偏向鎖優化前)昂貴的原子操作,從而減少了鎖的申請與釋放的開銷。

然而,一個鎖沒有被徵用並不代表僅僅只有一個線程訪問該鎖,當一個對象的偏好線程以外的其他線程申請該對象的內部鎖時,java虛擬機需要收回該對象對原偏好線程的“偏好”並重新設置該對象的偏好線程。這個偏好收回和重新分配過程的代價也是比較昂貴的,因此如果程序運行過程中存在比較多的鎖爭用的情況,那麼這種偏好收回和重新分配的代碼便會被放大。有鑑於此,偏向鎖優化只適合於存在相當大一部分鎖並沒有被徵用的系統之中。如果系統中存在大量被爭用到鎖而沒有被徵用的鎖僅佔極小的部分,那麼我們可以考慮關閉偏向鎖優化。

適應性鎖

適應性鎖是JIT編譯器對內部鎖實現所做的一種優化。

存在鎖爭用的情況下,一個線程申請一個鎖的時候如果這個鎖恰好被其他線程持有,那麼這個線程就需要等待該鎖被持有的線程釋放。實現這種等待的一種保守方法就是將這個線程暫停(線程的生命週期變爲非Runnable狀態)。由於暫停線程會導致上下文切換,因此對於一個具體鎖實例來說,這種實現策略比較適合於系統中絕大多數線程對該鎖的持有時間比較長的場景,這樣才能夠抵消上下文切換的開銷。另外一種實現方法就是採用忙等(實際上就是自旋操作)。所謂忙等相當於如下代碼所示的一個循環體爲空的循環語句:while(lockIsHeldByOtherThread){}

可見,忙等是通過反覆執行空操作直到所需的條件成立爲止而實現等待的。這種策略的好處是不會導致上下文切換,缺點是比較耗費處理器資源--如果所需的條件在相當長時間內未成立,那麼忙等的循環就會被一隻執行。因此,對於一個具體的鎖實例來說,忙等策略比較適合於絕大多數線程對該鎖的持有時間比較短的場景,這樣能夠避免過多的處理器時間開銷。

java虛擬機會根據其運行過程中收集到的信息來判斷這個鎖是屬於被線程持有時間較長還是較短的。對於被線程持有時間較長的鎖,java虛擬機會選用暫停等待策略;而對於被線程持有時間較短的鎖,java虛擬機會選用忙等等待策略。java虛擬機也可能先採用忙等等待策略,在忙等失敗的情況下再採用暫停等待策略。java虛擬機的這種優化就被稱爲適應性鎖,這種優化同樣也需要JIT編譯器介入。

適應性鎖優化可以是以具體的一個鎖實例爲基礎的,也就是說,java虛擬機可能對一個鎖實例採用忙等等待策略,而對另一個鎖實例採用暫停等待策略。從適應性鎖優化可以看出,內部鎖的使用並不一定會導致上下文切換。

優化對鎖的使用

鎖的開銷與鎖爭用監視

鎖的開銷包括以下幾個方面:

  • 上下文切換與線程調度的開銷。一個線程申請一個鎖的時候,如果這個鎖恰好被其他線程持有,那麼該線程最終可能會被暫停。java虛擬機還需要爲這個被暫停的線程維護一個等待隊列,以便在這個鎖被其持有線程釋放的時候將這些線程喚醒。而線程的暫停與喚醒就是一個上下文切換的過程,並且java虛擬機維護等待隊列也會產生一定的開銷。顯然,非爭用鎖並不會導致上下文切換和等待隊列的開銷。
  • 內存同步、編譯器優化受限的開銷。鎖的內部實現所使用的內存屏障也會產生直接和間接地開銷:直接的開銷是內存屏障鎖導致的沖刷寫緩衝器、清空無效化隊列所導致的開銷。另外,內存屏障會阻礙某些編譯器優化。無論是爭用鎖還是非正用鎖,都會產生這部分開銷。當然,非正用的鎖如果最終使用鎖消除優化的話,那麼這個鎖的任何開銷都會被徹底消除。
  • 限制可伸縮性。鎖的排他性的本質是局部的將併發計算改爲串行計算。這種特性會限制系統的可伸縮性。

可見,鎖的開銷主要體現在爭用鎖上面。因此,減少鎖的開銷的一個基本思路就是消除鎖的試用或者降低鎖的爭用程度。

影響鎖的爭用程度的因素有兩個:程序申請鎖的頻率以及鎖通常被持有的時間跨度。程序越是頻繁的申請一個鎖,或者這個鎖通常被其持有線程持有的時間越長,那麼這個鎖的爭用程度就越高;反之則該鎖的爭用程度就越低。

因此,降低鎖的爭用程度的基本思路就是儘可能減少鎖的被持有時間和減低申請鎖的頻率,就具體實現而言,降低鎖的爭用程度可以從減少臨界區長度以及減少鎖的粒度這兩個方面入手。

使用可參數化鎖

如果一個方法或者類內部鎖使用的鎖實例可以由該方法、類的客戶端代碼指定,那麼我們就稱這個鎖是可參數化的,相應的,這個鎖就被稱爲可參數化的鎖。可參數化的鎖在特定情況下有助於減少線程執行過程中參與的鎖實例的個數,從而減少鎖的開銷。

減少臨界區的長度

減少臨界區的長度可以減少鎖被持有的時間從而降低鎖被爭用的概覽,這有利於減少鎖的開銷。另外,減少鎖的持有時間有利於java虛擬機在適用性鎖優化發揮作用:在多數線程持有鎖的時間都很短的情況下,鎖的申請線程可以通過忙等而無需通過暫停線程來等待被爭用的鎖的釋放,這有利於減少上下文切換開銷。

臨界區邏輯上連貫額一些操作往往可以劃分爲幾個部分:預處理操作、共享變量訪問操作以及後處理操作。其中,預處理操作和後處理操作往往是不涉及共享變量的訪問的,因此,把這兩種操作挪到臨界區之外可以在不導致線程安全的前提下減少臨界區的長度。

減少鎖的粒度

降低鎖的爭用程度的另外一種思路是降低鎖的申請頻率。而減小鎖的粒度可以降低鎖的申請頻率,從而減少鎖被爭用的概覽。減小鎖粒度的一種常見方法是將一個粒度較粗的鎖拆分成若干粒度更細的鎖,其中每個鎖僅負責保護原粗粒度鎖保護的所有共享變量中的一部分共享變量,這種技術被稱爲鎖拆分技術。

 

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