哪些因素影響Java呼叫的效能?
當時發生了什麼?
這得從一個小故事說起。我在一個Java核心庫的郵件列表中提交了一個修改 ——重寫了一些本是 final 的
方法。一石激起千層浪,這一改動引發了幾番討論。而其中一個討論的話題是:呼叫一個去除 final
標記的方法,將導致哪種程度的效能下降(performance regression)。
我不能確定這一改變是否會導致效能下降,但當我決定將此暫時擱置一邊,試著尋找在這個討論裡是否有人公佈過任何相關的完整基準測試(sane benchmarks)時,結果空手而歸。我不能肯定地說有關的基準測試是不存在的,或者說其他人沒做過這方面的探討。但我能肯定的是,在這裡,連任何公開的程式碼評審都沒有。唉,看來是時候寫一個基準測試了。
基準測試的方法論
我決定選用一個相當不錯的框架 —— JMH 來構建基準測試。如果你質疑它測試的準確性,那麼建議你看下對這個框架作者(Aleksey Shipilev)的訪談,或者閱讀一下由Nitsan Wakart撰寫的一篇彰顯此框架風采的博文。
現在,我想知道哪些因素影響了Java方法呼叫的效能。所以我決定以不同方式呼叫方法,並測算它們的效能開銷。以單一變數為前提來構造一套基準測試,我便能逐個排除或確定,哪些因素或哪種組合會影響到方法呼叫的效能。
內聯
讓我們把這些方法呼叫點壓扁
方法呼叫的有無,是一個影響程度既是最高又是最低的因素——對於編譯器來說,徹底優化方法呼叫所帶來的開銷並非不可能,有兩種方法可以實現這樣的需求:直接內聯該方法本身和使用內聯快取(inline cache)。千萬別被引入的這些術語給嚇倒——它們都是通俗易懂的。現在我們假設有一個叫Foo
的類,該類定義了一個叫bar
的方法:
class Foo { void bar() { ... } }
我們以如下的方式呼叫bar
方法:
Foo foo = new Foo(); foo.bar();
這裡有一個重要的知識點:實際呼叫 bar
的位置,即 foo.bar()
,稱為呼叫點(callsite)。當我們說一個方法“被內聯”,意指方法體被插入到了呼叫點的位置上,以代替方法呼叫。對於那些由許多短小的方法所構成的程式——我稱之為被適當分解的程式——內聯可以有效地提升效能。這是因為結束以後可以發現,程式並沒有把所有時間用在方法呼叫上,實際上程式並沒有工作!我們在JMH中可以藉由 CompilerControl
註釋控制一個方法是否被內聯。關於內聯快取的概念,我稍後再來說明。
層次結構深度與重寫子類方法
是因為父母讓孩子慢下來了嗎?
如果我們移除一個方法的 final
關鍵字,便意味著我們能夠重寫它。所以這是另一個在進行測試我們需要考慮的情況。我會選擇在同一層次結構中不同層次的子類裡呼叫一些方法,並且在這些方法裡有一些是會被不同層次的子類重寫的。這樣的測試能讓我們確定或排除深的層次結構是否影響到重寫所帶來的效能開銷。
多型性
動物世界:多型是如何表現的
先前我提到呼叫點這一概念時,我偷偷地迴避了一個相當重要的問題——因為在子類中可以重寫一個非 final
方法,這使得呼叫點可以呼叫不同的方法。現假設我傳入一個 Foo 的例項或一個重寫了 bar
子類—— Baz的例項,編譯器如何得知要呼叫哪一個 bar
方法呢?在預設情況下,方法將在Java中被虛擬化(可重寫)。對於任一呼叫點,編譯器需要在一個稱為虛擬表(vtable)的表中尋找與其對應的方法。這是個非常耗時的過程,所以,能進行優化的編譯器,總是會試圖減少這種查詢帶來的開銷。一種方法就是先前提到的內聯,這的確是個良策,但前提是編譯器能證明在給定的呼叫點上呼叫的方法唯一。而這樣的呼叫點我們稱為單態(monomorphic)呼叫點。
不幸的是,進行這種分析需要耗費大量時間。所以在實際過程中,確定一個呼叫點是否單態是個不太可取的方法。對此,JIT編譯器傾向於使用一種替代方法:列出哪些類可以在此呼叫點被呼叫,接著根據之前的N個相同的呼叫猜測此呼叫點是否是單態的。以假定某個呼叫點永遠為單態,來進行投機性質的優化往往是可取的行為。因為這樣的優化往往都是正確的,但也因它無法確保永遠正確,編譯器需要在方法呼叫之前注入一個用於檢查方法型別的防護機制。
除了單態的呼叫點以外,還有兩種呼叫點我們希望對其進行優化。一種稱為雙態(bimorphic)呼叫點,在該點上有兩個候選方法。對此你依然可以實現內聯——藉助防護程式碼,讓其檢測應呼叫哪一個方法,並載入程式跳轉至內聯在呼叫點的兩個方法體中真正對應的那一個。這樣的方式還是比檢視所有虛擬表的方式要快得多。但在某些情況下,我們得利用內聯快取來進行優化。內聯快取需要藉助一張特定的跳轉表( jump table),這種表類似於對虛擬表查詢做的一份快取。hotsopt JIT編譯器支援雙態內聯快取,並定義那些擁有三個及三個以上候選方法的呼叫點為超多狀態(megamorphic)呼叫點。
這就使得我在基準測試與探究當中,需要額外地把呼叫情況劃分為三類:單態、雙態、超多狀態。
結果
讓我們把結果分類組織,以便研究細節。我已經提供了統計產生的原始資料。但我們的興趣點不應放在效能測試結果的具體數值上,而應是不同型別的方法呼叫的效能開銷之間的比率以及各自的錯誤率是否夠低。如果最快與最慢的結果之間比率為6.26,則說明這是一個顯著性差異。由於測試時使用的是空方法(詳見原始碼),所以在實際應用中,這樣的差異會更大。
你可以在 github上檢視此次基準測試的原始碼。為了避免產生困惑,待會所有的結果將分塊顯示。最後顯示的多型的基準測試是在 PolymorphicBenchmark
類中進行,其它的則在 JavaFinalBenchmark
類中。
簡單呼叫點
最先看到的的一組結果,是比較呼叫一個 virtual 方法、一個 final
方法和一個擁有很深的層級結構,同時被所有子類重寫的方法所帶來的開銷。注意,呼叫這些方法的時候我們都強制編譯器不要內聯它們。我們可以看到:三者在時間花費上相差甚微,並且各自的誤差率都小到可以忽略。對此我們可以斷定,僅新增一個 final
關鍵字並不會大幅度提升呼叫效能,重寫一個方法也不見得會帶來什麼影響。
內聯簡單呼叫
現在,我們在開啟內聯的情況下再來一次相同的測試。由結果可見,final
方法和 virtual 方法的時間花費依舊相近,並比在沒有內聯的情況下快了4倍,我將此歸功於內聯優化。相比而言,被所有子類重寫的方法的結果可就沒那麼好看了。我推測這是由於此方法有多個子類實現,使得編譯器必須插入一個型別保護。有關的細節我們將在研究多型性的結果時進行闡述。
類層次結構的影響
哇噢——這兒有好幾個的方法!方法名稱的編號(1~4)代表該方法呼叫的層次。因此,parentMethod4 表示我們呼叫的方法位於class的上面第四級。(譯註:在原始碼中該方法位於頂層的父類)。由此結果我們能斷定,結構層次的深度對效能開銷沒有影響。在開啟內聯的例項中,結論也是一樣。這個測試中,被內聯的方法的效能與 inlinableAlwaysOverriddenMethod
相當,但稍遜於 inlinableVirtualInvoke
。我依舊認為這與使用了型別保護有關。事實上JIT編譯器能剖析所有候選方法,從而只內聯對應的那一個,但這並不證明它總會這麼幹。
類的層級結構對final
方法的影響
該測試的結論與第一個測試一樣 —— final
關鍵字不會產生任何影響。我本以為該測試將證明 inlinableParentFinalMethod4
以無型別保護的方式進行內聯,但結果表明事實並非如此。
多型性
最後,我們來看涉及多型分派(polymorphic dispatch)的測試結果。單態呼叫的效能開銷與之前virtual方法相近。但對於雙態與超多狀態呼叫,由於需要在一張較大的虛擬表上面進行查詢,所以需要更多的時間。而一旦我們開啟內聯支援,型別分析(type profiling )將會在單態或雙態的呼叫點啟用,使得在這些呼叫點上的方法呼叫的開銷減少。但與層級結構的例項一樣,這隻會減少少量的時間。相比而言,超多狀態的例項則依舊耗時較長。記住,我並沒有說在這個測試中hotspot禁用了內聯,它只是沒有實現多型呼叫點的多型內聯快取。
我們從中學到了什麼?
我認為,需要我們引起注意的是,很多人沒有認識到不同方式的方法呼叫所花費的時間是不一樣的。即便有些人發現了這種問題,但他們不去證明是否真的如此。作為第一個吃螃蟹的人,我列出了各種壞的假設,因此我希望這份研究能夠幫助到大家。以下是我很樂於與大家分享的一些結論:
- 最快與最短的方法呼叫的型別之間存在巨大的效能差別。
- 在實際應用中,新增或刪除
final
關鍵字並不會真正影響效能。但如果除此以外,你還在層級結構上進行某些操作,那這些行為則可能導致效能下降。 - 更深的類的層次結構並不會真正影響到呼叫的效能。
- 單態呼叫比雙態呼叫更快。
- 雙態呼叫比超多狀態呼叫更快。
- 我們在能夠進行剖析(profile-ably),但是不能進行查驗的單態呼叫點中看到型別保護,這種保護會使得這些呼叫點的呼叫效能低於那些能夠進行查驗的單態呼叫點。
我想說的是,對我而言,型別保護帶來的效能開銷是一個“重大發現”。這是一個我之前很少提及,並且總是當做無關事物忽視掉的因素。
注意事項與進一步工作
本文不能囊括這個話題的全部內容。因為:
- 這篇博文所關注的影響到方法呼叫的效能的因素,只與型別有關。所以,有一個因素我並未提及:方法的長短或者說呼叫棧的深度——如果方法太長,那麼它將不會被內聯,為此你必須承受方法呼叫所帶來的開銷。另外,為了使程式碼具有易讀性,你也應當把方法寫得短小一些。
- 在本次測試的所有我並沒有嘗試引入介面。如果你對此有興趣的話,這裡有一篇有關介面呼叫的效能的研究Mechanical Sympathy。
- 還有一個因素被我完全忽視了,那就是方法內聯的優化方式在不同編譯器上的效果差異。當編譯器是僅關注某個方法(內部過程優化)時,它們需要足夠地資訊才能有效優化。內聯的限制可以有效地減少其它優化所需要關注的範圍。
- 試著站在組合語言的層面進行解釋的話,會涉及更多的細節內容。
或許以上內容已經超出了本文的範疇,需要另寫博文進行討論。
相關文章
- JAVA 異常對於效能的影響Java
- 伺服器穩定性受到哪些因素影響伺服器
- Java UUID生成的效能影響 – fastthreadJavaUIASTthread
- Java教程:影響MySQL效能的配置引數JavaMySql
- Java程式設計師想要高薪 哪些因素會影響工資高低Java程式設計師高薪
- 有哪些因素會影響美國伺服器速度伺服器
- 哪些因素會影響伺服器機櫃的正常工作伺服器
- 網站的建設質量受哪些因素所影響網站
- 伺服器的連線速度有哪些因素影響呢伺服器
- 影響Java EE效能的十大問題Java
- 香港伺服器ping值受哪些因素影響伺服器
- Java中的Exception拋異常對效能的影響 - BaeldungJavaException
- [zt] 影響SQL效能的原因SQL
- 伺服器SSL證書價格受到哪些因素影響?伺服器
- 華納雲:伺服器租用費用受哪些因素影響?伺服器
- Python為什麼執行效率低?受哪些因素影響?Python
- PHP程式應該減少brk呼叫,否則效能會受影響PHP
- 影響mysql效能的因素都有哪些MySql
- 影響HTTP效能的常見因素HTTP
- 影響MySQL效能的硬體因素MySql
- 影響MySQL效能的硬體因MySql
- 初學者如何學習Linux運維?影響運維的有哪些因素?Linux運維
- 【知識分享】應用伺服器的價格會受到哪些因素的影響伺服器
- session效能的影響,後臺 flush dirtySession
- SQL Server效能影響的重要結論SQLServer
- 批操作效能影響診斷
- 恆訊科技淺談:影響歐洲伺服器穩定性有哪些因素?伺服器
- DB2 HADR對效能的影響DB2
- InnoDB 隔離模式對 MySQL 效能的影響模式MySql
- 開發一套直播系統原始碼的價格主要受哪些因素影響?原始碼
- ASP中函式呼叫對引數的影響 (轉)函式
- MySQL影響伺服器效能的幾個方面MySql伺服器
- 分支對程式碼效能的影響和優化優化
- 影響儲存網路效能的因素有哪些?
- JavaScript 事件對記憶體和效能的影響JavaScript事件記憶體
- mysql刪除和更新操作對效能的影響MySql
- 影響你網站效能的 5 個瓶頸網站
- 磁碟排序對Oracle資料庫效能的影響排序Oracle資料庫