Java 面試-即時編譯( JIT )


當我們在寫代碼時,一個方法內部的行數自然是越少越好,這樣邏輯清晰、方便閱讀,其實好處遠不止如此,通過即時編譯,甚至可以提高執行時的性能,今天就讓我們好好來了解一下其中的原理。

簡介

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

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

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

分類

在 HotSpot 虛擬機中,內置了兩種 JIT,分別為C1 編譯器C2 編譯器,這兩個編譯器的編譯過程是不一樣的。

C1 編譯器

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

C2 編譯器

C2 編譯器是為長期運行的服務器端應用程序做性能調優的編譯器,適用於執行時間較長或對峰值性能有要求的程序,也稱為Server Compiler,例如,服務器上長期運行的 Java 應用對穩定運行就有一定的要求。

分層編譯

在 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 編譯,也是將字節碼編譯為本地代碼,但是會啟用一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。

對於 C1 的三種狀態,按執行效率從高至低:第 1 層、第 2層、第 3層。

通常情況下,C2 的執行效率比 C1 高出30%以上。

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

你可以通過 java -version命令行可以直接查看到當前系統使用的編譯模式:

C:\Users\Administrator>java -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, mixed mode)

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

C:\Users\Administrator>java -Xint -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, interpreted mode)

C:\Users\Administrator>java -Xcomp -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, compiled mode)

觸發標准

在 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 編譯運用了一些經典的編譯優化技術來實現代碼的優化,即通過一些例行檢查優化,可以智能地編譯出運行時的最優性能代碼。主要有兩種:方法內聯逃逸分析

方法內聯

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

這種執行操作要求在執行前保護現場並記憶執行的地址,執行后要恢復現場,並按原來保存的地址繼續執行。 因此,方法調用會產生一定的時間和空間方面的開銷(其實可以理解為一種上下文切換的精簡版)。

那么對於那些方法體代碼不是很大,又頻繁調用的方法來說,這個時間和空間的消耗會很大。

方法內聯的優化行為就是把目標方法的代碼復制到發起調用的方法之中,避免發生真實的方法調用。

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

  1. 經常執行的方法,默認情況下,方法體大小小於 325 字節的都會進行內聯,我們可以通過-XX:MaxFreqInlineSize=N來設置大小值;
  2. 不是經常執行的方法,默認情況下,方法大小小於 35 字節才會進行內聯,我們也可以通過-XX:MaxInlineSize=N來重置大小值。

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

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

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

  1. 通過設置 JVM 參數來減小熱點閾值或增加方法體閾值,以便更多的方法可以進行內聯,但這種方法意味着需要占用更多地內存;
  2. 在編程中,避免在一個方法中寫大量代碼,習慣使用小方法體;
  3. 盡量使用 final、private、static 關鍵字修飾方法,編碼方法因為繼承,會需要額外的類型檢查。

此處就聯系到了最開始提出的觀點,一個方法中的內容越少,當該方法經常被執行時,則容易進行方法內聯,從而優化性能。

逃逸分析

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

可以通過JVM參數進行設置:

-XX:+DoEscapeAnalysis 開啟逃逸分析(jdk1.8 默認開啟)
-XX:-DoEscapeAnalysis 關閉逃逸分析

其具體優化方法主要有三種:棧上分配鎖消除標量替換

棧上分配

在 Java 中默認創建一個對象是在堆中分配內存的,而當堆內存中的對象不再使用時,則需要通過垃圾回收機制回收,這個過程相對分配在棧中的對象的創建和銷毀來說,更消耗時間和性能。

這個時候,逃逸分析如果發現一個對象只在方法中使用,就會將對象分配在棧上。

但是,HotSpot 虛擬機目前的實現導致棧上分配實現比較復雜,可以說,在 HotSpot 中暫時沒有實現這項優化,所以大家可能暫時無法體會到這種優化(我看的資料顯示在 Java8 中還沒有實現,如果大家有什么其他的發現,歡迎留言)。

鎖消除

如果是在單線程環境下,其實完全沒有必要使用線程安全的容器,但就算使用了,因為不會有線程競爭,這個時候 JIT 編譯會對這個對象的方法鎖進行鎖消除。例如:

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

可以通過JVM參數進行設置:

-XX:+EliminateLocks 開啟鎖消除(jdk1.8 默認開啟)
-XX:-EliminateLocks 關閉鎖消除

標量替換

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

例如:

	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參數進行設置:

-XX:+EliminateAllocations 開啟標量替換(jdk1.8 默認開啟)
-XX:-EliminateAllocations 關閉就可以了

總結

今天的內容,由最基本的常識方法內部行數和邏輯需要盡可能簡單引出,了解了 JVM 通過即時編譯對熱點代碼進行優化的過程。如果你有什么想法,歡迎在下方留言。

有興趣的話可以訪問我的博客或者關注我的公眾號、頭條號,說不定會有意外的驚喜。

https://death00.github.io/


免責聲明!

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



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