天天看點

深入Java虛拟機:Javac編譯與JIT編譯

編譯過程

    不論是實體機還是虛拟機,大部分的程式代碼從開始編譯到最終轉化成實體機的目标代碼或虛拟機能執行的指令集之前,都會按照如下圖所示的各個步驟進行:

    其中綠色的子產品可以選擇性實作。很容易看出,上圖中間的那條分支是解釋執行的過程(即一條位元組碼一條位元組碼地解釋執行,如JavaScript),而下面的那條分支就是傳統編譯原理中從源代碼到目标機器代碼的生成過程。

    如今,基于實體機、虛拟機等的語言,大多都遵循這種基于現代經典編譯原理的思路,在執行前先對程式源碼進行詞法解析和文法解析處理,把源碼轉化為抽象文法樹。對于一門具體語言的實作來說,詞法和文法分析乃至後面的優化器和目标代碼生成器都可以選擇獨立于執行引擎,形成一個完整意義的編譯器去實作,這類代表是C/C++語言。也可以把抽象文法樹或指令流之前的步驟實作一個半獨立的編譯器,這類代表是Java語言。又或者可以把這些步驟和執行引擎全部集中在一起實作,如大多數的JavaScript執行器。

Javac編譯

   在Java中提到“編譯”,自然很容易想到Javac編譯器将*.java檔案編譯成為*.class檔案的過程,這裡的Javac編譯器稱為前端編譯器,其他的前端編譯器還有諸如Eclipse JDT中的增量式編譯器ECJ等。相對應的還有後端編譯器,它在程式運作期間将位元組碼轉變成機器碼(現在的Java程式在運作時基本都是解釋執行加編譯執行),如HotSpot虛拟機自帶的JIT(Just In Time Compiler)編譯器(分Client端和Server端)。另外,有時候還有可能會碰到靜态提前編譯器(AOT,Ahead Of Time Compiler)直接把*.java檔案編譯成本地機器代碼,如GCJ、Excelsior JET等,這類編譯器我們應該比較少遇到。

    下面簡要說下Javac編譯(前端編譯)的過程。

    詞法、文法分析

    詞法分析是将源代碼的字元流轉變為标記(Token)集合。單個字元是程式編寫過程中的的最小元素,而标記則是編譯過程的最小元素,關鍵字、變量名、字面量、運算符等都可以成為标記,比如整型标志int由三個字元構成,但是它隻是一個标記,不可拆分。

    文法分析是根據Token序列來構造抽象文法樹的過程。抽象文法樹是一種用來描述程式代碼文法結構的樹形表示方式,文法樹的每一個節點都代表着程式代碼中的一個文法結構,如bao、類型、修飾符、運算符等。經過這個步驟後,編譯器就基本不會再對源碼檔案進行操作了,後續的操作都建立在抽象文法樹之上。

    填充符号表

    完成了文法分析和詞法分析之後,下一步就是填充符号表的過程。符号表是由一組符号位址和符号資訊構成的表格。符号表中所登記的資訊在編譯的不同階段都要用到,在語義分析(後面的步驟)中,符号表所登記的内容将用于語義檢查和産生中間代碼,在目标代碼生成階段,黨對符号名進行位址配置設定時,符号表是位址配置設定的依據。

    語義分析

     文法樹能表示一個結構正确的源程式的抽象,但無法保證源程式是符合邏輯的。而語義分析的主要任務是讀結構上正确的源程式進行上下文有關性質的審查。語義分析過程分為标注檢查和資料及控制流分析兩個步驟:

· 标注檢查步驟檢查的内容包括諸如變量使用前是否已被聲明、變量和指派之間的資料類型是否比對等。

· 資料及控制流分析是對程式上下文邏輯更進一步的驗證,它可以檢查出諸如程式局部變量在使用前是否有指派、方法的每條路徑是否都有傳回值、是否所有的受查異常都被正确處理了等問題。

    位元組碼生成

    位元組碼生成是Javac編譯過程的最後一個階段。位元組碼生成階段不僅僅是把前面各個步驟所生成的資訊轉化成位元組碼寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作。 執行個體構造器<init>()方法和類構造器<clinit>()方法就是在這個階段添加到文法樹之中的(這裡的執行個體構造器并不是指預設的構造函數,而是指我們自己重載的構造函數,如果使用者代碼中沒有提供任何構造函數,那編譯器會自動添加一個沒有參數、通路權限與目前類一緻的預設構造函數,這個工作在填充符号表階段就已經完成了)。

JIT編譯

    Java程式最初是僅僅通過解釋器解釋執行的,即對位元組碼逐條解釋執行,這種方式的執行速度相對會比較慢,尤其當某個方法或代碼塊運作的特别頻繁時,這種方式的執行效率就顯得很低。于是後來在虛拟機中引入了JIT編譯器(即時編譯器),當虛拟機發現某個方法或代碼塊運作特别頻繁時,就會把這些代碼認定為“Hot Spot Code”(熱點代碼),為了提高熱點代碼的執行效率,在運作時,虛拟機将會把這些代碼編譯成與本地平台相關的機器碼,并進行各層次的優化,完成這項任務的正是JIT編譯器。

    現在主流的商用虛拟機(如Sun HotSpot、IBM J9)中幾乎都同時包含解釋器和編譯器(三大商用虛拟機之一的JRockit是個例外,它内部沒有解釋器,是以會有啟動相應時間長之類的缺點,但它主要是面向服務端的應用,這類應用一般不會重點關注啟動時間)。二者各有優勢:當程式需要迅速啟動和執行時,解釋器可以首先發揮作用,省去編譯的時間,立即執行;當程式運作後,随着時間的推移,編譯器逐漸會傳回作用,把越來越多的代碼編譯成本地代碼後,可以擷取更高的執行效率。解釋執行可以節約記憶體,而編譯執行可以提升效率。

    HotSpot虛拟機中内置了兩個JIT編譯器:Client Complier和Server Complier,分别用在用戶端和服務端,目前主流的HotSpot虛拟機中預設是采用解釋器與其中一個編譯器直接配合的方式工作。

運作過程中會被即時編譯器編譯的“熱點代碼”有兩類:

· 被多次調用的方法。

· 被多次調用的循環體。

    兩種情況,編譯器都是以整個方法作為編譯對象,這種編譯也是虛拟機中标準的編譯方式。要知道一段代碼或方法是不是熱點代碼,是不是需要觸發即時編譯,需要進行Hot Spot Detection(熱點探測)。目前主要的熱點 判定方式有以下兩種:

· 基于采樣的熱點探測:采用這種方法的虛拟機會周期性地檢查各個線程的棧頂,如果發現某些方法經常出現在棧頂,那這段方法代碼就是“熱點代碼”。這種探測方法的好處是實作簡單高效,還可以很容易地擷取方法調用關系,缺點是很難精确地确認一個方法的熱度,容易因為受到線程阻塞或别的外界因素的影響而擾亂熱點探測。

· 基于計數器的熱點探測:采用這種方法的虛拟機會為每個方法,甚至是代碼塊建立計數器,統計方法的執行次數,如果執行次數超過一定的閥值,就認為它是“熱點方法”。這種統計方法實作複雜一些,需要為每個方法建立并維護計數器,而且不能直接擷取到方法的調用關系,但是它的統計結果相對更加精确嚴謹。

    在HotSpot虛拟機中使用的是第二種——基于計數器的熱點探測方法,是以它為每個方法準備了兩個計數器:方法調用計數器和回邊計數器。

    方法調用計數器用來統計方法調用的次數,在預設設定下,方法調用計數器統計的并不是方法被調用的絕對次數,而是一個相對的執行頻率,即一段時間内方法被調用的次數。

    回邊計數器用于統計一個方法中循環體代碼執行的次數(準确地說,應該是回邊的次數,因為并非所有的循環都是回邊),在位元組碼中遇到控制流向後跳轉的指令就稱為“回邊”。

    在确定虛拟機運作參數的前提下,這兩個計數器都有一個确定的閥值,當計數器的值超過了閥值,就會觸發JIT編譯。觸發了JIT編譯後,在預設設定下,執行引擎并不會同步等待編譯請求完成,而是繼續進入解釋器按照解釋方式執行位元組碼,直到送出的請求被編譯器編譯完成為止(編譯工作在背景線程中進行)。當編譯工作完成後,下一次調用該方法或代碼時,就會使用已編譯的版本。

    由于方法計數器觸發即時編譯的過程與回邊計數器觸發即時編譯的過程類似,是以這裡僅給出方法調用計數器觸發即時編譯的流程:

   Javac位元組碼編譯器與虛拟機内的JIT編譯器的執行過程合起來其實就等同于一個傳統的編譯器所執行的編譯過程。