1. 程式人生 > >Java-JVM-編譯原理

Java-JVM-編譯原理

Java-JVM-編譯原理

轉載宣告:

本文大量內容系轉載自以下文章,並參考其他文件資料加入了一些內容:

摘要

我們可以通過javac命令將Java程式的原始碼編譯成Java位元組碼,即我們常說的class檔案。這是我們通常意義上理解的編譯。但是,位元組碼並不是機器語言,要想讓機器能夠執行,還需要把位元組碼翻譯成機器指令。這個過程是Java虛擬機器做的,這個過程也叫編譯。是更深層次的編譯。在編譯原理中,把原始碼翻譯成機器指令,一般要經過以下幾個重要步驟:

編譯流程

根據完成任務不同,可以將編譯器的組成部分劃分為前端(Front End)與後端(Back End):

  • 前端編譯主要指與源語言有關但與目標機無關的部分,包括詞法分析、語法分析、語義分析與中間程式碼生成。將.java檔案編譯成.class的編譯過程稱之為前端編譯。
  • 後端編譯主要指與目標機有關的部分,包括程式碼優化和目的碼生成等。將.class檔案翻譯成機器指令的編譯過程稱之為後端編譯。

如下圖所示,編譯器可以分為:前端編譯器、JIT 編譯器和AOT編譯器。下面我們逐個講解。
Java編譯器

0x01 前端編譯器:原始碼到位元組碼

1.1 基本概念

對於 Java 虛擬機器來說,其實際輸入的是位元組碼檔案,而不是 Java 檔案。JDK 的安裝目錄裡有 javac

工具,就是它將 Java 程式碼翻譯成位元組碼。相對於後面要講的其他編譯器,因為Javac處於編譯的前期,因此又被成為前端編譯器。
javac

1.2 例子

通過 javac 編譯器,我們可以很方便地將 java 原始檔翻譯成位元組碼檔案。就拿我們最熟悉的 Hello World 作為例子:

public class Demo{
   public static void main(String args[]){
        System.out.println("Hello World!");
   }
}

我們使用 javac 命令編譯上面這個類,便會生成一個 Demo.class 檔案:

javac Demo.java

我們使用純文字編輯器開啟 Demo.class 檔案,我們會發現是一連串的 16 進位制二進位制流。
class檔案

1.3 javac流程

執行 javac 命令的過程,其實就是 javac 編譯器解析 Java 原始碼,並生成位元組碼檔案的過程,可以分為下面四個階段:

  1. 詞法、語法分析
    JVM 對原始碼的字元進行一次掃描,經過詞法分析(原始碼CharStream轉為Tokens),語法分析,最終生成一個抽象的語法樹。語法樹每一個節點都代表程式碼中一個語法結構,如包、型別、運算子。
    語法樹
  2. 填充符號表
    符號表由一組符號地址和符號資訊構成,在編譯不同階段都需使用。語義分析中,符號表所登記的內容用於語義檢查和生成中間程式碼。
    我們知道類之間是會互相引用的,但在編譯階段,我們無法確定其具體的地址,所以我們會使用一個符號來替代。在這個階段做的就是類似的事情,即對抽象的類或介面進行符號填充。等到類載入階段,JVM 會將符號替換成具體的記憶體地址(解析階段,符號引用轉直接引用)。
  3. 註解處理
    在這個階段會對註解進行分析,根據註解的作用將其還原成具體的指令集。
    JDK1.6之前註解是在執行期起作用,JDK1.6提供了插入式註解處理器在編譯期處理註解,相當於編譯器外掛,會按需修改抽象語法樹,所以一旦修改了AST,會回到第一步重複前三步,直到註解處理不再修改AST。
  4. 語義分析
    語義分析(包括標註檢查和資料及控制流分析、解語法糖(泛型、自動裝拆箱等)等)。主要是對結構上正確的原始碼(語法分析階段確認)進行上下文檢查,如型別檢查(比如boolean b=false;char c=2;int d=b+c就有問題了)。最終得到標註了屬性的AST。
  5. 位元組碼生成
    javac 編譯的最後階段是位元組碼生成,JVM 便會根據上面幾個階段分析出來的結果(AST, 符號表等)轉換為位元組碼寫入磁碟,還會新增(如clinitinit(不包括已在填充符號表時已執行的預設構造方法)在這時被新增到AST中)和轉換(如將String的加轉為StringBuilder.append)少量程式碼。

我們一般稱 javac 編譯器為前端編譯器,因為其發生在整個編譯的前期。常見的前端編譯器有 Sun 的 javac,Eclipse JDT 的增量式編譯器(ECJ)。

0x02 JIT 編譯器:從位元組碼到機器碼

2.1 基本概念

2.1.1 兩種執行模式

當原始碼轉化為位元組碼之後,要執行程式有兩種選擇:

  • 使用 Java 直譯器來直接解釋執行位元組碼(基於棧的指令集)。
    首先java原始碼編譯(javac)稱為.class檔案,JVM的類載入器載入位元組碼到方法區後,JVM內建的解析器會解釋執行,一行一行到把位元組碼轉換為機器語言再執行
  • 使用 JIT 編譯器將位元組碼轉化為本地機器程式碼執行
    由於大部分程式都表現出“小部分熱點程式碼消耗大部分的資源”,這裡的熱點程式碼就是高頻率呼叫的程式碼塊,類似“二八定律”,於是引入了JIT(方法級),也就是動態編譯器,利用了在執行時進行熱點程式碼編譯的技術,直接將位元組碼編譯為本地機器碼,JIT會快取編譯過的程式碼到Code Cache裡(HotSpot在啟動時,會為所有位元組碼建立在目標平臺上執行的解釋執行的機器碼,並存放在CodeCache中,在解釋執行位元組碼的過程中,就會從CodeCache中取出這些本地機器碼並執行。),且之後無需重複解釋。且在此過程中,會有大量優化策略!

這兩種方式的區別在於,前者啟動速度快但執行速度慢(指令較多、基於記憶體是瓶頸速度慢於暫存器),而後者啟動速度慢但執行速度快。因為直譯器不需要像 JIT 編譯器一樣,將所有位元組碼都轉化為機器碼,自然就省去了優化的時間。而當 JIT 編譯器完成第一次編譯後,其會將位元組碼對應的機器碼儲存下來,下次可以直接使用。而我們知道,機器碼的執行效率肯定是高於 Java 直譯器的。且在JIT編譯過程中,會有大量優化策略!

2.1.2 直譯器和編譯器配合

  1. 當程式需要迅速啟動和執行的時候,解析器首先發揮作用,省去編譯的時間,立即執行。隨著時間的推移,編譯器發揮作用,把越來越多的程式碼編譯成原生代碼,獲得更高的執行效率。
  2. 當機器記憶體限制比較大,可以用解析方式節約記憶體,反之可以用編譯提升效率。
  3. 解析器還可以作為編譯器的“逃生門”。當例如載入了新類後型別結構發生變化,可以採用逆優化,退回到解析狀態繼續執行。

2.1.3 混合模式

所以在實際情況中,為了執行速度以及效率,我們通常採用直譯器和JIT相結合的方式(即混合模式)進行 Java 程式碼的編譯執行。
C1C2

2.2 C1與C2

2.2.1 簡介

在 HotSpot 虛擬機器內建了兩個即時編譯器,分別稱為 Client CompilerServer Compiler。這兩種不同的編譯器衍生出兩種不同的編譯模式,我們分別稱之為:C1 編譯模式C2 編譯模式

注意:現在許多人習慣上將 Client Compiler 稱為 C1 編譯器,將 Server Compiler 稱為 C2 編譯器,但在 Oracle 官方文件中將其描述為 compiler mode(編譯模式)。所以說 C1 編譯器、C2 編譯器只是我們自己的習慣性稱呼,並不是官方的說法。這點需要特別注意。

Compilation Modes

2.2.2 C1 和 C2 對比

  • C1 編譯模式會將位元組碼編譯為原生代碼,進行簡單、可靠的優化,如有必要將加入效能監控的邏輯。針對啟動效能有要求的客戶端GUI程式。
  • C2 編譯模式,也是將位元組碼編譯為原生代碼,但是會啟用一些編譯耗時較長的優化,甚至會根據效能監控資訊進行一些不可靠的激進優化。針對性能峰值。且在JDK1.7後,為了均衡啟動速度和執行效率,Server模式JVM採用了分層編譯為預設編譯策略,會根據編譯器編譯、優化的規模和耗時,劃分不同的編譯層次:
  1. 0層:程式直接解釋執行,
  2. 1層,即C1編譯。將位元組碼編譯為本地機器程式碼,
  3. 2層或2層以上:C2編譯。
    在分層編譯中,C0不需要再蒐集效能監控資訊。C1(更高編譯速度)和 C2同時工作(更好的編譯質量),程式碼可能多次編譯。

2.3 熱點程式碼

2.3.1 熱點程式碼分類

前面提到的會被JIT編譯的熱點程式碼有兩類:

  • 被多次呼叫的方法
    JIT會以整個方法作為編譯物件,該方式是JVM中標準的JIT編譯方式。
  • 被多次執行的迴圈體
    JIT會以整個方法而不是迴圈體作為編譯物件

2.3.2 探測方法分類

目前主要的熱點程式碼識別方式是熱點探測(Hot Spot Detection),有以下兩種:

  1. 基於取樣的方式探測(Sample Based Hot Spot Detection) :週期性檢測各個執行緒的棧頂,發現某個方法經常出險在棧頂,就認為是熱點方法。
    好處就是簡單;缺點就是無法精確確認一個方法的熱度。容易受執行緒阻塞或別的原因干擾熱點探測。
  2. 基於計數器的熱點探測(Counter Based Hot Spot Detection)。採用這種方法的虛擬機器會為每個方法(甚至是程式碼塊)建立計數器,統計方法的執行次數,某個方法超過閥值就認為是熱點方法,觸發JIT編譯。
    好處是統計結果精確;缺點是這種方式需為每個方法維護一個計數器,且無法直接獲取方法間呼叫關係

2.3.3 熱點探測計數器

HotSpot使用基於計數器的熱點探測方法,為每個方法準備兩個計數器。他們都會先檢視是否存在已編譯版本,如果有就優先執行已編譯的原生代碼。否則計數器加一,然後判斷兩個計數器之和超過閾值就觸發JIT編譯,否則以解釋方式繼續執行:

  • 方法計數器。記錄方法被呼叫次數。

  • 回邊計數器。是記錄方法中的for或者while的執行次數的計數器。

  • 關於OSR棧上替換
    在回邊計數器中,編譯動作由迴圈體觸發,編譯器會以整個方法作為編譯物件,也就是說會在方法執行過程中進行編譯。那麼就會發生方法棧幀還在棧內,方法就被替換了,即所謂OSR棧上替換。

2.3.4 解釋、編譯和阻塞

在觸發編譯時,執行引擎不會等待編譯完成而是再繼續解釋執行位元組碼,直到編譯完成,將方法的呼叫入口地址直接替換為新的編譯後的程式碼地址。以後呼叫就可以都用已編譯版本。

2.4 JIT編譯優化

JIT除了具有快取的功能外,還會對程式碼做各種優化。典型的有:
逃逸分析、 公共子表示式消除、方法內聯、 陣列邊界檢查消除、鎖消除鎖粗化

0x03 AOT 編譯器:原始碼直接到機器碼

AOT 編譯器的基本思想是:在程式執行前將原始碼直接生成 Java 方法的原生代碼,以便在程式執行時直接使用原生代碼。

但是 Java 語言本身的動態特性帶來了額外的複雜性,影響了 Java 程式靜態編譯程式碼的質量。例如 Java 語言中的動態類載入,因為 AOT 是在程式執行前編譯的,所以無法獲知這一資訊,所以會導致一些問題的產生。類似的問題還有很多,這裡就不一一舉例了。

總的來說,AOT 編譯器從編譯質量上來看,肯定比不上 JIT 編譯器。其存在的目的在於避免 JIT 編譯器的執行時效能消耗或記憶體消耗,或者避免解釋程式的早期效能開銷。

在執行速度上來說,AOT 編譯器編譯出來的程式碼比 JIT 編譯出來的慢,但是比解釋執行的快。而編譯時間上,AOT 也是一箇中等的速度。所以說,AOT 編譯器的存在是 JVM 犧牲質量換取效能的一種策略。就如 JVM 其執行模式中選擇 Mixed 混合模式一樣,使用 C1 編譯模式只進行簡單的優化,而 C2 編譯模式則進行較為激進的優化。充分利用兩種模式的優點,從而達到最優的執行效率。

0x04 思考

  • 為什麼Java不直接解釋執行原始碼?
  1. 使用位元組碼,可以避免每次執行時詞法、語法、語義分析之類的重複性工作。
  2. 位元組碼更便於虛擬機器讀取,不用在解析字串,所以執行速度比直接解析原始碼快。
  3. 語法是會變的,而原始碼中沒有版本資訊,而位元組碼中不但有版本資訊,還可以經由編譯過程抹平一些語言層面的變化(即語言語法雖然有變化,但位元組碼依然遵照原來的規則即可)。
  4. 位元組碼也可以由其他語言生成,如Groovy,Clojure,Scala。需要注意的事,既然這些語言可以編譯成位元組碼,也就可以被Java或其他JVM語言呼叫。
  5. 位元組碼比原始碼更加緊湊,檔案尺寸更小,方便網路傳輸。
  6. 有些嵌入裝置,不夠資源跑起完整的編譯器,這些裝置只需要嵌入一個小巧的JVM就行了,在額外的平臺上編譯原始碼。

0x05 直譯器FAQ

  1. 直譯器是一個轉換高階語言原始碼到機器編碼的程式?
    錯誤。
    這是編譯器乾的事。直譯器用來解釋執行非原生代碼(如Java的位元組碼)
  2. Java直譯器的輸入是二進位制程式碼(前端編譯中由java編譯器將原始碼編譯為二進位制位元組碼)?
    正確
  3. Java直譯器是JVM的一部分,他執行在JVM中,所以直譯器將生成由JVM執行的程式碼?
    錯誤
    位元組碼直譯器是JVM的一部分,但是並不執行在JVM中。而且位元組碼直譯器不產出任何東西,而是直接解釋執行位元組碼
  4. 直譯器用位元組碼生成中間程式碼和目標機器程式碼,並提交給JVM?
    錯誤
    以上工作是JVM做的事情
  5. JVM輪流在實現或執行JVM的OS平臺上執行該程式碼?
    錯誤
    JVM使用位元組碼、優化後的使用者程式碼、包含java lib和原生代碼的java內庫,以及OS呼叫來執行java應用程式。

0x06 總結

使用直譯器實現的程式語言實現裡,通常:
- 至少會在解釋執行前做完語法分析,然後通過樹直譯器來實現解釋執行;
- 兼顧易於實現、跨平臺、執行效率這幾點,會選擇使用位元組碼直譯器實現解釋執行

在 JVM 中有三個非常重要的編譯器,它們分別是:前端編譯器、JIT 編譯器、AOT 編譯器。

  • 前端編譯器,最常見的就是我們的 javac 編譯器,其將 Java 原始碼編譯為 Java 位元組碼檔案。

  • JIT 即時編譯器,最常見的是 HotSpot 虛擬機器中的 Client Compiler 和 Server Compiler,其將 Java 位元組碼編譯為本地機器程式碼。

  • 而 AOT 編譯器則能將原始碼直接編譯為本地機器碼。

  • 這三種編譯器的編譯速度和編譯質量如下:

  • 編譯速度上,解釋執行 > AOT 編譯器 > JIT 編譯器。

  • 編譯質量上,JIT 編譯器 > AOT 編譯器 > 解釋執行。

  • 而在 JVM 中,通過這幾種不同方式的配合,使得 JVM 的編譯質量和執行速度達到最優的狀態。

參考文件

《深入理解Java虛擬機器》

對java平臺的理解、java是解釋執行嗎?

Java為什麼解釋執行時不直接解釋原始碼?

虛擬機器隨談(一):直譯器,樹遍歷直譯器,基於棧與基於暫存器,大雜燴

HotSpot模板直譯器目的碼生成過程原始碼分析

How exactly does the Java interpreter or any interpreter work?