Java中影響方法調用性能的因素

jopen 10年前發布 | 26K 次閱讀 Java Java開發

我們先從一個小故事開始講起。幾個星期前,我在Java核心庫的郵件列表中發起一個修改的提議,希望能重寫一些目前是final類型的方法。這個提案引發了好幾個討論的話題——其中一個是方法是不是final類型的,它的性能差距到底有多大。

關于取消final是否會導到性能變差我其實有一些自己想法,但我決定先拋開這些主觀看法,想找找看有沒有這個課題相關的一些基準測試的數據。很不 幸的是我沒找到。并不是說真的不存在或者沒有人研究過這種情況,只能說我沒有看到有公開的同行審查過的代碼。看來,得自己寫點測試了。

基準測試方法論

我決定使用JMH這個靠譜的框架來將這些基準測試進行打包。如果你不相信會有框架能幫助你得到精確的基準測試的數據,你應該看下Aleksey Shipilev的這個演講,他是這個框架的作者,或者看下 Nitsan Wakart的這篇很贊的博文,里面詳細解釋了具體的原因。

對我而言,我想知道的是什么因素會影響到方法調用的性能。我決定使用不同的方式來進行方法調用,并測量下它們各自的開銷。我這有一組基準測試,每次只調整其中的一個因素,這樣我們可以搞清楚不同的因素或者不同因素的組合到底會對方法調用的開銷產生怎樣的影響。

內聯

Java中影響方法調用性能的因素

最明顯但同時又最不明顯的因素當然就是到底有沒有產生方法調用。編譯器是有可能將整個方法調用的開銷全都都優化掉的。一般來說,有兩種減少調用開銷 的方法。一個是直接將方法本身內聯,另外一種是使用內聯緩存。別擔心——這只是些很簡單的概念而已,只不過里面用到的一些術語可能需要介紹下。我們先假設 有一個叫Foo的類,它里面定義了一個bar方法。

class Foo {
  void bar() { ... }
}
 

調用這個bar方法的話可以這么寫:

Foo foo = new Foo();
foo.bar();
 

這里重要的在于bar方法實際產生調用的位置——foo.bar(),這個被稱作調用點(callsite)。當我們說一個方法被“內聯”了,它的 意思是方法體被拿出來塞到了這個調用點這里,整個替換掉了這次方法調用。對于那些包含很多小的方法的程序來說(我敢說,這是個正確分解任務的程序),方法 內聯能顯著地提升程序的運行速度。這是因為程序不會花太多的時候進行方法調用而不是在干實際的工作!我們可以通過@CompilerControl注解來 控制是否要內聯一個方法。后面我們會講到什么是內聯緩存。

類層次的深度及方法的重寫

Java中影響方法調用性能的因素

如果我們選擇移除方法的final關鍵字,這意味我們可以對它進行重寫了。這是另一個我們需要考慮的因素。因些我在一個類的不同的層級上調用它的方法,同時在不同的層級上對它們進行重寫,這樣我才能弄清楚類的深度和重寫到底會產生多大的開銷。

多態

Java中影響方法調用性能的因素

在前面我提到調用點的時候,我故意漏掉了一個很重要的細節。由于可以在子類中重寫非final方法,我們的調用點可能最終會調用到不同的方法。那么 可能我傳的是Foo對象或者是它的子類——Baz——它也實現了bar()方法。那編譯器怎么知道該調用哪個方法呢?Java中的方法默認都是虛方法(可 重寫的),也就是說每次方法調用都得在一張表中查找合適的方法,這個表稱為虛方法表。這個過程是相當慢的,因此好的編譯器都會嘗試去減少這個查找的開銷。 我們之前提到的一個方法是內聯,如果你的編譯器能夠確定在一個指定的調用點只會調用到某個方法的話那就太棒了。這樣的調用點被稱作單態調用點。

不幸的是要證明一個調用點是單態的所花費的時間大多都是不切實際的。JIT編譯器傾向于采用另一種方法,它會統計各個調用點實際調用的類型,如果前 N個調用都是單態的,那它就會猜測這個調用點可能一直都是單態的。這個投機式的優化通常來說都是正確的,但由于它并不全是對的,因此編譯器需要方法調用前 插入一個守衛,以便檢查這個方法的類型。

我們想要優化的可不止單態調用點一個而已。有很多調用點專業點的話叫做雙態——它可能會調用到兩個方法。你可以使用你的守衛代碼來判斷應該調用哪個 方法,然后跳轉過去。這比完整的方法調用可要廉價多了。這種情況也可以使用內聯緩存來進行優化。內聯緩存并不是實際將方法體內聯到調用點,而是使用了一個 專門的跳轉表,它就像是一個完整的虛方法表查詢的緩存。HotSpot的JIT編譯器支持雙態內聯緩存,它將那些有三個以上可能的實現的調用點稱為”兆態 “(megamorphic)。

現在我們區分出了三種需要進行基準測試和分析的調用方式:單態,雙態,及兆態。

測試結果

我們將測試結果進行分組收集,這樣能更容易看清問題的本質。我把原始數據列了出來,同時還附帶了一點分析。具體的數字或者開銷意義其實并不是特別 大。但有趣的是不同的方法調用間的比率以及相應的標準誤差都非常的低。不同調用的區別非常明顯——最快和最慢的實現差了6.26倍。現實中這種差距可能更 大,因為這里我們測量的是一個空方法的開銷。

這些基準測試的源代碼在GitHub上有。我沒有把結果都放到起以免產生混淆。多態的基準測試是運行PolymorphicBenchmark的結果,而其它的是運行JavaFinalBenchmark的結果。

簡單調用點

基準測試 模式 采樣數 均值 標準誤差 單位
c.i.j.JavaFinalBenchmark.finalInvoke avgt 25 2.606 0.007 ns/op
c.i.j.JavaFinalBenchmark.virtualInvoke avgt 25 2.598 0.008 ns/op
c.i.j.JavaFinalBenchmark.alwaysOverriddenMethod avgt 25 2.609 0.006 ns/op

我們的第一組數據比較的是虛方法,final方法,以及一個在很深的類層次中進行重寫的方法間的調用開銷。注意,我們這里強制讓編譯器不進行內聯。 我們可以看到,它們之間的差別非常小,標準誤差也很低。因此我們可以得出這樣的結論,簡單的加一個final關鍵字其實不會對方法調用的性能有太大的提 升。同樣的,重寫方法也不會產生太大的區別。

簡單調用點內聯

基準測試 模式 采樣數 均值 標準誤差 單位
c.i.j.JavaFinalBenchmark.inlinableFinalInvoke avgt 25 0.782 0.003 ns/op
c.i.j.JavaFinalBenchmark.inlinableVirtualInvoke avgt 25 0.780 0.002 ns/op
c.i.j.JavaFinalBenchmark.inlinableAlwaysOverriddenMethod avgt 25 1.393 0.060 ns/op

現在我們還是使用這三個用例進行測試,但去掉了內聯的限制。這次final和虛方法調用的結果仍然很接近。它們比非內聯版本快了大概4倍,我認為這 當然是進行了內聯的原因。重寫的這個方法介于兩者之間。我懷疑這是由于這個方法可能存在多個子類的實現,導致編譯器插入了一個類型守衛(type guard)。這個機制在上面的多態一節中已經有很詳細的描述。

類層級的影響

基準測試 模式 采樣數 均值 標準誤差 單位
c.i.j.JavaFinalBenchmark.parentMethod1 avgt 25 2.600 0.008 ns/op
c.i.j.JavaFinalBenchmark.parentMethod2 avgt 25 2.596 0.007 ns/op
c.i.j.JavaFinalBenchmark.parentMethod3 avgt 25 2.598 0.006 ns/op
c.i.j.JavaFinalBenchmark.parentMethod4 avgt 25 2.601 0.006 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentMethod1 avgt 25 1.373 0.006 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentMethod2 avgt 25 1.368 0.004 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentMethod3 avgt 25 1.371 0.004 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentMethod4 avgt 25 1.371 0.005 ns/op

哇,這里的方法可真多。每個編號的方法(1-4)都對應著方法調用所在類的層級的深度。因此parentMethod4方法意味著我們調用的這個方 法聲明在類的第4級的父類中。如果你看一下結果數據你會發現1到4之間其實沒太大區別。因此我們可以認為,類層級的深度并沒有什么影響。內聯的方法性能和 前面的inlinableAlwaysOverriddenMethod結果差不多,但比inlinableVirtualInvoke要差些。我認為這 是使用了類型守衛的原因。JIT編譯器可以統計方法找出需要內聯的那個,但它不能確保一直都會是調用的這個方法。

類層級對final方法的影響

基準測試 模式 采樣數 均值 標準誤差 單位
c.i.j.JavaFinalBenchmark.parentFinalMethod1 avgt 25 2.598 0.007 ns/op
c.i.j.JavaFinalBenchmark.parentFinalMethod2 avgt 25 2.596 0.007 ns/op
c.i.j.JavaFinalBenchmark.parentFinalMethod3 avgt 25 2.640 0.135 ns/op
c.i.j.JavaFinalBenchmark.parentFinalMethod4 avgt 25 2.601 0.009 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod1 avgt 25 1.373 0.004 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod2 avgt 25 1.375 0.016 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod3 avgt 25 1.369 0.005 ns/op
c.i.j.JavaFinalBenchmark.inlinableParentFinalMethod4 avgt 25 1.371 0.003 ns/op

這和上面的結果差不多——final關鍵字看起來并沒有太大的影響。我原本認為這里可能inlinableParentFinalMethod4會被內聯成不使用類型守衛的版本,不過從結果來看并不是這樣。

多態

Monomorphic: 2.816 +- 0.056 ns/op Bimorphic: 3.258 +- 0.195 ns/op Megamorphic: 4.896 +- 0.017 ns/op Inlinable Monomorphic: 1.555 +- 0.007 ns/op Inlinable Bimorphic: 1.555 +- 0.004 ns/op Inlinable Megamorphic: 4.278 +- 0.013 ns/op

最后終于到了多態分發的了。單態調用的開銷和通常的虛方法調用是一樣的。隨著我們需要查找的虛方法表越來越大,它們也變得越來越慢了,就像雙態和兆 態那兩個例子中那樣。一旦我們開啟了內聯,類型分析開始介入了,單態和雙態的調用點的開銷降低成了”內聯守衛(inlined with guard)“的開銷。跟類層級用例中的很類似,只是稍微慢了些。兆態的這個仍然十分緩慢。記住,我們并沒有告訴hotspot說不要去進行內聯,只是它 沒有為比雙態更復雜的調用點來實現多態的內聯緩存而已。

我們從中學到了什么?

我想值得注意的是,很多人對于不同類型的方法調用需要不同的時間并沒有一個清晰的性能模型,同時雖然很多人也知道它們所花費的時間不同,但卻沒能正確地理解它。我曾經也是這樣,也曾做過話多錯誤的假設。因此我希望這次的分析能對你們有所幫助。下面是我的一些總結。

  • 方法調用的最快實現和最慢實現的差別還是很大的。
  • 在實踐中增加或者減少final關鍵字其實對性能的影響并不大。
  • 類層次結構的深度對方法調用的性能并沒有什么實際的影響。
  • 單態調用比雙態調用要快。
  • 雙態調用比兆態調用要快。
  • 在我們前面看到的基于統計分析可能是單態調用但是并不確定的用例中(注:會進行內聯優化,但是在調用前會插入一個類型守衛),里面所使用到的類型守衛的確是會影響到性能。

類型守衛的開銷對我個人而言有很大的啟發。我很少看到有人提及它,通常大家都認為它無關而忽略掉了。

說明

當然這并不是這個話題的一個結論性的論述。

  • 本文關注的只是和方法調用性能相關的類型相關的因素。有一個因素我沒有提及到的是方法體的大小和調用棧的深度對內聯的影響。如果你的方法太大的話,它是不會被內聯的,你仍然會為方法調用的開銷買單。這也是為什么方法要寫得小而易看懂的一個原因。
  • 我沒有分析通過接口調用方法對這幾種情況的影響。如果你對這個感興趣的話,在Mechanical Sympathy的博客上有關于接口調用性能的一個分析。
  • 還有一個完全忽略了的因素是方法內聯對其它編譯器優化的影響。當編譯只對某個方法進行優化的時候,它當然希望能收集盡可能多的信息,這樣它才能更有效地進行優化。內聯優化的限制可能會對其它優化產生很大的影響。
  • 從匯編的層面來進行分析才能對這個問題有更深入的了解。

也許在以后的博客中會討論一下這些話題。

來自:Java中影響方法調用性能的因素

英文原文鏈接

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!