你的Java代碼對JIT編譯友好麼?

2021-02-19 InfoQ

JIT編譯器是Java虛擬機(以下簡稱JVM)中效率最高並且最重要的組成部分之一。但是很多的程序並沒有充分利用JIT的高性能優化能力,很多開發者甚至也並不清楚他們的程序有效利用JIT的程度。

在本文中,我們將介紹一些簡單的方法來驗證你的程序是否對JIT友好。這裡我們並不打算覆蓋諸如JIT編譯器工作原理這些細節。只是提供一些簡單基礎的檢測和方法來幫助你的代碼對JIT友好,進而得到優化。

JIT編譯的關鍵一點就是JVM會自動地監控正在被解釋器執行的方法。一旦某個方法被視為頻繁調用,這個方法就會被標記,進而編譯成本地機器指令。這些頻繁執行的方法的編譯由後臺的一個JVM線程來完成。在編譯完成之前,JVM會執行這個方法的解釋執行版本。一旦該方法編譯完成,JVM會使用將方法調度表中該方法的解釋的版本替換成編譯後的版本。

Hotspot虛擬機有很多JIT編譯優化的技術,但是其中最重要的一個優化技術就是內聯。在內聯的過程中,JIT編譯器有效地將一個方法的方法體提取到其調用者中,從而減少虛方法調用。舉個例子,看如下的代碼:

public int add(int x, int y)
{ return x + y;}
int result = add(a, b);

當內聯發生之後,上述代碼會變成:

int result = a + b;

上面的變量a和b替換了方法的參數,並且add方法的方法體已經複製到了調用者的區域。使用內聯可以為程序帶來很多好處,比如

不會引起額外的性能損失

減少指針的間接引用

不需要對內聯方法進行虛方法查找

另外,通過將方法的實現複製到調用者中,JIT編譯器處理的代碼增多,使得後續的優化和更多的內聯成為可能。

內聯取決於方法的大小。預設情況下,含有35個字節碼或更少的方法可以進行內聯操作。對於被頻繁調用的方法,臨界值可以達到325個字節。我們可以通過設置-XX:MaxInlineSize=# 選項來修改最大的臨界值,通過設置‑XX:FreqInlineSize=#選項來修改頻繁調用的方法的臨界值。但是在沒有正確的分析的情況下,我們不應該修改這些配置。因為盲目地修改可能會對程序的性能帶來不可預料的影響。

由於內聯會對代碼的性能有大幅提升,因此讓儘可能多的方法達到內聯條件尤為重要。這裡我們介紹一款叫做Jarscan的工具來幫助我們檢測程序中有多少方法是對內聯友好的。

Jarscan工具是分析JIT編譯的JITWatch開源工具套件中的一部分。和在運行時分析JIT日誌的主工具不同,Jarscan是一款靜態分析jar文件的工具。該工具的輸出結果格式為CSV,結果中包含了超過頻繁調用方法臨界值的方法等信息。JITWatch和Jarscan是AdoptOpenJDK工程的一部分,該工程由Chris Newland領導。

在使用Jarscan並得到分析結果之前,需要從AdoptOpenJDK Jenkins網站下載二進位工具(Java 7 工具,Java 8 工具)。

運行很簡單,如下所示:

./jarScan.sh <jars to analyse>

更多關於Jarscan的細節可以訪問AdoptOpenJDK wiki進行了解。

上面產生的報告對於開發團隊的開發工作很有幫助,根據報告結果,他們可以查找程序中是否包含了過大而不能JIT編譯的關鍵路徑方法。上面的操作依賴於手動執行。但是為了以後的自動化,可以開啟Java的-XX:+PrintCompilation 選項。開啟這個選項會生成如下的日誌信息:

37 1
java.lang.String::hashCode (67 bytes)124
2 s! java.lang.ClassLoader::loadClass
(58 bytes)

其中,第一列表示從進程啟動到JIT編譯發生經過的時間,單位為毫秒。第二列表示的是編譯id,表明該方法正在被編譯(在Hotspot中一個方法可以多次去優化和再優化)。第三列表示的是附加的一些標誌信息,比如s代表synchronized,!代表有異常處理。最後兩列分別代表正在編譯的方法名稱和該方法的字節大小。

關於PrintCompilation輸出的更多細節,Stephen Colebourne寫過一篇博客文章詳細介紹日誌結果中各列的具體含義,感興趣的可以訪問「閱讀原文」閱讀。

PrintCompilation的輸出結果會提供運行時正在編譯的方法的信息,Jarscan工具的輸出結果可以告訴我們哪些方法不能進行JIT編譯。結合兩者,我們就可以清楚地知道哪些方法進行了編譯,哪些沒有進行。另外,PrintCompilation選項可以在線上環境使用,因為開啟這個選項幾乎不會影響JIT編譯器的性能。

但是,PrintCompilation也存在著兩個小問題,有時候會顯得不是那麼方便:

上述的第二個問題的影響在於PrintCompilation的日誌會和其他常用的日誌混在一起。對於大多數伺服器端程序來說,我們需要一個過濾進程來將PrintCompilation的日誌過濾到一個獨立的日誌中。最簡單的判斷一個方法否是JIT友好的途徑就是遵循下面這個簡單的步驟:

如果一個方法超過了內聯的臨界值,大多數情況下最常用的方法就是講這個重要的方法拆分成多個可以進行內聯的小方法,這樣修改之後通常會獲取更好的執行效率。但是對於所有的性能優化而言,優化之前的執行效率需要測量記錄,並且需要需要同優化後的數據進行對比之後,才能決定是否進行優化。為了性能優化而做出的改變不應該是盲目的。

幾乎所有的Java程序都依賴大量的提供關鍵功能的庫。Jarscan可以幫助我們檢測哪些庫或者框架的方法超過了內聯的臨界值。舉一個具體的例子,我們這裡檢查JVM主要的運行時庫 rt.jar文件。

為了讓結果有點意思,我們分別比較Java 7 和Java 8,並查看這個庫的變化。在開始之前我們需要安裝Java 7 和 Java8 JDK。首先,我們分別運行Jarscan掃描各自的rt.jar文件,並得到用來後續分析的報告結果:

$ ./jarScan.sh /Library/Java
/JavaVirtualMachines/jdk1.
7.0_71.jdk/Contents/Home/jre/lib/rt.jar >
large_jre_methods_7u71.txt $ ./jarScan.sh /Library/Java
/JavaVirtualMachines/jdk1.8.0_25.
jdk/Contents/Home/jre/lib/rt.jar >
large_jre_methods_8u25.txt

上述操作結束之後,我們得到兩個CSV文件,一個是JDK 7u71的結果,另一個是JDK 8u25。然後我們看一看不同的版本內聯情況有哪些變化。首先,一個最簡單的判斷驗證方式,看一看不同版本的JRE中有多少對JIT不友好的方法。

$ wc -l large_jre_methods_* 3684
large_jre_methods_7u71.txt 3576
large_jre_methods_8u25.txt

我們可以看到,相比Java 7,Java 8 少了100多個內聯不友好的方法。下面繼續深入研究,看看一些關鍵的包的變化。為了便於理解如何操作,我們再次介紹一下Jarscan的輸出結果。Jarscan的輸出結果有如下3個屬性組成:

"<package>",
"<method name and signature>"
,<num of bytes>

了解了上述的格式,我們可以利用一些Unix文本處理的工具來研究報告結果。比如,我們想看一下Java 7 和 Java 8 這兩個版本中java.lang包下哪些方法變得內聯友好了:

$ cat large_jre_methods_7u71.txt
large_jre_methods_8u25.txt |
grep -i ^\"java.lang | sort | uniq -c

上面的語句使用grep命令過濾出每份報告中以java.lang開頭的行,即只顯示位於包java.lang中的類的內聯不友好的方法。sort | uniq -c 是一個比較老的Unix小技巧,首先將講行信息進行排序(相同的信息將聚集到一起),然後對上面的排序數據進行去重操作。另外本命令還會統計一個當前行信息重複的次數,這個數據位於每一行信息的最開始部分。讓我們看一下上述命令的執行結果:

$ cat large_jre_methods_7u71.txt
large_jre_methods_8u25.txt |
grep -i ^\"java.lang | sort |
uniq -c2 "java.lang.CharacterData00","
int getNumericValue
(int)",8352 "java.lang.
CharacterData00","
int toLowerCase(int)",13392 "
java.lang.CharacterData00","
int toUpperCase(int)",1307// ...
skipped output2 "java.lang.invoke.
DirectMethodHandle","private static
java.lang.invoke.LambdaForm
makePreparedLambdaForm(java.lang.
invoke.MethodType,int)",6131 "
java.lang.invoke.
InnerClassLambdaMetafactory","
private java.lang.Class spinInnerClass()
",497// ... more output ----

報告中,以2(這是使用了uniq -c 對相同的信息計算數量的結果)最為起始的條目說明這些方法在Java 7 和Java 8 中起字節碼大小沒有改變。雖然這並不能完全肯定地說明這些方法的字節碼沒有改變,但通常我們也可以視為沒有改變。重複次數為1的方法有如下的情況:

我們看一下以1開始的行數據:

1 "java.lang.invoke.
AbstractValidatingLambdaMetafactory","
voidvalidateMetafactoryArgs()",864 1 "java.lang.invoke.
InnerClassLambdaMetafactory","
privatejava.lang.Class
spinInnerClass()",497 1 "java.lang.reflect.
Executable","java.lang.String
sharedToGenericString
(int,boolean)",329

上面三個對內聯不友好的方法全部來自Java 8,因此這屬於新方法的情況。前兩個方法與lamda表達式實現相關,第三個方法和反射子系統中繼承層級調整有關。在這裡,這個改變就是在Java 8 中引入了方法和構造器可以繼承的通用基類。

最後,我們看一看JDK核心庫一些令人驚訝的特性:

$ grep -i ^\"java.lang.String
large_jre_methods_8u25.txt
"java.lang.String","public
java.lang.String[] split
(java.lang.String,int)",326
"java.lang.String","public
java.lang.String toLowerCase
(java.util.Locale)",431 "
java.lang.String","
public java.lang.String
toUpperCase(java.util.Locale)",439

從上面的日誌我們可以了解到,即使是Java 8 中一些java.lang.String中一些關鍵的方法還是處於內聯不友好的狀態。尤其是toLowerCase和toUpperCase這兩個方法居然過大而無法內聯,著實讓人感到奇怪。但是,這兩個方法由於要處理UTF-8數據而不是簡單的ASCII數據,進而增加了方法的複雜性和大小,因而超過了內聯友好的臨界值。

對於性能要求較高並且確定只處理ASCII數據的程序,通常我們需要實現一個自己的StringUtils類。該類中包含一些靜態的方法來實現上述內聯不友好的方法的功能,但這些靜態方法既保持緊湊型又能到達內聯的要求。

上述我們討論的改進都是大部分基於靜態分析。除此之外,使用強大的JITWatch工具可以幫助我們更好地優化。JITWatch工具需要設置-XX:+LogCompilation選項開啟日誌列印。其列印出來的日誌為XML格式,而非PrintCompilation簡單的文本輸出,並且這些日誌比較大,通常會到達幾百MB。它會影響正在運行的程序(默認情況下主要來自日誌輸出的影響),因此這個選項不適合在線上的生產環境使用。

PrintCompilation和Jarscan結合使用並不困難,但卻提供了簡單且很有實際作用的一步,尤其是對於開發團隊打算研究其程序中即時編譯執行情況時。大多數情況下,在性能優化中,一個快速的分析可以幫助我們完成一些容易實現的目標。

作者Ben Evans,jClarity公司的CEO,jClarity是一家致力於Java和JVM性能分析研究的創業公司。除此之外他還是London Java Community的負責人之一併在Java Community Process Executive Committee有一席之地。他之前的項目有Google IPO性能測試,金融交易系統,90年代知名電影網站等。

投稿請聯繫:

郵箱:editors@cn.infoq.com QQ:1073600161

▣ 版權歸屬InfoQ,禁止私自抄襲轉載。

回復關鍵詞:React | 架構師 | 運維 | 雲 | 開源 | 物聯網 | Kubernetes | 架構 | 人工智慧 | Kafka | Docker | Netty | CoreOS | QCon | Github | Swift | 敏捷 | 語言 | 程式設計師

相關焦點

  • Java代碼的編譯與反編譯
    2、用編譯程序產生目標程序的動作。 編譯就是把高級語言變成計算機可以識別的2進位語言,計算機只認識1和0,編譯程序把人們熟悉的語言換成2進位的。 編譯程序把一個源程序翻譯成目標程序的工作過程分為五個階段:詞法分析;語法分析;語義檢查和中間代碼生成;代碼優化;目標代碼生成。主要是進行詞法分析和語法分析,又稱為源程序分析,分析過程中發現有語法錯誤,給出提示信息。
  • C++與Lua 交互 即時編譯器LuaJIT
    程序運行通常有兩種方式:靜態編譯和動態解釋,即時編譯混合了二者。即時編譯是動態編譯的一種形式,是一種優化虛擬機運行的技術。即時編譯器會將頻繁執行的代碼編譯成機器碼緩存起來,下次調用時將直接執行機器碼。相比原生逐條執行虛擬機指令效率更高。而對於那些只執行一次的代碼仍然逐條執行。值得注意的是,即時編譯帶來的效率提升,並不一定能抵消編譯效率的下降。
  • luajit VS C,運行性能超過C?
    在設計完數據結構和算法之後分別採用lua 和 C 預算實現了一遍,發現lua運行效率一點不輸C的效率,而根據我實際編寫代碼耗時看,差不多是C語言一半的時間消耗或者三分之一。廢話不多說,先描述實驗方法 使用C語言完成測試代碼編寫,採用gcc 10.2.0 版本分別使用 -O0 -O1 -O2 -O3 編譯三個可執行版本。
  • 常用java反編譯工具匯總
    以下是個人常用的幾種java反編譯工具,個人比較推薦使用IDEA自帶的反編譯組件,直接調用IDEA的java-decompiler組件進行反編譯,也方便進行調試分析。部分工具不僅限於反編譯,其他功能請自行摸索。蘿蔔青菜,各有所愛,各位依據自身情況選擇工具。
  • 不需要jre運行Java?你沒看錯!
    今天我們要介紹的是spring-native,它可以讓你的spring boot程序,體驗graalvm編譯器的特性,把你的應用直接編譯成native的!不需要再安裝jre,你的應用程式將和exe一樣,直接在目標機器上運行!而且啟動時間不到1秒鐘。
  • 使用 lombok 簡化 Java 代碼
    但是,這樣真的就可以了麼?編譯下,讓我們看看生成的二進位代碼。(請自行下載lombok.jar)命令行> javac -cp lombok.jar A.java命令行> javap -c A.class輸出結果略。可以看到完全一樣。
  • Java與Lua相互調用
    另外補充一句,我們是遊戲項目一、方案目前最常見的方案:luaj,純Java實現的Lua解析器,基於Lua 5.2LuaJ的原理:用Java實現了一套Lua的編譯器,本質上是把Lua文件中的Lua語言動態編譯成了Java字節碼,因此會收到諸多限制(比如第三方庫的問題),而LuaJ本質上也只是運行在JVM上的Java字節碼,和運行在C編譯器環境下的Lua是有區別的
  • Java代碼編譯和執行的整個過程
    Java代碼編譯是由Java源碼編譯器來完成,流程圖如下所示:
  • cocos2dx lua 反編譯
    一般情況是libcocos2dlua.so,IDA 打開,函數窗口直接搜索applicationDidFinishLaunching,就能帶你飛,可惜只有loadChunksFromZip,沒有setXXTEAKeyAndSign,這保存解密KEY的被編譯優化了。怎麼辦?IDA字符串窗口幫你忙,編譯器編譯代碼的時候都是就近原則,只要是差不多地方出現的字符串,都會被放在一起。
  • 可憐的 Java 實驗性 AOT and JIT Compiler
    收錄於話題 #java 「JEP 295: Ahead-of-Time Compilation」 在 Java 9 開始引入了工具 jatoc,實現了提前編譯
  • luajit 開啟FFI + jit之後彪悍的性能
    在這次改造實現的過程中使用了FFI + jit 技術,同時lua代碼結構和C的代碼結構、數據結構保持一致。年前最後一個周末我抽空做了lua和C的代碼性能測試。測試效果非常令人驚嘆!!!其中lua代碼沒有刻意為了能讓luajit 發揮jit功效的方向做代碼編寫,完全遵循易讀與業務邏輯清晰這兩個基本原則實現。
  • 用好Lua+Unity,讓性能飛起來—LuaJIT性能坑詳解(上)
    然而不幸的是這個模式在iOS下是無法開啟的,因為iOS為了安全,從系統設計上禁止了用戶進程自行申請有執行權限的內存空間,因此你沒有辦法在運行時編譯出一段代碼到內存然後執行,所以JIT模式在iOS以及其他有權限管制的平臺(例如PS4,XBox)都不能使用。
  • 你寫的 Java 代碼是如何一步步輸出結果的?
    在本篇文章中,將重點研究java原始碼的執行原理,即從程式設計師編寫JAVA原始碼,到最終形成產品,在整個過程中,都經歷了什麼?每一步又是怎麼執行的?執行原理又是什麼?一、編寫java源程序java源文件:指存儲java源碼的文件。
  • numba,讓你的Python飛起來!
    numba是一款可以將python函數編譯為機器代碼的JIT編譯器,經過numba編譯的python代碼(僅限數組運算),其運行速度可以接近C或FORTRAN語言。import numpy as npimport numbafrom numba import jit@jit(nopython=True) # jit,numba裝飾器中的一種def go_fast(a): # 首次調用時,函數被編譯為機器代碼 trace = 0 # 假設輸入變量是numpy數組 for i in range
  • Vue SSR 即時編譯技術
    提取模板中靜態的 html 只需在編譯期對模板結構做解析,而判斷動態節點在服務端渲染階段是否為靜態,需在運行時對 VNode 做 Diff,將動態節點轉化成靜態 html 需要修改渲染函數的原始碼,我們將這種在運行時優化服務端渲染函數的技術稱作 SSR 即時編譯技術(JIT)JIT Diff
  • 前端零基礎編譯原理科普
    但是當有 100 中單詞的格式要處理,全部寫成 if else,我的天,那代碼還能看麼。所以要把每個單詞的處理過程當成一種狀態,處理到不同的單詞格式就跳到不同的狀態,跳轉的方式自然是根據當前處理的字符來的,處理一個字符串從開始狀態流轉到不同的狀態來處理,這樣就是狀態自動機,每個 token 識別完了就可以拋出來,最終產出的就是一個 token 數組。沒錯,就像你說的吃豆子一樣。
  • Java Web安全-代碼審計(一)
    本篇內容涉及Java Web安全-代碼審計,非戰鬥人員速速離開。通俗的說Java代碼審計就是通過審計Java代碼來發現Java應用程式自身中存在的安全問題,由於Java本身是編譯型語言,所以即便只有class文件的情況下我們依然可以對Java代碼進行審計。對於未編譯的Java原始碼文件我們可以直接閱讀其源碼,而對於已編譯的class或者jar文件我們就需要進行反編譯了。
  • 看了這篇【JIT編譯器】,你也能說你會java性能優化了!
    這篇文章小菜帶你一起探究 Java性能優化之JIT編譯器。通常這兩個編譯器也稱為 c1 編譯器(client編譯器) 和 c2  編譯器(server編譯器)分層編譯器:分層編譯意味著必須使用 server 編譯器關閉分層編譯:java -client -XX:+TieredCompilation other_args
  • 程式設計師進階系列:實戰自己動手編譯 JDK
    方式一:通過 Mercurial 代碼版本管理工具取得源碼。# 安裝版本控制工具brew install mercurial# 安裝加速編譯工具和編譯中要依賴的freetypebrew install ccache freetype# 下載原始碼hg clone https://hg.openjdk.java.net/jdk/jdk12‍不過不推薦這種方式,原因試一下就知道
  • 一行代碼讓你的python運行速度提高100倍
    ()-tt))        return sprint(foo(1,100000000))結果:Time used: 6.779874801635742 sec4999999950000000我們來加一行代碼,再看看結果:from numba import jitimport