1. 程式人生 > >JVM理論:(四/2)編譯過程——晚期(運行期)

JVM理論:(四/2)編譯過程——晚期(運行期)

num 計算 圖片 標準 int 虛擬 java 方法 特殊情況 cap

一、解釋器與編譯器

  當虛擬機發現某個方法或代碼塊的運行特別頻繁時,就會把這些代碼認定為“熱點代碼” 。為了提高熱點代碼的執行效率,在運行時,虛擬機將會把這些代碼編譯成與本地平臺相關的機器碼,並進行各種層次的優化,完成這個任務的編譯器稱為即時編譯器(JIT 編譯器)。

  即時編譯器並不是虛擬機必需的部分,但是,即時編譯器編譯性能的好壞、代碼優化程度的高低卻是衡量一款商用虛擬機優秀與否的最關鍵指標之一,它也是虛擬機內中最核心且最能體現虛擬機技術水平的部分。

解釋器與編譯器兩者各有優勢:

  當程序需要迅速啟動和執行的時候,解釋器可以首先發揮作用,省去編譯的時間,立即執行。在程序運行後,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的代碼編譯成本地代碼之後,可以獲取更高的執行效率。

當程序運行環境中內存資源限制較大,可以使用解釋執行節約內存,反之可以使用編譯執行來提升效率。同時,解釋器還可以作為編譯器激進優化時的一個 “逃生門”,讓編譯器根據概率選擇一些大多數時候都能提升運行速度的優化手段。

  

  HotSpot 虛擬機中內置了兩個即時編譯器,分別稱為 Client Compiler 和 Server Compiler 或者簡稱為 C1 編譯器和 C2 編譯器。目前主流的 HotSpot 虛擬機中,默認采用解釋器與其中一個編譯器直接配合的方式工作,用戶可以使用 “-client” 或 “-server” 參數去強制指定虛擬機運行在 Client 模式或 Server 模式。

  無論采用的編譯器是 Client Compiler 還是 Server Compiler,解釋器與編譯器搭配使用的方式稱為 “混合模式” (Mixed Mode),

  用戶可以使用參數 “-Xint” 強制虛擬機運行於 “解釋模式”(Interpreted Mode),這是編譯器完全不介入工作,全部代碼都使用解釋方式執行。

  另外,也可以使用參數 “-Xcomp” 強制虛擬機運行於 “編譯模式”(Compiled Mode),這時將優先采用編譯方式執行程序,但是解釋器仍然要在編譯無法進行的情況下介入執行過程,可以通過虛擬機的 “-version” 命令的輸出結果顯示出這 3 種模式。

二、編譯對象與觸發條件

上文提到的“熱點代碼” 有兩類,即:

  • 被多次調用的方法。
  • 被多次執行的循環體。

  對於第一種情況,由於是由方法調用觸發的編譯,因此編譯器理所當然地會以整個方法作為編譯對象,這種編譯也是虛擬機中標準的 JIT 編譯方式。而對於後一種情況,盡管編譯動作是由循環體所觸發的,但編譯器依然會以整個方法(而不是單獨的循環體)作為編譯對象。這種編譯方式因為編譯發生在方法執行過程之中,因此形象地稱之為棧上替換(簡稱為 OSR 編譯,即方法棧幀還在棧上,方法就被替換了)。

  

那到底多少次才算 “多次” 呢?如何統計一個方法或一段代碼被執行過多少次呢?

  判斷一段代碼是不是熱點代碼,是不是需要觸發即時編譯,這樣的行為稱為熱點探測。目前主要的熱點探測判定方式有兩種。

  • 基於采樣的熱點探測:采用這種方法的虛擬機會周期性地檢查各個線程的棧頂,如果發現某個(或某些)方法經常出現在棧頂,那這個方法就是 “熱點方法”。簡單、高效,可以容易獲取方法調用關系,但很難精確地確認一個方法的熱度。
  • 基於計數器的熱點探測:采用這種方法的虛擬機會為每個方法(甚至是代碼塊)建立計數器,統計方法的執行次數,如果執行次數超過一定的閾值就認為它是 “熱點方法”。

  在 HotSpot 虛擬機中使用的是第二種——基於計數器的熱點探測方法,因此它為每個方法準備了兩類計數器:方法調用計數器、回邊計數器。在確定虛擬機運行參數的前提下,這兩個計數器都有一個確定的閾值,當計數器超過閾值溢出了,就會觸發 JIT 編譯。

1)方法調用計數器

  用於統計方法被調用的次數,它的默認閾值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次,這個閾值可以通過虛擬機參數-XX:CompileThreshold來人為設定。

  當一個方法被調用時,會先檢查該方法是否存在被 JIT 編譯過的版本,如果存在,則優先使用編譯後的本地代碼來執行。如果不存在已被編譯過的版本,則將此方法的調用計數器值加 1,然後判斷方法調用計數器與回邊計數器值之和是否查過方法調用計數器的閾值。如果已超過閾值,那麽將會向即時編譯器提交一個該方法的代碼編譯請求。如果不做任何設置,執行引擎並不會同步等待編譯請求完成,而是繼續進入解釋器按照解釋方式執行字節碼,直到提交的請求被編譯器編譯完成。當編譯工作完成之後,這個方法調用入口地址就會被系統自動改成新的,下一次調用該方法時就會使用已編譯的版本。整個 JIT 編譯的交互過程如下圖。

  技術分享圖片

  如果不做任何設置,方法調用計數器統計的並不是方法被調用的絕對次數,而是一個相對的執行頻率,即一段時間之內方法被調用的次數。當超過一定的時間限度,如果方法的調用次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的調用計數器就會被減少一半,這個過程稱為方法調用計數器熱度的衰減,而這段時間就稱為此方法統計的半衰周期。進行熱度衰減的動作是在虛擬機進行垃圾收集時順便進行的,可以使用虛擬機參數 -XX: -UseCounterDecay 來關閉熱度衰減,讓方法計數器統計方法調用的絕對次數,這樣,只要系統運行時間足夠長,絕大部分方法都會被編譯成本地代碼。另外,可以使用 -XX: CounterHalfLifeTime 參數設置半衰周期的時間,單位是秒。

2)回邊計數器

  它的作用是統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱為 “回邊”。 在Client 模式虛擬機的回邊計數器的閾值為 13995,在Server 模式下的閾值為 10700。

  當解釋器遇到一條回邊指令時,會先查找將要執行的代碼片段是否有已經編譯好的版本,如果有,它將會優先執行已編譯的代碼,否則就把回邊計數器的值加 1,然後判斷方法調用計數器與回邊計數器之和是否超過回邊計數器的閾值。當超過閾值的時候,將會提交一個 OSR 編譯請求,並且把回邊計數器的值降低一些,以便繼續在解釋器中執行循環,等待編譯器輸出編譯結果,整個執行過程如下圖。

  技術分享圖片

  與方法計數器不同,回邊計數器沒有計數熱度衰減的過程,因此這個計數器統計的就是該方法循環執行的絕對次數。當計數器溢出的時候,它還會把方法計數器的值也調整到溢出狀態,這樣下次再進入該方法的時候就會執行標準編譯過程。

  在默認設置下,無論是方法調用產生的即時編譯請求,還是 OSR 編譯請求,虛擬機在代碼編譯器還未完成之前,都仍然將按照解釋方式繼續執行,而編譯動作則在後臺的編譯線程中進行。用戶可以通過參數 -XX: -BackgroundCompilation 來禁止後臺編譯,在禁止後臺編譯後,一旦達到 JIT 的編譯條件,執行線程向虛擬機提交編譯請求後將會一直等待,直到編譯過程完成後再開始執行編譯器輸出的本地代碼。

三、編譯優化技術

  Java 程序員有一個共識,以編譯方式執行本地代碼比解釋方式更快,之所以有這樣的共識,除去虛擬機解釋執行字節碼時額外消耗時間的原因外,還有一個很重要的原因就是虛擬機設計團隊幾乎把對代碼的所有優化措施都集中在了即時編譯器之中。因此一般來說,即時編譯器產生的本地代碼會比 javac 產生的字節碼更加優秀。  

1、公共子表達式消除

  如果一個表達式 E 已經計算過了,並且從先前的計算到現在 E 中所有變量的值都沒有發生變化,那麽 E 的這次出現就成為了公共子表達式。對於這種表達式,沒有必要花時間再對它進行計算,只需要直接用前面計算過的表達式結果代替 E 就可以了。

  假設存在如下代碼:

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

  當這段代碼進入到虛擬機即時編譯器後,它將進行如下優化:編譯器檢測到 “c*b” 與 “b*c” 是一樣的表達式,而且在計算期間 b 與 c 的值是不變的。因此,這條表達式就可能被視為:

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

  編譯器還可能進行另外一種優化,代數化簡,把表達式變為:

  int d = E * 13 + a * 2;

  表達式進行變換之後,再計算起來就可以節省一些時間了。

2、數組邊界檢查消除

  如果有一個數組 foo[],在 Java 語言中訪問數組元素 foo[i] 的時候系統將會自動進行上下界的範圍檢查,即檢查 i 必須滿足 i >= 0 && i < foo.length 這個條件。對於虛擬機的執行子系統來說,每次數組元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量數組訪問的程序代碼,這無疑也是一種性能負擔。

  如果下面這個簡單的情況:數組下標是一個常量,如 foo[3],只要在編譯期根據數組流分析來確定 foo.length 的值,並判斷下標 “3” 沒有越界,執行的時候就無須判斷了。更加常見的情況是數組訪問發生在循環之中,並且使用循環遍歷來進行數組訪問,如果編譯器只要通過數據流分析就可以判定循環變量的取值範圍永遠在區間[0, foo.length)之內,那在整個循環中就可以把數組的上下界檢查消除,這可以節省很多次的條件判斷操作。

3、方法內聯

  方法內聯的重要性要高於其他優化措施,它的主要目的有兩個,一是去除方法調用的成本(如建立棧幀等)。二是為其他優化建立良好的基礎,方法內聯膨脹之後可以便於在更大範圍上采取後續的優化手段,從而獲取更好的優化效果。因此,各種編譯器一般都會把內聯優化放在優化序列的最靠前位置。

  講Java 方法解析和分派調用的時候就已經介紹過。只有使用invokespecial 指令調用的私有方法、實例構造器、父類方法以及使用invokestatic 指令進行調用的靜態方法才是在編譯期進行解析的,除了上述 4 種方法之外,其他的 Java 方法調用都需要在運行時進行方法接收者的多態選擇,並且都可能存在多於一個版本的方法接收者(最多再除去被 final 修飾的方法這種特殊情況,盡管它使用 invokevirtual 指令調用),簡而言之,Java 語言中默認的實例方法是虛方法。

  對於一個虛方法,編譯期做內聯的時候根本就無法確定應該使用哪個方法版本,需要在運行期才能確定。為了解決虛方法的內聯問題,Java 虛擬機設計團隊引入了一種名為 “類型繼承關系分析”(CHA)的技術,它用於確定在目前已加載的類中,某個接口是否有多於一種的實現,某個類是否存在子類、子類是否為抽象類等信息。  

  編譯器在進行內聯時,如果是非虛方法,那麽直接進行內聯就可以了。如果遇到虛方法,則會向 CHA 查詢此方法在當前程序下是否有多個目標版本可供選擇,如果查詢結果只有一個版本,那也可以進行內聯,不過需要預留一個 “逃生門”。如果程序的後續執行過程中,虛擬機一直沒有加載到會令這個方法的接收者的繼承關系發生變化的類,那這個內聯優化的代碼就可以一直使用下去。但如果加載了導致繼承關系發生變化的新類,那就需要拋棄已經編譯的代碼,退回到解釋狀態執行,或者重新進行編譯。

  如果向 CHA 查詢出來的結果是多個版本的目標方法可供選擇,則編譯器會使用內聯緩存來完成方法內聯,這是一個建立在目標方法正常入口之前的緩存,它的工作原理大致是:在未發生方法調用之前,內聯緩存狀態為空,當第一次調用發生後,緩存記錄下方法接收者的版本信息,並且每次進行方法調用時都比較接收者版本,如果以後進來的每次調用的方法接收者版本都是一樣的,那這個內聯還可以一直用下去。如果發生了方法接收者不一致的情況,就說明程序真正使用了虛方法的多態特性,這時才會取消內聯,查找虛方法表進行方法分派。

4、逃逸分析

  逃逸分析的基本行為就是分析對象動態作用域:當一個對象在方法中被定義後,它可能被外部方法所引用,例如作為調用參數傳遞到其他方法中,稱為方法逃逸。甚至還有可能被外部線程訪問到,譬如賦值給類變量或可以在其他線程中訪問的實例變量,稱為線程逃逸。如果能證明一個對象不會逃逸到方法或線程之外,也就是別的方法或線程無法通過任何途徑訪問到這個對象,則可能為這個變量進行一些高效的優化,如下。

1)棧上分配:

  Java 堆中的對象對於各個線程都是共享和可見的,只要持有這個對象的引用,就可以訪問堆中存儲的對象數據。虛擬機的垃圾收集系統可以回收堆中不再使用的對象,但回收動作無論是篩選可回收對象,還是回收和整理內存都需要耗費時間。如果確定一個對象不會逃逸出方法之外,那讓這個對象在棧上分配內存將會是一個很不錯的註意,對象所占用的內存空間就可以隨棧幀出棧而銷毀。在一般應用中,不會逃逸的局部對象所占的比例很大,如果能使用棧上分配,那大量的對象就會隨著方法的結束而自動銷毀了,垃圾收集系統的壓力將會小很多。

2)同步消除:

  線程同步本身是一個相對耗時的過程,如果逃逸分析能夠確定一個變量不會逃逸出線程,無法被其他線程訪問,那這個變量的讀寫肯定就不會有競爭,對這個變量實施的同步措施也就可以消除掉。

3)標量替換:

  標量是指一個數據已經無法再分解成更小的數據來表示了,Java 虛擬機中的原始數據類型(int、long 等數值類型以及 reference 類型等)都不能再進一步分解,它們就可以稱為標量。如果把一個 Java 對象拆散,根據程序訪問的情況,將其使用到的成員變量恢復原始類型來訪問就叫做標量替換。如果逃逸分析證明一個對象不會被外部訪問,並且這個對象可以被拆散的話,那程序真正執行的時候將可能不創建這個對象,而改為直接創建它的若幹個被這個方法使用到的成員變量來代替。將對象拆分後,除了可以讓對象的成員變量在棧上分配和讀寫之外(棧上存儲的數據,有很大的概率會被虛擬機分配至物理機器的告訴寄存器中存儲),還可以為後續進一步的優化手段創建條件。

  如果有需要,並且確認對程序運行有益,用戶可以使用參數 -XX:+DoEscapeAnalysis 來手動開啟逃逸分析,開啟之後可以通過參數 -XX:+PrintEscapeAnalysis 來查看分析結果。有了逃逸分析支持之後,用戶可以使用參數 -XX:+EliminateAllocations 來開啟標量替換,使用+XX:+EliminateLocks 來開啟同步消除,使用參數-XX:+PrintEliminateAllocations 來查看標量的替換情況。

四、Java 與 C/C++ 的編譯器對比

  Java 虛擬機的即時編譯器與 C/C++ 的靜態優化編譯器相比,可能會由於下列這些原因而導致輸出的本地代碼有一些劣勢。

  第一,因為即時編譯器運行占用的是用戶程序的運行時間,具有很大的時間壓力,它能提供的優化手段也嚴重受制於編譯成本。如果編譯速度不能達到要求,那用戶將在啟動程序或程序的某部分察覺到重大延遲,這點使得即時編譯器不敢隨便引入大規模的優化技術,而編譯的時間成本在靜態優化編譯器中並不是主要的關註點。

  第二,Java 語言是動態的類型安全語言,這就意味著需要由虛擬機來確保程序不會違反語言語義或訪問非結構化內存。從實現層面上看,這就意味著虛擬機必須頻繁地進行動態檢查,如實例方法訪問時檢測空指針、數組元素訪問時檢測上下文範圍、類型轉換時檢測繼承關系等。對於這類程序代碼沒有明確寫出的檢查行為,盡管編譯器會努力進行優化,但是總體上仍然要消耗不少的運行時間。

  第三,Java 語言使用虛方法的頻率卻遠遠大於 C/C++ 語言,這意味著運行時對方法接收者進行多態選擇的頻率要遠遠大於 C/C++ 語言,也意味著即時編譯在進行一些優化(如前面提到的方法內聯)時的難度要遠大於 C/C++ 的靜態優化編譯器。

  第四,Java 語言是可以動態擴展的語言,運行時加載新的類可能改變程序類型的繼承關系,這使得很多全局的優化都難以進行,因為編譯器無法看見程序的全貌,許多全局的優化措施都只能以激進優化的方式來完成,編譯器不得不時刻註意並隨著類型的變化而在運行時撤銷或重新進行一些優化

  第五,Java 語言中對象的內存分配都是堆上進行的,只有方法中的局部變量才能在棧上分配。而 C/C++ 的對象則有多種內存分配方式,既可能在堆上分配,又可能在棧上分配,如果可以在棧上分配線程私有的對象,將減輕內存回收的壓力。另外,C/C++ 中主要由用戶程序代碼來回收分配的內存,這就不存在無用對象篩選的過程,因此效率上(僅指運行效率,排除了開發效率)也比垃圾收集機制要高。

JVM理論:(四/2)編譯過程——晚期(運行期)