JVM程序編譯與代碼優化(JIT)

編譯期優化是什麼?

Java語言的編譯期是一段操做過程,具體的能夠分爲三類:前端

  • Javac(前端靜態編譯器):把*.java編譯爲*.class文件
  • JIT編譯器(後端運行編譯器):把*.class文件轉變成機器碼的過程
  • AOT編譯器(靜態提早編譯器):把*.java文件編譯成本地機器碼的過程

因此編譯期優化是上者三者共同作出的貢獻。java

源代碼,字節碼,機器碼,本地代碼?編程

源文件就是.java文件。字節碼就是.class文件。機器碼和本地代碼是計算機可以直接識別運行的代碼,就是機器指令。衆所周知,java的特色之一就是跨平臺性,跨平臺的結果是運行效率慢,JVM爲了增快速度,將某些代碼會編譯成機器碼,以此提升運行效率。後端

Javac編譯器

我以前作過一篇關於javac編譯器的博客,較爲詳細的講了Javac編譯的各步驟及做用,若是看過那篇文章的小夥伴們就知道Javac對代碼的運行效率幾乎沒有優化措施,可是,有一些「語法糖」是靠javac編譯器實現的,例如foreach語法、註解等。數組

那麼關於Javac編譯器的部分能夠去看以前的博客,這裏就不耽誤你們時間了。緩存

JIT編譯器(即時編譯器)

舉個JIT編譯器優化的例子,當虛擬機發現某個方法被頻繁運行時(或一個屢次執行的循環體),就會把這些代碼認定爲「熱點代碼」,爲提升效率,運行時,就會把這些代碼編譯成與本地平臺相關的機器碼,而完成這個任務的,就是JIT編譯器。安全

能夠經過java -verison來查看本身的JIT模式,如圖,個人是Server模式,而且採用的mixed mode。下面來解釋一下什麼是mixed mode和Server,先來分析mixed mode。 bash

image

解釋器和編譯器

HotSpot虛擬機採用解釋器和編譯器的架構。架構

解釋器

這裏的解釋器做用就是將字節碼一條一條翻譯爲機器碼,它的特色是當即執行,節約內存函數

編譯器

它的做用是把源程序的每一條語句都編譯成機器語言,並保存爲二進制文件。

那麼爲何要同時使用解釋器和編譯器呢?由於解釋器很慢,但節約內存;而編譯器編譯成本地代碼後執行效率更高。

還有一點,當編譯器使用激進優化不成立時(即優化事後發現並無起到優化做用),例如加載了新類後繼承結構變化,這時能夠逆優化退回到解釋狀態繼續執行。那麼這裏的mixed mode也就是解釋器和編譯器組合的混合模式了。

JIT編譯器分類

JIT編譯器能夠分爲兩類,Client和Servcer又名C1,C2。

C1編譯器將字節碼編譯爲本地代碼,進行簡單可靠的優化。C2編譯器則會啓動一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。

在jdk1.8以後,引入了分層編譯的策略,在運行初期開啓C1編譯器編譯,隨時間的推移執行頻率高的代碼會再次被C2編譯器編譯:

  • 第0層:程序解釋執行,解釋器不開啓性能監控功能,
  • 第1層,C1編譯,將字節碼編譯爲本地代碼,進行簡單、可靠的優化,有必要的話加入性能監控的邏輯
  • 第2層,C2編譯,將字節碼編譯爲本地代碼,會啓用一些編譯耗時長的優化,甚至激進優化。

JIT優化的對象和觸發條件

JIT編譯的熱點代碼有兩類

  • 屢次調用的方法
  • 屢次執行的循環體

那麼這個屢次是多少次呢?這就須要進行熱點探測。目前主要的熱點探測方式有如下兩種:

(1)基於採樣的熱點探測

採用這種方法的虛擬機會週期性地檢查各個線程的棧頂,若是發現某些方法常常出如今棧頂,那這個方法就是「熱點方法」。這種探測方法的好處是實現簡單高效,還能夠很容易地獲取方法調用關係(將調用堆棧展開便可),缺點是很難精確地確認一個方法的熱度,容易由於受到線程阻塞或別的外界因素的影響而擾亂熱點探測。

(2)基於計數器的熱點探測

採用這種方法的虛擬機會爲每一個方法(甚至是代碼塊)創建計數器,統計方法的執行次數,若是執行次數超過必定的閥值,就認爲它是「熱點方法」。這種統計方法實現複雜一些,須要爲每一個方法創建並維護計數器,並且不能直接獲取到方法的調用關係,可是它的統計結果相對更加精確嚴謹。

HotSpot採用基於計數器的熱點探測方法,他爲每一個方法準備了方法調用計數器和回邊計數器:

方法調用計數器統計的並非方法被調用的絕對次數,而是一個相對的執行頻率,即一段時間內方法被調用的次數。當超過必定的時間限度,若是方法調用次數仍然不足以讓它提交給即時編譯器,那這個方法的調用計數器就會衰減通常,這個過程稱爲方法調用計數器熱度的衰減。

回邊計數器統計的是一個方法中循環體代碼執行的次數,它沒有熱度衰減。創建回邊計數器統計的目的是爲了處罰OSR編譯,OSR即棧上替換,也就是編譯發生在方法執行過程之中。

OSR編譯:某段循環執行的代碼還在不停的循環中,若是在某次循環以後,計數器達到了某一閾值,這時JVM已經認定這段代碼是熱點代碼,此時編譯器會將這段代碼編譯成機器語言並緩存後,可是這段循環仍在執行,JVM就會把執行代碼替換掉,那麼等循環到下一次時,就會直接執行緩存的編譯代碼,而不須要必須等到循環結束才進行替換,這個就是所謂的棧上替換

優化技術

下面來看一下JIT生成代碼時的代碼優化技術:

語言無關的優化技術之一:公共子表達式消除

他的意思是若是一個表達式E已經計算過了,而且從先前的計算到如今E中全部變量的值都沒有發生變化,那麼E的此次出現就成爲了公共子表達式。

int d = (c * b) * 12 + a + (a + b * c);
//優化爲:
int d = E * 12 + a + (a+E);
複製代碼

語言相關的優化技術之一:數組邊界檢查消除

咱們知道Java是動態安全的語言,若是訪問一個超出數組邊界的元素會拋出異常,若是沒有優化,那麼每一次對數組元素的讀寫都會進行判斷是否越界,這顯然是很消耗性能的。但毫無疑問的是數組邊界檢查是必須作的。

//1.若是數組下標是常量
array[3]
//編譯器根據數據流分析肯定foo.length的值,並判斷下標"3"沒有越界,執行的時候就無須判斷了

//2.數組下標是循環變量
for(int i...)
    array[i]
/**若是編譯器經過數據流分析就能夠斷定循環變量"0<=i< foo.length",
那在整個循環中就能夠把數組的上下界檢查消除掉,這能夠節省不少次的條件判斷操做。*/
複製代碼

最重要的優化技術之一:方法內聯

方法內聯能夠理解爲將目標方法的代碼「複製」到發起調用的方法之中,避免真實的方法調用。但實際上,咱們平時所說的面向接口編程,會使用多態等特性,而多態是要在運行時才能斷定到底使用哪一個方法的(實際上Java的默認實例方法是虛方法,而虛方法作內聯時根本沒法肯定使用哪一個版本),因此咱們就能夠知道要達到方法內聯的條件是比較苛刻的。

那麼,方法內聯以後可以進行哪些優化呢?

  • 去除方法調用的成本
  • 爲其餘優化創建良好的基礎,方法內聯後能夠便於在更大範圍上採起後續的優化手段

下面舉個例子:

//內聯前的代碼
static class B{
    int value;
    final int get(){
        return value;
    }
}

public void foo(){
    y = b.get();
    z = b.get();
    sum = y + z;
}
複製代碼
//內聯後的代碼
public void foo(){
    y = b.value;
    z = b.value;
    sum = y + z;
}
複製代碼

內聯後採起的其餘優化

//冗餘訪問消除
public void foo(){
    y = b.value;
    z = y;
    sum = y + z;
}
複製代碼
//複寫傳播
public void foo(){
    y = b.value;
    y = y;
    sum = y + y;
}
複製代碼
//無用代碼消除
public void foo(){
    y = b.value;
    sum = y + y;
}
複製代碼
哪些方法能夠內聯呢?

從字節碼的角度來看的話,只有使用invokespecial指令調用的私有方法、實力構造器、父類方法;以及invokestatic指令進行調用的靜態方法纔是在編譯期進行解析的;另外final修飾的方法也是非虛方法。前文也說了虛方法沒法在編譯期肯定調用的具體方法,因此爲了解決虛方法的內聯問題,JVM設計師們引入了一個「類型繼承關係分析」(CHA)的技術。

內聯步驟:

  • 若是是非虛方法,直接內聯
  • 若是是虛方法,向CHA查詢
    • 若是查詢結果只有一個版本,也能夠進行內聯; 不過這屬於激進優化,須要預留一個"逃生門",稱爲守護內聯(Guarded Inlining); 由於運行加載類可能致使繼承關係發生變化,須要退回解釋執行,或從新編譯;
    • 若是查詢結果有多個版本目標,使用內聯緩存(Inline Cache)來完成方法內聯; 當緩存第一次調用方法接收者信息,之後每次調用都比較,不一致時取消內聯;

因此內聯優化不少狀況下是激進優化,須要有「逃生門」回到解釋狀態從新執行

最前沿的優化技術之一:逃逸分析

它的基本行爲就是分析對象狀態做用域:例如若是在一個方法內返回了一個方法內生成的新對象,若是被引用,這是方法逃逸;若是被外部線程訪問到,這是線程逃逸。

那麼JIT又是如何對逃逸分析進行優化的呢?

  • 棧上分配:通常來講,JVM都將對象建立在堆上,但若是肯定一個對象不會逃逸出方法外,那麼就將這個對象建立在棧上,隨着出棧死亡
  • 同步消除:若是肯定一個變量不會逃逸出線程,沒法被其餘線程訪問,那麼同步措施也能夠取消
  • 標量替換:若是一個對象不會被外部訪問,而且這個對象能夠被拆散,那程序執行時能夠不建立這個對象,而直接建立若干個被這個方法使用到的成員變量替代

事實上,逃逸分析還未成熟,緣由也很簡單,若是要判斷一個對象是否會逃逸,須要進行數據流的一系列分析,而這個逃逸分析帶來的消耗未必比逃逸分析帶來的優化小。

JIT總結

因此,綜上所述,JIT編譯器做用時間在程序運行時,做用將運行頻繁的代碼編譯爲本地代碼,因此由於JIT做用時間在運行時,因此他在優化性能的同時也讓java保持了跨平臺的特性

AOT編譯器

AOT是jdk9才引入的編譯方式,和JIT不一樣,AOT是在程序運行前進行的靜態編譯,那麼爲何有了JIT後還須要AOT呢?

  • 由於JIT是在運行時編譯的,因此要佔用運行時資源,而AOT在運行前編譯,能夠避免運行時的編譯消耗和內存消耗
  • JIT在編譯時要去識別代碼是否爲熱點代碼,這就須要佔用時間,結果就是讓初始編譯不能達到最高性能,但AOT在運行時就編譯好了,那麼再運行初期就達到最高性能

彷佛有一個問題,若是AOT的出現是由於JIT優化的時間在運行時,那麼爲何不直接在javac編譯階段優化呢,或者爲何不在編譯階段就優化完畢呢?

  • 一開始Sun/Oracle公司沒作這方面的打算(這應該也算一個緣由)
  • 某些優化只能在運行的時候作,由於java是一門動態類型的語言,前文也說了java大多數方法都是虛方法,徹底能夠在運行時經過類加載器來改變類的結構,這樣,例如逃逸分析這類優化就很難在編譯的時候進行

但事實上也有在靜態編譯期間將優化全作完的,例如Excelsior JET,意思就是能夠指定編譯模式爲不使用動態特性,那若是發生運行時類加載了一個新類,那麼就直接回退到從新編譯JIT就行了。因此,爲了java的動態性的實現,咱們很難在靜態編譯期就徹底實現優化。

總結

javac

  • 靜態編譯器,將.java編譯爲.class
  • 不作優化,語法糖在這個時候解除

JIT、AOT

  • JIT是動態編譯,JIT在運行時進行編譯,將熱點代碼編譯爲機器碼
  • JIT在判斷代碼是否爲熱點代碼採用的是採樣的熱點探測和計數器的熱點探測
  • HotSpot採用編譯器和解釋器並存的架構模式
  • JIT可以作公共子表達式消除、函數內聯、逃逸分析、數組邊界消除
  • 運行時佔用內存致使程序卡頓,編譯時還要判斷是否能夠優化佔時間

AOT

  • AOT在運行前將部分熱點代碼編譯爲機器碼
  • AOT是靜態編譯,運行前編譯,避免編譯消耗和內存消耗,顯著加快程序啓動,程序初期就是最高性能
  • 犧牲Java一致性

參考:《深刻理解Java虛擬機》《深刻分析JavaWeb》《逃逸分析爲何不在編譯時間運行》 https://www.zhihu.com/question/27963717/answer/38871719

相關文章
相關標籤/搜索