前言
在很多年以前,做C或者C++的程序員經常說Java語言的運行速度不如C或C++,Java運行速度慢主要是因為它是解釋執行的,而C或C++是編譯執行的,解釋執行需要通過JVM虛擬機將字節碼實時翻譯成機器碼(邊翻譯邊執行),才能運行在操作系統上,這個過程會比編譯執行慢。
但現在再說這個結論就不太對了,隨著JIT即時編譯技術的發展,性能差距正在逐步縮小,甚至在某些情況下,執行速度是優于C或C++的。
一.為什么出現JIT
1.JVM代碼執行流程
我們編寫的Java程序是由以.java結尾的源文件,通過javac指令編譯為由.class結尾的字節碼文件,而字節碼文件被加載進入JVM后,是通過JVM的解釋器逐條讀取字節碼,并將其翻譯成對應平臺的機器指令執行。雖然解釋執行具有跨平臺性好、啟動速度快的特點,但其執行效率相對較低。因為每次執行字節碼時,都需要經過解釋器的翻譯過程,這增加了額外的開銷。特別是在執行循環、遞歸等熱點代碼時,性能瓶頸尤為明顯。
2.JIT技術的引入
為了解決這一性能瓶頸,JVM引入了JIT即時編譯器技術。JIT技術能夠在程序運行時動態地將字節碼編譯成本地機器碼,并且根據程序的實際運行情況對機器碼進行優化,從而提高程序的執行效率。
當某些方法或代碼塊(它們都對應特定的字節碼)被頻繁調用時,這部分代碼就被視為熱點代碼。JVM虛擬機會針對性的對這部分’熱點代碼進行優化編譯,將它們從字節碼轉換為本地機器碼,然后將優化后的本地機器碼緩存起來,后續再執行時可以直接從緩存中獲取并運行,無需再次編譯
。JVM提供了一個參數“-XX:ReservedCodeCacheSize”,用來限制 CodeCache 的大小。也就是說,JIT 編譯后的代碼都會放在 CodeCache 里。
而熱點代碼由熱點探測進行發現,熱點探測基于計數器,JVM虛擬機會為每個方法建立對應的計數器,統計方法的執行次數、方法內的循環次數等,如果計數器超過指定閾值,則標識其為熱點代碼。
二.認識JIT即時編譯器
1.C1和C2編譯器
主流的HotSpot虛擬機內置了兩個JIT編譯器:C1(Client Compiler)編譯器和C2(Server Compiler)編譯器,C1和C2編譯器在優化方面有不同的側重點:C1側重編譯速度,C2側重深度優化
- Client Compiler(C1):針對客戶端應用程序,優化啟動時間,以較少的編譯優化來實現更快的編譯速度。
- Server Compiler(C2):C2編譯器側重于深度優化,與C1正好相反,C2編譯器的編譯時間較長,但優化的程度較高。C2的優化策略比較深度,會進行更高級的優化,比如逃逸分析等,C2編譯器編譯的代碼的執行速度通常比C1編譯器快。
C2編譯器由于深度優化代碼過于復雜,已經很難維護了,從JDK 10開始,Graal編譯器已經代替了C2編譯器,與C1編譯器協同工作
2.JIT優化技術-熱點探測
JIT(Just-In-Time)優化技術是一種在程序運行時動態地將部分代碼編譯成機器代碼,以提高程序執行效率和性能的技術。這種技術廣泛應用于動態語言、虛擬機和一些解釋型語言的執行環境中。JIT優化技術主要包括:熱點探測,編譯優化,內聯優化,挑分析等。
熱點檢測:熱點檢測是指在程序運行時,通過監測代碼的執行情況,識別出被頻繁執行的代碼塊或方法,即熱點代碼。通過計數器記錄代碼塊或方法的執行次數,當某個代碼塊的執行次數超過一定閾值時,認為它是熱點代碼。
虛擬機為每個方法準備了兩類計數器:方法調用計數器(Invocation Counter)和回邊計數器(Back Edge Counter)。在確定虛擬機運行參數的前提下,這兩個計數器都有一個確定的閾值,當計數器超過閾值溢出了,就會觸發 JIT 編譯。
方法調用計數器
用于統計方法被調用的次數,方法調用計數器的默認閾值在客戶端模式下是 1500 次,在服務端模式下是 10000 次(我們用的都是服務端,java –version查詢),可通過 -XX: CompileThreshold 來設定
回邊計數器
用于統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向后跳轉的指令稱為“回邊”(Back Edge),該值用于計算是否觸發 C1 編譯的閾值,在不開啟分層編譯的情況下,在服務端模式下是10700。
回邊計數器閾值 =方法調用計數器閾值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解釋器監控比率(InterpreterProfilePercentage)/100 , 通過 java -XX:+PrintFlagsFinal –version查詢相關參數:
其中OnStackReplacePercentage默認值為140,InterpreterProfilePercentage默認值為33,如果都取默認值,那Server模式虛擬機回邊計數器的閾值為10700. 回邊計數器閾值 =10000×(140-33)=10700
3.方法內聯
將函數調用處的代碼直接插入到調用點,減少函數調用的開銷。
// 方法內聯
public int xx() {int num1 = 111;int num2 = 222;// 等價于 -> sum = num1 + num2int sum = add(num1, num2);return sum;
}public int add(int num1, int num2) {return num1 + num1;
}
在代碼中,方法內聯會將其中的add(num1, num2)方法轉換為實際的num1 + num1,直接進行計算操作,避免了方法調用。
4.鎖消除技術
如果在線程安全的情況下使用了一個線程安全的容器那么會導致性能降低,比如StringBuffer這樣的類的append方法是有Synchronized同步鎖使的性能底下
public void xx(){SpringBuffer s = new StringBuffer();s.append(...)
}
但實際上,在以上代碼測試中,StringBuffer 和 StringBuilder 的性能基本沒什么區別。這是因為在局部方法中創建的對象只能被當前線程訪問,無法被其它線程訪問,這個變量的讀寫肯定不會有競爭,這個時候 JIT 編譯會對這個對象的方法鎖進行鎖消除。
使用StringBuffer和StringBuilder,我們把鎖消除關閉—測試發現性能差別有點大
- -XX:+EliminateLocks開啟鎖消除(jdk1.8默認開啟,其它版本未測試)
- -XX:-EliminateLocks 關閉鎖消除
鎖粗化
for( ... ){Synchronized(this){ ... }
}
鎖粗化的作用:如果檢測到同一個對象執行了連續的加鎖和解鎖的操作,則會將這一系列操作合并成一個更大的鎖,從而提升程序的執行效率。
5.逃逸分析技術
大家常理解的對象分配是在堆中分配的,對象的引用變量通常在棧中,當方法結束棧銷毀后,堆中對象失去引用后等待垃圾回收器回收。在某種情況下對象是可以在棧中分配的,也就是說當棧被銷毀對象也會被銷毀,這樣的話大大減少了GC的回收成本。這種對象分配就是棧上分配,是否能在棧上分配需要使用逃逸分析
算法進行計算。
逃逸分析的原理:分析對象動態作用域,當一個對象在方法中定義后,它不會被外部方法所引用(無法逃逸),那么這樣的對象會被在棧中分配,因為該對象只是在當前方法中使用,如下:
public void jjjj(){for(... : 50000){xxx();}
}
public void xxx() {User user = new User(); //棧上分配user.name = "zhangsan";user.age = 18;//to do something
}
當然逃逸分析技術屬于JIT的優化技術,所以必須要符合熱點代碼,JIT才會優化,另外對象如果要分配到棧上,需要將對象拆分(大對象放不下需要拆解),這種編譯優化就叫做標量替換技術。
也就是說:要滿足棧中分配需要滿足2個條件,一是熱點代碼 ,而是標量替換。
- -XX:+DoEscapeAnalysis開啟逃逸分析(jdk1.8默認開啟)
- -XX:-DoEscapeAnalysis 關閉逃逸分析
- -XX:+EliminateAllocations開啟標量替換(jdk1.8默認開啟)
- -XX:-EliminateAllocations 關閉標量替換
三.總結
本篇文章介紹了JVM的JIT即時編譯器,它解決了解釋器在逐行解釋性能差的問題,它通過對熱點代碼的探測,將熱點代碼編譯后進行緩存,從而提高程序的執行性能。
而JIT的優化技術除了熱點代碼編譯緩存外,還提供了方法內聯,鎖消除,逃逸分析等手段來提高程序性能。
文章結束喜歡的話請給個好評!!!