JVM性能監測及調優(2)

深入JVM即時編譯器JIT,優化Java編譯

說到編譯,我猜你一定會想到 .java 文件被編譯成 .class 文件的過程,這個編譯我們一般稱爲前端編譯。Java 的編譯和運行過程非常複雜,除了前端編譯,還有運行時編譯。由於機器無法直接運行 Java 生成的字節碼,所以在運行時,JIT 或解釋器會將字節碼轉換成機器碼,這個過程就叫運行時編譯。

類文件在運行時被進一步編譯,它們可以變成高度優化的機器代碼,由於 C/C++ 編譯器的所有優化都是在編譯期間完成的,運行期間的性能監控僅作爲基礎的優化措施則無法進行,例如,調用頻率預測、分支頻率預測、裁剪未被選擇的分支等,而 Java 在運行時的再次編譯,就可以進行基礎的優化措施。因此,JIT 編譯器可以說是 JVM 中運行時編譯最重要的部分之一。

然而許多 Java 開發人員對 JIT 編譯器的瞭解並不多,不深挖其工作原理,也不深究如何檢測應用程序的即時編譯情況,線上發生問題後很難做到從容應對。今天我們就來學習運行時編譯如何實現對 Java 代碼的優化。

類編譯加載執行過程
在這之前,我們先了解下 Java 從編譯到運行的整個過程,爲後面的學習打下基礎。請看下圖:

類編譯

在編寫好代碼之後,我們需要將 .java 文件編譯成 .class 文件,才能在虛擬機上正常運行代碼。文件的編譯通常是由 JDK 中自帶的 Javac 工具完成,一個簡單的 .java 文件,我們可以通過 javac 命令來生成 .class 文件。

下面我們通過 javap 反編譯來看看一個 class 文件結構中主要包含了哪些信息:

看似一個簡單的命令執行,前期編譯的過程其實是非常複雜的,包括詞法分析、填充符號表、註解處理、語義分析以及生成 class 文件,這個過程我們不用過多關注。只要從上圖中知道,編譯後的字節碼文件主要包括常量池和方法表集合這兩部分就可以了。

常量池主要記錄的是類文件中出現的字面量以及符號引用。字面常量包括字符串常量(例如 String str=“abc”,其中"abc"就是常量),聲明爲 final 的屬性以及一些基本類型(例如,範圍在 -127-128 之間的整型)的屬性。符號引用包括類和接口的全限定名、類引用、方法引用以及成員變量引用(例如 String str=“abc”,其中 str 就是成員變量引用)等。

方法表集合中主要包含一些方法的字節碼、方法訪問權限(public、protect、prviate 等)、方法名索引(與常量池中的方法引用對應)、描述符索引、JVM 執行指令以及屬性集合等。

類加載
當一個類被創建實例或者被其它對象引用時,虛擬機在沒有加載過該類的情況下,會通過類加載器將字節碼文件加載到內存中。

不同的實現類由不同的類加載器加載,JDK 中的本地方法類一般由根加載器(Bootstrp loader)加載進來,JDK 中內部實現的擴展類一般由擴展加載器(ExtClassLoader )實現加載,而程序中的類文件則由系統加載器(AppClassLoader )實現加載。

在類加載後,class 類文件中的常量池信息以及其它數據會被保存到 JVM 內存的方法區中。

類連接
類在加載進來之後,會進行連接、初始化,最後纔會被使用。在連接過程中,又包括驗證、準備和解析三個部分。

驗證:驗證類符合 Java 規範和 JVM 規範,在保證符合規範的前提下,避免危害虛擬機安全。

準備:爲類的靜態變量分配內存,初始化爲系統的初始值。對於 final static 修飾的變量,直接賦值爲用戶的定義值。例如,private final static int value=123,會在準備階段分配內存,並初始化值爲 123,而如果是 private static int value=123,這個階段 value 的值仍然爲 0。

解析:將符號引用轉爲直接引用的過程。我們知道,在編譯時,Java 類並不知道所引用的類的實際地址,因此只能使用符號引用來代替。類結構文件的常量池中存儲了符號引用,包括類和接口的全限定名、類引用、方法引用以及成員變量引用等。如果要使用這些類和方法,就需要把它們轉化爲 JVM 可以直接獲取的內存地址或指針,即直接引用。

類初始化
類初始化階段是類加載過程的最後階段,在這個階段中,JVM 首先將執行構造器方法,編譯器會在將 .java 文件編譯成 .class 文件時,收集所有類初始化代碼,包括靜態變量賦值語句、靜態代碼塊、靜態方法,收集在一起成爲() 方法。

初始化類的靜態變量和靜態代碼塊爲用戶自定義的值,初始化的順序和 Java 源碼從上到下的順序一致。例如:

private static int i=1;
static{
i=0;
}
public static void main(String [] args){
System.out.println(i);
}

此時運行結果爲:

0

再來看看以下代碼:

static{
i=0;
}
private static int i=1;
public static void main(String [] args){
System.out.println(i);
}

此時運行結果爲:

1

子類初始化時會首先調用父類的() 方法,再執行子類的() 方法,運行以下代碼:

public class Parent{
public static String parentStr= “parent static string”;
static{
System.out.println(“parent static fields”);
System.out.println(parentStr);
}
public Parent(){
System.out.println(“parent instance initialization”);
}
}

public class Sub extends Parent{
public static String subStr= “sub static string”;
static{
System.out.println(“sub static fields”);
System.out.println(subStr);
}

public Sub(){
System.out.println(“sub instance initialization”);
}

public static void main(String[] args){
System.out.println(“sub main”);
new Sub();
}
}

運行結果:

parent static fields
parent static string
sub static fields
sub static string
sub main
parent instance initialization
sub instance initialization

JVM 會保證() 方法的線程安全,保證同一時間只有一個線程執行。

JVM 在初始化執行代碼時,如果實例化一個新對象,會調用方法對實例變量進行初始化,並執行對應的構造方法內的代碼。

即時編譯

初始化完成後,類在調用執行過程中,執行引擎會把字節碼轉爲機器碼,然後在操作系統中才能執行。在字節碼轉換爲機器碼的過程中,虛擬機中還存在着一道編譯,那就是即時編譯。

最初,虛擬機中的字節碼是由解釋器( Interpreter )完成編譯的,當虛擬機發現某個方法或代碼塊的運行特別頻繁的時候,就會把這些代碼認定爲“熱點代碼”。

爲了提高熱點代碼的執行效率,在運行時,即時編譯器(JIT)會把這些代碼編譯成與本地平臺相關的機器碼,並進行各層次的優化,然後保存到內存中。

即時編譯器類型
在 HotSpot 虛擬機中,內置了兩個 JIT,分別爲 C1 編譯器和 C2 編譯器,這兩個編譯器的編譯過程是不一樣的。

C1 編譯器是一個簡單快速的編譯器,主要的關注點在於局部性的優化,適用於執行時間較短或對啓動性能有要求的程序,例如,GUI 應用對界面啓動速度就有一定要求。

C2 編譯器是爲長期運行的服務器端應用程序做性能調優的編譯器,適用於執行時間較長或對峯值性能有要求的程序。根據各自的適配性,這兩種即時編譯也被稱爲 Client Compiler 和 Server Compiler。

在 Java7 之前,需要根據程序的特性來選擇對應的 JIT,虛擬機默認採用解釋器和其中一個編譯器配合工作。

Java7 引入了分層編譯,這種方式綜合了 C1 的啓動性能優勢和 C2 的峯值性能優勢,我們也可以通過參數“-client”“-server”強制指定虛擬機的即時編譯模式。分層編譯將 JVM 的執行狀態分爲了 5 個層次:
第 0 層:程序解釋執行,默認開啓性能監控功能(Profiling),如果不開啓,可觸發第二層編譯;

第 1 層:可稱爲 C1 編譯,將字節碼編譯爲本地代碼,進行簡單、可靠的優化,不開啓 Profiling;

第 2 層:也稱爲 C1 編譯,開啓 Profiling,僅執行帶方法調用次數和循環回邊執行次數 profiling 的 C1 編譯;

第 3 層:也稱爲 C1 編譯,執行所有帶 Profiling 的 C1 編譯;

第 4 層:可稱爲 C2 編譯,也是將字節碼編譯爲本地代碼,但是會啓用一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。

在 Java8 中,默認開啓分層編譯,-client 和 -server 的設置已經是無效的了。如果只想開啓 C2,可以關閉分層編譯(-XX:-TieredCompilation),如果只想用 C1,可以在打開分層編譯的同時,使用參數:-XX:TieredStopAtLevel=1。

除了這種默認的混合編譯模式,我們還可以使用“-Xint”參數強制虛擬機運行於只有解釋器的編譯模式下,這時 JIT 完全不介入工作;我們還可以使用參數“-Xcomp”強制虛擬機運行於只有 JIT 的編譯模式下。

通過 java -version 命令行可以直接查看到當前系統使用的編譯模式。如下圖所示:

熱點探測

在 HotSpot 虛擬機中的熱點探測是 JIT 優化的條件,熱點探測是基於計數器的熱點探測,採用這種方法的虛擬機會爲每個方法建立計數器統計方法的執行次數,如果執行次數超過一定的閾值就認爲它是“熱點方法”。

虛擬機爲每個方法準備了兩類計數器:方法調用計數器(Invocation Counter)和回邊計數器(Back Edge Counter)。在確定虛擬機運行參數的前提下,這兩個計數器都有一個確定的閾值,當計數器超過閾值溢出了,就會觸發 JIT 編譯。

方法調用計數器:用於統計方法被調用的次數,方法調用計數器的默認閾值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次,可通過 -XX: CompileThreshold 來設定;而在分層編譯的情況下,-XX: CompileThreshold 指定的閾值將失效,此時將會根據當前待編譯的方法數以及編譯線程數來動態調整。當方法計數器和回邊計數器之和超過方法計數器閾值時,就會觸發 JIT 編譯器。

回邊計數器:用於統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱爲“回邊”(Back Edge),該值用於計算是否觸發 C1 編譯的閾值,在不開啓分層編譯的情況下,C1 默認爲 13995,C2 默認爲 10700,可通過 -XX: OnStackReplacePercentage=N 來設置;而在分層編譯的情況下,-XX: OnStackReplacePercentage 指定的閾值同樣會失效,此時將根據當前待編譯的方法數以及編譯線程數來動態調整。

建立回邊計數器的主要目的是爲了觸發 OSR(On StackReplacement)編譯,即棧上編譯。在一些循環週期比較長的代碼段中,當循環達到回邊計數器閾值時,JVM 會認爲這段是熱點代碼,JIT 編譯器就會將這段代碼編譯成機器語言並緩存,在該循環時間段內,會直接將執行代碼替換,執行緩存的機器語言。

編譯優化技術
JIT 編譯運用了一些經典的編譯優化技術來實現代碼的優化,即通過一些例行檢查優化,可以智能地編譯出運行時的最優性能代碼。今天我們主要來學習以下兩種優化手段:

  1. 方法內聯

調用一個方法通常要經歷壓棧和出棧。調用方法是將程序執行順序轉移到存儲該方法的內存地址,將方法的內容執行完後,再返回到執行該方法前的位置。

這種執行操作要求在執行前保護現場並記憶執行的地址,執行後要恢復現場,並按原來保存的地址繼續執行。因此,方法調用會產生一定的時間和空間方面的開銷。

那麼對於那些方法體代碼不是很大,又頻繁調用的方法來說,這個時間和空間的消耗會很大。方法內聯的優化行爲就是把目標方法的代碼複製到發起調用的方法之中,避免發生真實的方法調用。

例如以下方法:

private int add1(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2) {
return x1 + x2;
}

最終會被優化爲:

private int add1(int x1, int x2, int x3, int x4) {
return x1 + x2+ x3 + x4;
}

JVM 會自動識別熱點方法,並對它們使用方法內聯進行優化。我們可以通過 -XX:CompileThreshold 來設置熱點方法的閾值。但要強調一點,熱點方法不一定會被 JVM 做內聯優化,如果這個方法體太大了,JVM 將不執行內聯操作。而方法體的大小閾值,我們也可以通過參數設置來優化:

經常執行的方法,默認情況下,方法體大小小於 325 字節的都會進行內聯,我們可以通過 -XX:MaxFreqInlineSize=N 來設置大小值;

不是經常執行的方法,默認情況下,方法大小小於 35 字節纔會進行內聯,我們也可以通過 -XX:MaxInlineSize=N 來重置大小值。

之後我們就可以通過配置 JVM 參數來查看到方法被內聯的情況:

-XX:+PrintCompilation //在控制檯打印編譯過程信息
-XX:+UnlockDiagnosticVMOptions //解鎖對JVM進行診斷的選項參數。默認是關閉的,開啓後支持一些特定參數對JVM進行診斷
-XX:+PrintInlining //將內聯方法打印出來

當我們設置 VM 參數:-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 之後,運行以下代碼:

public static void main(String[] args) {
for(int i=0; i<1000000; i++) {//方法調用計數器的默認閾值在C1模式下是1500次,在C2模式在是10000次,我們循環遍歷超過需要閾值
add1(1,2,3,4);
}
}

我們可以看到運行結果中,顯示了方法內聯的日誌:

熱點方法的優化可以有效提高系統性能,一般我們可以通過以下幾種方式來提高方法內聯:

通過設置 JVM 參數來減小熱點閾值或增加方法體閾值,以便更多的方法可以進行內聯,但這種方法意味着需要佔用更多地內存;

在編程中,避免在一個方法中寫大量代碼,習慣使用小方法體;

儘量使用 final、private、static 關鍵字修飾方法,編碼方法因爲繼承,會需要額外的類型檢查。

  1. 逃逸分析

逃逸分析(Escape Analysis)是判斷一個對象是否被外部方法引用或外部線程訪問的分析技術,編譯器會根據逃逸分析的結果對代碼進行優化。
棧上分配

我們知道,在 Java 中默認創建一個對象是在堆中分配內存的,而當堆內存中的對象不再使用時,則需要通過垃圾回收機制回收,這個過程相對分配在棧中的對象的創建和銷燬來說,更消耗時間和性能。這個時候,逃逸分析如果發現一個對象只在方法中使用,就會將對象分配在棧上。

以下是通過循環獲取學生年齡的案例,方法中創建一個學生對象,我們現在通過案例來看看打開逃逸分析和關閉逃逸分析後,堆內存對象創建的數量對比。

public static void main(String[] args) {
for (int i = 0; i < 200000 ; i++) {
getAge();
}
}

public static int getAge(){
Student person = new Student(“小明”,18,30);
return person.getAge();
}

static class Student {
private String name;
private int age;

public Student(String name, int age) {
    this.name = name;
    this.age = age;
}

public String getName() {
    return name;
}

public void setName(String name) {
    this.name = name;
}

public int getAge() {
    return age;
}

public void setAge(int age) {
    this.age = age;
}

}

然後,我們分別設置 VM 參數:Xmx1000m -Xms1000m -XX:-DoEscapeAnalysis -XX:+PrintGC 以及 -Xmx1000m -Xms1000m -XX:+DoEscapeAnalysis -XX:+PrintGC,通過之前講過的 VisualVM 工具,查看堆中創建的對象數量。

然而,運行結果卻沒有達到我們想要的優化效果,也許你懷疑是 JDK 版本的問題,然而我分別在 1.6~1.8 版本都測試過了,效果還是一樣的:

(-server -Xmx1000m -Xms1000m -XX:-DoEscapeAnalysis -XX:+PrintGC)

(-server -Xmx1000m -Xms1000m -XX:+DoEscapeAnalysis -XX:+PrintGC)

這其實是因爲 HotSpot 虛擬機目前的實現導致棧上分配實現比較複雜,可以說,在 HotSpot 中暫時沒有實現這項優化。隨着即時編譯器的發展與逃逸分析技術的逐漸成熟,相信不久的將來 HotSpot 也會實現這項優化功能。

鎖消除

在非線程安全的情況下,儘量不要使用線程安全容器,比如 StringBuffer。由於 StringBuffer 中的 append 方法被 Synchronized 關鍵字修飾,會使用到鎖,從而導致性能下降。

但實際上,在以下代碼測試中,StringBuffer 和 StringBuilder 的性能基本沒什麼區別。這是因爲在局部方法中創建的對象只能被當前線程訪問,無法被其它線程訪問,這個變量的讀寫肯定不會有競爭,這個時候 JIT 編譯會對這個對象的方法鎖進行鎖消除。

 public static String getString(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

標量替換

逃逸分析證明一個對象不會被外部訪問,如果這個對象可以被拆分的話,當程序真正執行的時候可能不創建這個對象,而直接創建它的成員變量來代替。將對象拆分後,可以分配對象的成員變量在棧或寄存器上,原本的對象就無需分配內存空間了。這種編譯優化就叫做標量替換。

我們用以下代碼驗證:

public void foo() {
TestInfo info = new TestInfo();
info.id = 1;
info.count = 99;
…//to do something
}

逃逸分析後,代碼會被優化爲:

public void foo() {
id = 1;
count = 99;
…//to do something
}

我們可以通過設置 JVM 參數來開關逃逸分析,還可以單獨開關同步消除和標量替換,在 JDK1.8 中 JVM 是默認開啓這些操作的。

-XX:+DoEscapeAnalysis開啓逃逸分析(jdk1.8默認開啓,其它版本未測試)
-XX:-DoEscapeAnalysis 關閉逃逸分析

-XX:+EliminateLocks開啓鎖消除(jdk1.8默認開啓,其它版本未測試)
-XX:-EliminateLocks 關閉鎖消除

-XX:+EliminateAllocations開啓標量替換(jdk1.8默認開啓,其它版本未測試)
-XX:-EliminateAllocations 關閉就可以了

總結
今天我們主要了解了 JKD1.8 以及之前的類的編譯和加載過程,Java 源程序是通過 Javac 編譯器編譯成 .class 文件,其中文件中包含的代碼格式我們稱之爲 Java 字節碼(bytecode)。

這種代碼格式無法直接運行,但可以被不同平臺 JVM 中的 Interpreter 解釋執行。由於 Interpreter 的效率低下,JVM 中的 JIT 會在運行時有選擇性地將運行次數較多的方法編譯成二進制代碼,直接運行在底層硬件上。

在 Java8 之前,HotSpot 集成了兩個 JIT,用 C1 和 C2 來完成 JVM 中的即時編譯。雖然 JIT 優化了代碼,但收集監控信息會消耗運行時的性能,且編譯過程會佔用程序的運行時間。

到了 Java9,AOT 編譯器被引入。和 JIT 不同,AOT 是在程序運行前進行的靜態編譯,這樣就可以避免運行時的編譯消耗和內存消耗,且 .class 文件通過 AOT 編譯器是可以編譯成 .so 的二進制文件的。

到了 Java10,一個新的 JIT 編譯器 Graal 被引入。Graal 是一個以 Java 爲主要編程語言、面向 Java bytecode 的編譯器。與用 C++ 實現的 C1 和 C2 相比,它的模塊化更加明顯,也更容易維護。Graal 既可以作爲動態編譯器,在運行時編譯熱點方法;也可以作爲靜態編譯器,實現 AOT 編譯。

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