Java虛擬機(七):編譯及優化

1 什么是編譯

“編譯”這個詞匯在各種關于編程語言的資料中都能看到,那究竟什么是編譯呢?簡單地說,編譯是一個行為,是一個將一種語言翻譯成另一種語言的行為,而實現這個行為的東西就是“編譯器”。例如,C語言編譯器會將C語言源代碼翻譯成匯編代碼,然后再由匯編器將匯編代碼翻譯成機器可以直接識別的機器代碼。

如果按照是否有編譯這個過程將編程語言分類的話,大致可以分為兩大類:編譯型語言和解釋型語言。編譯型語言的典型代表就是C和C++,解釋型語言的典型代表是Python和JavaScript。那么Java屬于哪一種類型呢?說實話,我不確定,因為Java先經過javac編譯后形成字節碼,然后JVM再解釋執行字節碼,從這個角度看,好像Java可以歸到解釋型語言,但由于Java中也存在JIT即時編譯機制,將字節碼編譯成機器碼,然后機器再執行機器碼,從這個角度看,好像又可以認為Java是編譯型的。但我個人更傾向于“Java”是編譯型語言,因為javac將Java源代碼編譯成了字節碼,字節碼和源代碼已經有非常大的不同了,換句話說,編譯的程度很深(而且javac編譯器也會包括編譯器該有的功能,例如詞法分析、語法分析、語義分析等),故我認為Java是編譯型語言。

在網上看到有一條啟發性原則用來判斷語言的類型:如果翻譯器對程序進行了徹底的分析而非某種機械的變換,而且生成的中間程序與源程序之間沒有很強的相似性,我們就認為這個語言是編譯的。徹底的分析和非平凡的變換,是編譯方式的標志性特征。

2 Java編譯器

Java大概有三類編譯器:前端編譯器、后端編譯器、靜態提前編譯器。

  • 前端編譯器。典型代表是javac,作用是將Java源代碼編譯成虛擬機可以識別的字節碼,通俗的說就是將.java文件轉換成.class文件(這個說法有些狹隘了,實際上,不一定就是.class文件,只要是虛擬機可執行的字節碼即可)。
  • 后端編譯器。即我們經常看到的“JIT”編譯器,典型代表是HotSpot的Client編譯器和Server編譯器,簡稱為C1和C2,作用就是將字節碼轉換成機器可識別的機器碼。
  • 靜態提前編譯器。典型代表是GNU Compiler for the Java,作用是在前端編譯器編譯之前,直接將java源代碼編譯成機器可識別的機器碼。

2.1 前端編譯器

我們通常所說的“編譯”大多少數時候都是指的“前端編譯”(僅限于Java領域)。前端編譯的主要工作是將java源碼編譯成字節碼,供虛擬機解釋執行,但虛擬機規范并沒有對具體的編譯過程做嚴格要求,這就導致了各個編譯器可能大相徑庭,對于Javac來說,大致可以分為3個過程,分別是:

  • 解析與填充符號表的過程。即解析Java源代碼,并將其中內容轉換成符合表示并插入符號表里,詳細的過程會在下面說到。
  • 插入式注解處理器的注解處理過程。Java5之后提供了注解,注解有可能會導致原代碼的部分邏輯發生改變,所以在這個過程會對注解做處理,并回到第一個過程,再次做解析與符號表填充。
  • 分析與字節碼的生成。即生成最終可被虛擬機識別并解釋執行的字節碼。

2.1.1 解析與符號表填充

解析包括詞法分析和語法分析,幾乎所有的編譯器都至少有這么兩步,通過這兩個步驟,會生成抽象語法樹,后續的過程都是基于抽象語法樹的,不會再直接對源代碼進行操作。

詞法分析及即將源代碼的字符流轉換成一個一個的Token,Token被定義成一個不可再拆分的元素。例如對于int a = 5;來說,int、a、=、5、;等都是Token,且Token并不一定是一個字符。詞法分析是基于Java語言的規范來進行的,這也就是為什么三個字符的int被認為是一個Token的原因。

語法分析會根據詞法分析得到的Token序列來構造抽象語法樹,抽象語法樹是一種樹形的數據結構,樹中的每個節點都代表程序代碼中的一個語法結構,例如包、類、修飾符、運算符等。

完成詞法和語法分析后就算是完成了解析過程,接下來就是符號表的填充了。符號表是一組符號和符號地址組成的表,可以理解成Hash表,但實際上不一定就是Hash表的格式,也有可能是樹形或者其他形式,只要能表示符號和符號地址的映射關系即可,符號表的信息在后面的各個階段都有可能用到,例如在語義分析中,這些信息會被用來做語法檢查和生成中間代碼。

2.1.2 注解處理器

在編譯期對注解進行處理的過程中,可能會修改抽象語法樹的節點,所以在完成對注解的處理之后,還需要回到解析和符號表填充的過程,再次生成新的抽象語法樹,這樣一個循環可能會發生多次,直到注解處理器不會在修改抽象語法樹。

2.1.3 語義分析和字節碼生成

完成了上面的步驟之后,抽象語法樹的信息就不會再發生改變了,即此時的抽象語法樹是一個最終版本的抽象語法樹,但抽象語法樹只能表示程序是正確的,是符合語法要求的,但并不表示程序是符合邏輯的,所以還需要對其進行語義分析來確定程序是否符合邏輯,分析的項目大概有標注檢查、數據流和控制流分析等。這些檢查完成后會著手“解語法糖”,最后才會生成字節碼。

上面提到了“語法糖”這個東西,語法糖是用來方便程序員的,提高程序員的開發效率的。但本質上對程序性能上沒有什么增益。例如For-each循環在編譯后展開成以迭代器的方式遍歷,基本類型的自動裝箱和拆箱操作也會在編譯后展開成valueOf(),或者xxxValue()的方法調用。

2.1.4 前端編譯器的優化操作

前端編譯器也會做一些優化操作,但比起后端編譯器來說,優化的力度比較小。在這里簡單說一下兩個優化操作:常量折疊和條件編譯。

常量折疊是一個將常量簡化的優化操作。例如現在有如下代碼:

int a = 2 + 3;

編譯器如果有常量折疊這項優化的話,會將這條語句優化成下面這樣:

int a = 5;

這樣就可以讓JVM少執行一次加法指令,提高執行效率。

條件編譯即將一些條件判斷的步驟省略,讓JVM少執行一次條件判斷指令。例如:

public static void main(String[] args) {
    if (true) {
        System.out.println("block1");
    } else {
        System.out.println("block2")
    }
}

如果編譯器又這么一項優化的話,可能就會將代碼編譯成這樣:

public static void main(String[] args) {
    System.out.println("block1");
}

這樣就少了一個條件判斷的指令,執行效率就提高了。這種優化只會發生在條件變量是常量的時候才行,如果條件變量可能發生改變,那么編譯器就不會做這項優化。

2.2 后端編譯器

在Java中,我們通常所說的后端編譯就是說的JIT(即時編譯)。在HotSpot虛擬機中,有兩個即時編譯器,即Client Compiler和Server Compiler,簡稱為C1和C2,這兩種編譯器對應著虛擬機的運行模式,如果虛擬機的運行模式是Client,那么就使用C1,如果是Server模式,就使用C2,虛擬機的運行模式可以通過如下命令看到。

> java -v

2.2.1 編譯器和解釋器

自從有了JIT,Java代碼就不再全是又虛擬機解釋執行的了,而是一部分代碼繼續使用虛擬機解釋器解釋執行,一部分代碼被編譯為機器碼,機器直接執行機器碼。這樣可以發揮解釋器和編譯器的優勢,當程序需要快速啟動的時候,解釋器可以省去編譯過程(但之前講到的前端編譯生成字節碼的步驟仍然是必須的),直接解釋執行程序,當程序運行后,編譯器可以將一些“熱點代碼”編譯成機器碼,以提高運行時的執行效率。

解釋器還能作為編譯器的“逃生門”,當編譯失敗或者編譯后的代碼出現運行問題(這通常是因為編譯器“激進”的優化操作導致的)時可以進行“逆優化”操作,此時虛擬機將繼續以解釋的模式運行這部分代碼,這使得即使編譯失敗也不會突然導致運行中的應用程序崩潰。下面是解釋器和編譯器的交互示意圖:

由于即時編譯需要在程序運行時執行,必然會占用程序的資源,要編譯出優化程度高的代碼,需要的資源可能會很多。HotSpot虛擬機提供了分層編譯的策略來緩解這個問題,大致可分為3層:程序解釋執行,C1編譯、C2編譯。3層的優化程度以此遞增,需要占用的資源也以此遞增,但將原來所需要的更大的資源分為了三個部分,虛擬機完全可以在不同的時間段里執行三個過程,最終生成優化程度很高的代碼。

2.2.2 JIT的觸發條件

上面的討論中提到過一個“熱點代碼”的概念,虛擬機不會將所有的字節碼都編譯成機器碼(因為有些代碼可能僅僅會執行那么一次兩次,編譯這部分代碼有點得不償失),而僅僅將部分經常被使用的代碼編譯成機器碼,這部分代碼就稱作“熱點代碼”。

判斷一段代碼是不是熱點代碼,是不是需要進行即使編譯,這樣的過程稱作“熱點探測”。目前主流的熱點探測方法有兩種:

  • 基于采樣的熱點探測。使用這種方式的虛擬機會周期性的檢查棧頂,如果發現某個方法經常出現在棧頂,那這個方法就被判斷為熱點代碼。這種方式的好處是實現簡單、高效,還可以通過展開棧來獲得調用關系,缺點就是結果可能不準確,容易受到例如線程阻塞或者外部因素的干擾。
  • 基于計數器的熱點探測。使用這種方式的虛擬機會為每個方法創建計數器,當方法被調用的時候,對應的計數器就加1,當計數器的值達到某個閾值的時候,就會判斷該方法為熱點方代碼,可以對其觸發即使編譯。這樣的好處是結果準確,但無法獲得方法的調用關系。

HotSpot虛擬機采用的是第二種方法,它為每個方法準備了兩個計數器:方法調用計數器和回邊計數器。

  • 方法調用計數器。每當方法被調用的時候,就加+1。
  • 回邊計數器。作用是統計一個方法體里的循環體的執行次數。

這兩個計數器的閾值并不一樣,只要有一個計數器達到閾值,就會觸發即時編譯,而且都會編譯整個方法,即使僅僅因為里面的循環體是熱點代碼。

即時編譯和解釋執行時可以并發執行的,即編譯還沒完成的時候,解釋器仍然以解釋執行的方式執行代碼,當編譯完成后再選擇運行編譯后的代碼。下圖是方法調用計數器觸發即時編譯的流程圖(回邊計數器觸發的流程也相差不多):

關于JIT具體編譯的過程和細節就不多說了,書上寫得很詳細(但也比較晦澀),建議看看書上的第11章。

3 編譯優化技術

HotSpot虛擬機在即時編譯方面有很多優化技術,其中也有不少經典的優化技術,例如常量折疊、條件編譯等,也有一些針對Java的優化技術,例如棧上替換,逃逸分析等。下面介紹幾種比較具有代表性的優化技術。

3.1 公共子表達式消除

這是一種普遍的優化技術,他的描述是這樣的:如果一個表達式E已經計算過了,并且從先前到現在E都沒有發生過改變,那么E的這次出現就成為了公共子表達式,對于這種表達式就沒必要花費時間再次計算了,只需要用先前計算的結果替代即可。假設有如下代碼:

int d = (c * b) * 12 + a + (a + b * c);

其中cxb和bxc是等效的,故將其看做E,編譯器就可以做出類似下面的優化。

int d = E * 12 + a + (a + E);

甚至如果編譯器還有“代數化簡”的優化項目的話,可能會變成下面這樣:

int d = E * 13 + a * 2;

這樣一來,原來至少有6個算數運算以及若干個括號相關、若干個算法運算法則相關的壓棧和出棧操作變成了只有3個算數運算操作,最終效率就變高。

3.2 數組邊界檢查消除

Java在對數組訪問的時候會對數組邊界進行檢查,如果發生數組越界了,會拋出java.lang.ArrayIndexOutOfBoudnsException異常,而不會像C/C++那樣要么出現Segment Falut,要么就會出現亂碼,這一點對程序員來說是一件很好的事情,即使程序員沒有專門編寫防御性代碼,也可以避免很多內存溢出攻擊,但正是由于多了邊界檢查,所以程序的運行效率肯定也會受到影響。

為了降低數組邊界檢查的影響,編譯器可能會進行“數組邊界檢查消除”的優化,注意,這個優化并不是完全將數組邊界檢查這個特性拋棄,而是對沒有必要進行數組邊界檢查的數組訪問操作進行檢查消除。例如對于數組A進行A[3]的訪問操作,如果能在編譯期根據數據流分析得到A.length的值,并判斷出3小于A.length,那么在執行訪問操作的時候就不需要對數組邊界進行檢查了。再舉個例子,例如我們現在有如下代碼遍歷數組A:

for (int i = 0; i < A.length; i++) {
    System.out.println(A[i]);
}

這段代碼中,循環遍歷i的值完全可以在編譯期確定就在[0,A.lenght)這個范圍里,而這個范圍沒有發生數組越界,故編譯器可以對這段代碼做數組邊界檢查消除的優化,從而提高執行效率。

除了數組邊界優化之外,還有一種技術也可以降低隱式開銷:隱式異常處理。我們在編寫Java代碼的時候,經常需要處理異常,Java程序在運行時對異常的處理要做更多的事情(各種的檢查、判斷),所以,為了降低這種開銷,編譯器可能會對異常做一些“特殊處理”。假設有下面這樣的代碼:

public static void main(String[] args) {
    User user = getUser("yenono");
    System.out.println(user.getName());
}

虛擬機在執行的時候會對user對象做空值判斷,Java偽代碼如下所示:

if (user != null) {
    System.out.println(user.getName());
} else {
    throw new NullPointException();
}

如果編譯器有“隱式異常處理”這項優化的話,可能就會變成下面這樣(Java偽代碼):

try {
    System.out.println(user.getName());
} catch (segment_fault) {
    uncommon_trap();
}

比起原來的代碼,少了判斷過程,直接就對對象進行操作了。當確實發生異常的時候,就不得不從用戶態陷入到內核態去處理該異常,處理完成之后再回到用戶態繼續執行,這個過程的效率遠遠比一次空值判斷低得多。所以,當很少發生異常的情況下,應用程序能從這項優化中獲益,但如果經常發生異常,這樣的優化反而會使得應用程序的效率更低,不過好在虛擬機足夠智能,可以通過運行時的各種信息來自動的選擇最好的方案。

這里提到了異常會陷入內核態,這是因為虛擬機會在操作系統中注冊一個segment_fault異常,注冊完畢后,該異常就屬于操作系統異常了,當虛擬機捕獲到這個異常的時候,就會發生中斷陷入到內核態中對異常進行處理,處理完畢后又從內核態切換回用戶態繼續執行后面的邏輯。

3.3 方法內聯

學過C++的朋友應該都接觸過“內聯函數”,即那些有inline關鍵字標識的函數。在C++中,函數內聯可以簡單理解成將整個函數當做一個代碼塊,當其他函數調用的時候,就直接把這個代碼塊復制到調用該函數的地方,最終的效果就是沒有發生函數調用,也就是少了一次函數的入棧和出棧操作,這樣的做法對性能的增益確實挺大的,尤其是對C++這種靜態編譯的語言,方法內聯的過程完全可以在編譯期就完成了,在運行時就不會再做復制代碼的操作了。

在Java中,無法進行如此直接的方法內聯,因為Java的多態實在運行時實現的,不像C++那樣在編譯期就確定了虛函數表以及C++對象的虛函數指針。所以java在編譯期進行方法內聯幾乎無法完成,因為根本無法確定最終調用的方法是哪一個版本,不過對于有fianl修飾的方法(無法被重寫),倒是可以進行方法內聯,但總不可能為了性能,到處使用final修飾方法吧。那Java究竟是如何實現方法內聯優化的呢?虛擬機設計團隊引入了一種“類型繼承關系分析(Class Hierarchy Analysis,CHA)”的技術,說實話,這個技術我完全沒弄明白,所以在這里就不多說了,要想細致了解的,建議看看《深入理解Java虛擬機》中11.3.4節的內容。

3.4 逃逸分析

逃逸分析不是直接的優化技術,而是為其他優化提供依據的分析技術。那什么是逃逸呢?當在一個方法里創建了一個對象,然后再在該方法里調用其他方法并將對象作為參數傳遞到被調用方法里,這個就是對象“逃逸”了,這種方式的逃逸稱作方法逃逸。在多線并發的環境中,還有線程逃逸的說法,那指的是某個對象被其他線程訪問到,詳細的可以看看《Java并發編程》中線程安全那一章節。

如果用逃逸分析技術分析某個對象不會發生逃逸,那么就可以不將對象分配到堆里,而是在棧上分配對象,反正這個對象又不會被其他方法調用到,僅在本方法里使用。在棧上分配的好處是線程安全、不需要垃圾回收和可以進行標量替換:

  • 線程安全。因為棧是線程私有的,對象分配在棧上了,自然就不可能被其他線程共享了,也就不會有線程安全問題了。
  • 不需要垃圾回收。當方法棧幀出棧以后,這部分棧內存就自動釋放了,自然不需要進行垃圾回收了,這就減輕了垃圾回收的壓力。
  • 標量替換。標量是指一個數據已經無法再分解成更小的數據類型來表示了,例如基本數據類型int,long以及引用類型等,相應的,一個數據如果能繼續分解成更小的數據類型,那就稱作聚合量,例如Java對象。根據對象的訪問狀況將其成員變量恢復原始類型的訪問就叫做標量替換。例如某方法里用到一個user對象,而且方法里僅僅方法了user對象的name,和age字段,那么編譯器就可以對其做一個標量替換,直接為name和age創建兩個局部變量,而不需要再創建user對象了,減少了創建對象的開銷。

逃逸分析是一個比較前沿的技術,在JDK1.6中才有實現,到現如今也并不是很成熟。原因就是因為無法保證逃逸分析帶來的性能提升大于逃逸分析本身的消耗,畢竟逃逸分析是一個很復雜的過程,也是非常耗時的。一種比較極端的情況就是,經過一頓復雜的分析,最后發現該方法里的所有對象都會逃逸,這樣基于逃逸分析的優化操作就無法做了,白白浪費時間來做這這些分析。

4 小結

本文簡單介紹了前端編譯器和后端編譯器,最后還說了幾個具有代表性的編譯優化技術。編譯器和編譯優化技術看起來距離我們很遠(對于普通的應用開發者),但理解它們是絕對有益處的,例如我知道了編譯器會幫我將公共子表達式消除了,我在編寫代碼的時候就不需要老關注是否存在公共子表達式了(因為如果邏輯比較復雜的話,這個過程是非常煩人的),提升了開發效率并且對程序執行效率沒有影響。記得知乎上曾經有過一個問題:C/C++的i++和++i的寫法性能上有什么差別?看過《CSAPP》的朋友應該知道++i的寫法效率上會比較好(具體原因在這里就不多說了),但實際上呢?編譯器會給我們優化!所以編譯后這兩種寫法沒有區別!如果了解編譯器的優化技術的話,在實際開發中,就不用糾結這樣的問題了,根據公司的代碼風格使用其中一種就行了(最好不要混搭,否則太混亂了)。

5 參考資料

《深入理解Java虛擬機》

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 此文為我在學習《深入理解Java虛擬機:JVM高級特性與最佳實踐》時所做的筆記,把我認為是重點、面試時可能會被問到...
    CyanStone閱讀 1,168評論 0 3
  • 如果云知道怎么她不逃逃之夭夭我沉默良久絕口不提你是怎樣消失掉 如果風知道怎么她不逃赤條條一個漢子就這樣備受煎熬我絕...
    墨上城閱讀 1,062評論 21 28
  • 朋友,你也許,現在并不喜歡我這樣稱呼你了。 因為我們,從今夜起,好像就這樣散了。 為什么呢?為什么你走的時候,連一...
    背景墻閱讀 280評論 0 0
  • 劉小澤寫于18.11.25每學習一遍之前的知識,總能從不同角度獲取一些觀點,然后擴增自己的知識庫,這就是“知識迭代...
    劉小澤閱讀 11,902評論 6 52
  • 采購家庭食材的一天 提前一天,與家庭成員預約次日行程,不拖沓按計劃執行,明確行動的分工,并相互協助。 剩下的就是...
    雅樂人閱讀 794評論 0 1