鎖消除+逃逸分析


 如果能確認某個加鎖的對象不會逃逸出局部作用域,就可以進行鎖刪除。這意味着這個對象同時只可能被一個線程訪問,因此也就沒有必要防止其它線程對它進行訪問了。這樣的話這個鎖就是可以刪除的。這個便叫做鎖消除,本文是JVM實現機制的系列文章,這也正是今天要講的主題。

眾所周知,java.lang.StringBuffer是一個使用同步方法的線程安全的類,它可以用來很好地詮釋鎖消除。StringBuffer是Java1.0的時候開始引入的,可以用來高效地拼接不可變的字符串對象。它對所有append方法都進行了同步操作,以確保當多個線程同時寫入同一個StringBuffer對象的時候也能夠保證構造中的字符串可以安全地創建出來。

很多程序其實並不需要這層線程安全保障,因此在Java 5中又引入了一個非同步的java.lang.StringBuilder類來作為它的備選。這兩個類都繼承了包私有(注:簡單來說就是沒有修飾符的類)的java.lang.AbstractStringBuilder類,它們的append方法的實現也非常類似。

不同之處就在於StringBuffer的同步操作:

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

和StringBuilder作一下對比:

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

調用StringBuffer的append方法的線程,必須得獲取到這個對象的內部鎖(也叫監視器鎖)才能進入到方法內部,在退出方法前也必須要釋放掉這個鎖。而StringBuilder就不需要進行這個操作,因此它的執行性能比StringBuffer的要高——至少乍看上去是這樣的。

不過在HotSpot虛擬機引入了逃逸分析之后,在調用像StringBuffer這樣的對象的同步方法時,就能夠自動地把鎖消除掉了。這只會出現在方法域內部所創建的對象上,只有這樣才能保證不會發生逃逸。

Java的性能測試一般都會用到Java Microbenchmark Harness(JMH)。我們就用JMH來測試一下,當現代的JVM能夠確認StringBuffer對象只能被一個線程訪問時,它是如何通過消除StringBuffer上的鎖來縮小性能上的差距的。

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

鎖消除是一項非常有效的優化,在Java 8中它是默認開啟的,不過你也可以通過-XX:-DoEscapeAnalysis這個VM參數來關掉它,這樣可以看下優化的效果。開啟(默認)了逃逸分析后,StringBuffer和StringBuilder的性能基本上是一樣的。(結果報告統計的是每秒執行的操作數。分數越高說明性能越好。)

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

如上所示,關掉了逃逸分析后,StringBuffer的代碼要慢15%左右——而這個差別主要就是由於調用append()方法時的加鎖操作導致的。

鎖粗化(Lock Coarsening)

HotSpot虛擬機還有一些額外的鎖優化的技術,雖然從技術上講它們並不屬於逃逸分析子系統中的一部分,但也是通過分析作用域來提高內部鎖的性能。當連續獲取同一個對象的鎖時,HotSpot虛擬機會去檢查多個鎖區域是否能合並成一個更大的鎖區域。這種聚合被稱作鎖粗化,它能夠減少加鎖和解鎖的消耗。

當HotSpot JVM發現需要加鎖時,它會嘗試往前查找同一個對象的解鎖操作。如果能匹配上,它會考慮是否要將兩個鎖區域作合並,並刪除一組解鎖/加鎖操作。

我們來看一個程序,它會連續獲取同一個對象的監視器鎖:

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

它的字節碼如下,看起來非常的冗長:

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

[代碼最后的注釋對應着后面的輸出結果行。——Ed.]

先來回顧一下,操作內部鎖對應的字節碼是monitorenter和monitorexit。

字節碼中的每一條monitorenter指令都會對應着兩條monitorexit指令,它們分別對應着不同的執行路徑。原因是第一條monitorexit指令會在正常退出鎖區域時釋放監視器鎖,而第二條指令則是在異常退出時進行釋放。

這段字節碼看起來可能很奇怪,因為在源程序中同步塊中只有一個int變量的自增操作而已。代碼中並沒有拋異常,不過它的確有可能會異常退出鎖區域。(如果線程捕獲到InterruptedException異常就可能會這樣,比如調用了執行線程的stop()方法。因此,需要有第二條執行路徑來確保監視器鎖一定能被釋放掉,即使是拋了非受檢異常(unchecked exception)也是如此。從JVM規范中可以了解到更多相關知識。)鎖粗化是默認開啟的,不過也可以通過啟動參數-XX:-EliminateLocks來關掉它。

嵌套鎖

同步塊可能會一個嵌套一個,進而兩個塊使用同一個對象的監視器鎖來進行同步也是很有可能的。這種情況我們稱之為嵌套鎖,HotSpot虛擬機是可以識別出來並刪除掉內部塊中的鎖的。當一個線程進入外部塊時就已經獲取到鎖了,因此當它嘗試進入內部塊時,肯定也仍持有這個鎖,所以這個時候刪除鎖是可行的。

在寫作本文的時候,Java 8中的嵌套鎖刪除只有在鎖被聲明為static final或者鎖的是this對象時才可能發生。

下面是一個碰到嵌套同步塊時刪除內部鎖的例子:

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

HotSpot虛擬機會刪除掉內部的嵌套鎖,因此這段代碼最終會變成這樣:

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

嵌套鎖優化是默認開啟的,不過也可以通過啟動參數-XX:-EliminateNestedLocks來關掉它。

數組及逃逸分析

非堆上分配的空間要么存儲在棧上,要么就在CPU寄存器中,這些都是相對稀缺的資源,因此逃逸分析和其它優化一樣,(在實現上)肯定會面臨妥協。HotSpot JVM上的一個默認限制是大於64個元素的數組不會進行逃逸分析優化。這個大小可以通過啟動參數-XX:EliminateAllocationArraySizeLimit=n來進行控制,n是數組的大小。

假設有段熱點代碼,它會去分配一個臨時數組用於從緩存中讀取數據。如果逃逸分析發現這個數組的作用域沒有逃逸出方法體外,便不會在堆上分配內存。不過如果數組大小超過64的話(哪怕並不是全都用到)便仍會存儲到堆里。這樣數組的逃逸分析優化便不會起作用,也仍會從堆內分配內存。

在下面的JMH基准測試中,test方法會分別新建大小為63、64、65的非逃逸數組。(大小為63的數組之所以也參與測試,是為了證明64的數組比65的快並不是因為內存對齊的緣故。)

每輪測試都只使用到了數組的前兩個元素,也就是a[0]和a[1]。需要注意的是,逃逸分析只受限於數組長度的大小,和實際使用到多少個元素是沒有關系的。

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

從結果來看,一旦數組分配不能受益於逃逸分析的優化時,性能便會出現大幅下降。(這里的分數也是對應的每秒的操作數,分越高性能越好。)

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

如果你需要在熱點代碼中分配更大的數組,可以通過配置讓虛擬機對大數組進行優化。把元素上限調整成65,然后再跑一下基准測試便會發現性能也對齊了。

命令行:

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

的執行結果是:

 

查漏補缺,JVM優化篇,鎖消除+逃逸分析

 

 

可以看到結果是一樣的。

結論

本文及上一篇關於逃逸分析的文章給大家展示了HotSpot JVM所蘊藏的一些魔力。同時大家也能看到這些優化背后的復雜度。Java的每一次大的版本發布都會往JVM中增加一些新的特性。

事實上,Oracle也在研究下一代的編譯技術。它便是Graal,這是一款可插拔、可擴展的、Java實現的just-in-time (JIT)編譯器。它是Metropolis項目的重要組成部分,這個項目的目標是盡可能多地使用Java語言來構建JVM的運行時程序。

正如JEP 317中所提到的,Graal編譯器是Java 10的一項實驗性的新功能。它的主要目標是讓開發人員和專業平台的負責人能夠自己去編寫專屬的、滿足自身特殊需求的JIT編譯器。對於新的優化技術的設計和原型完成來說,Graal是一個非常合適的平台。

本文及上一篇文章所提到的作用域分析的方法可以用來實現很多優化技術。首先便是分配消除(allocation elimination,也就是標量替換,注:指的是把對象分解成int等基礎類型,在棧和寄存器中分配空間,這樣就可以不在堆上分配內存,也不需要GC進行回收了),還有我們討論到的這些鎖相關的技術。這些只是HotSpot JVM中成熟的C2編譯器的所提供的JIT編譯技術中的一些例子。后續的文章還會陸續介紹HotSpot JVM中用來提升代碼性能的一些其它的技術。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM