一文帶你吃透Java代碼執行過程:JVM加載字節碼+解釋執行+編譯執行

大數據架構師 發佈 2022-11-16T10:53:48.052874+00:00

Java代碼執行過程簡介前面介紹了C/C++代碼編譯執行的過程,以及C++編譯器如何支持面向對象的特徵。本節簡單介紹Java代碼執行過程,JVM在執行Java代碼時所做的工作,以及JVM是如何設計的。Java代碼執行的過程簡單可以分為以下幾步:1)Java代碼被編譯成字節碼。

Java代碼執行過程簡介

前面介紹了C/C++代碼編譯執行的過程,以及C++編譯器如何支持面向對象的特徵。本節簡單介紹Java代碼執行過程,JVM在執行Java代碼時所做的工作,以及JVM是如何設計的。Java代碼執行的過程簡單可以分為以下幾步:

1)Java代碼被編譯成字節碼。

2)(可選)字節碼被AOT編譯器編譯成可執行文件(該功能在JDK 17中被廢棄)。

3)通過JVM執行字節碼或者可執行文件。

JVM作為一個程序,主要包含以下功能:

1)JVM加載並解析字節碼,或者JVM加載可執行文件,並解析可執行文件。為了能執行編譯後的字節碼,JVM中實現了一套多態處理的機制,類似於C++編譯器中的虛指針、虛函數表。在介紹C++編譯器時,提到了編譯器把虛函數表放在數據段中,代碼位於代碼段。這些內容在JVM中是如何實現的?簡單地說,可以認為虛函數表等信息是Java類的描述信息,而這些信息只需要保存一份即可,所以JVM設計了所謂的Klass,用於保存Java類中的描述信息。

2)JVM提供了解釋執行的方案,其執行方法是把每一條字節碼指令翻譯成一段目標機器指令,然後執行。

3)JVM還提供了編譯執行的方案,其執行方法是把一個Java函數對應的字節碼或者字節碼片段翻譯成一段目標機器指令,在翻譯的過程中還進行了編譯優化,從而達到高效執行的目的。為了平衡編譯時長和執行時長,JVM提供了兩種編譯器C1和C2。C1編譯時間短但編譯後的代碼質量略低,C2編譯時間長但編譯質量高。

4)JVM提供了內存管理的功能,包括對象的快速/高效分配、垃圾回收等。

5)JVM維護和管理線程棧,最主要的原因是存在多種複雜的調用,比如Java可以調用C/C++,Java可以調用Java,C/C++本地代碼也可以調用Java。JVM整體架構圖如圖1-12所示。

下面從一個具體的實例出發,看一下Java代碼是如何執行的。代碼如下:

public class Example {

int add(int i, int j){

return i + j;

}

public static void main(String [] args) {

Example obj = new Example();

int result = obj.add(1,3);

}

}

Java代碼到字節碼

Java代碼編譯成字節碼由javac這個工具完成,編譯後生成class文件。

可以通過javap這個工具反編譯字節碼文件。反編譯後整個文件很長,這裡僅僅截取add函數和main函數相關代碼片段對應的字節碼。如下所示:

public class Example {

// compiled from: Example.java

// access flags 0x0

add(II)I

L0

LINENUMBER 4 L0

ILOAD 1

ILOAD 2

IADD

IRETURN

L1

LOCALVARIABLE this LExample; L0 L1 0

LOCALVARIABLE i I L0 L1 1

LOCALVARIABLE j I L0 L1 2

MAXSTACK = 2

MAXLOCALS = 3

// access flags 0x9

public static main([Ljava/lang/String;)V throws java/lang/Exception

L0

LINENUMBER 9 L0

NEW Example

DUP

INVOKESPECIAL Example.<init> ()V

ASTORE 1

L1

LINENUMBER 11 L1

ALOAD 1

ICONST_1

ICONST_3

INVOKEVIRTUAL Example.add (II)I

ISTORE 2

L2

LINENUMBER 13 L2

RETURN

L3

LOCALVARIABLE args [Ljava/lang/String; L0 L3 0

LOCALVARIABLE obj LExample; L1 L3 1

LOCALVARIABLE result I L2 L3 2

MAXSTACK = 3

MAXLOCALS = 3

}

在這個字碼片段中,add函數對應一共有4個字節碼指令(ILOAD l、ILOAD 2、IADD、IRETURN),JVM在執行add函數時執行的就是這4個指令。

JVM加載字節碼

文件加載分為兩種情況。第一種情況是JVM直接加載字節碼文件,按照文件格式進行解析、連結和初始化。關於字節碼的加載過程已經有很多文章和書籍介紹過,這裡不贅述。

類加載完成後,Java類的描述信息已經存儲在JVM的內存空間中。JVM為了存儲類描述信息,設計了Klass結構,而Java的實例化對象在JVM內部使用oop結構存儲。從C++的角度來理解Klass和oop,可以把JVM中的Klass對象視為C++中的Class對象(Class是類,為了描述類信息,需要對象來存儲,所以稱為Klass對象),oop對象是C++中Class實例化的對象。

回顧C++編譯器對多態的支持,使用虛函數表來記錄不同類實現的虛函數。這個思路在JVM中同樣適用。也就是說,JVM需要在維護的Klass結構中維護虛函數表。JVM中描述Java類對象的Klass(更準確的類型是InstanceKlass)結構如圖1-13所示。

在圖1-13中,Klass中有一個vtable,等價於C++編譯器中的vtbl。另外,Klass中的itable的作用類似於vtable,主要原因是Java語言只支持單繼承和多接口,itable對應的就是接口的實現。Klass中還有一個oop map,這個變量與垃圾回收緊密相關,該信息用於支持精確垃圾回收,更多信息可參考第2章。除此以外,Klass還有幾個重要的成員,圖1-13中都已經展開介紹。

JVM文件加載的第二種情況是加載通過jaotc(JDK 9開始支持該功能)編譯產生的可執行文件,加載過程實際類似於字節碼,只不過文件格式不同。

另外的不同點還有可執行文件中包含了一些額外的信息,這些信息用於垃圾回收、動態連結等。由於該特性已經從JDK 17中移除,因此本書不再進一步討論。

另外,Java語言有一個特別的設計,即所有的引用類型都繼承於Object類(此類是Java類庫定義的基礎類),Object類有5個方法是虛函數,所以任意的引用類型也都會包含這5個虛函數,並且對於引用類型定義的函數(默認函數都是虛函數,除非顯式地使用final、static等修飾符限定)會添加在自己的虛函數中。Object默認的虛函數如圖1-14所示。

另外,Object類中還有wait/notify等方法,它們使用final等修飾符限定後,不屬於虛方法,直接編譯成類似於C++語言的靜態方法。JVM規範中還設計了不同的字節碼用於執行不同類型的函數調用,例如使用字節碼invokevirtual執行虛函數,使用字節碼invokestatic執行靜態函數。

解釋執行

在JVM規範中對於字節碼的解釋執行有詳細的說明。從規範中可以看到,解釋器主要有3個主要組件:PC、Operand Stack和Local Var,含義分別為PC是下一條執行字節碼的地址、Operand Stack是解釋器棧幀、Local Var是局部變量表,存放局部變量。下面以main函數調用add函數為例演示一下解釋器的執行過程。

在main函數中通過invokevirtual調用add函數,在調用之前執行了3個字節碼,分別是:

ALOAD 1 //將局部變量表中第1個槽位的對象放在棧中

ICONST_1 //將常量1放在操作數棧中

ICONST_3 //將常量3放在操作數棧中

在執行invokevirtual前需要將參數放入操作數棧中,參數的順序是對象、參數1、參數2……,參數的順序和方法描述的保持一致。此時PC、操作數棧和局部變量的狀態如圖1-15所示。

執行invokevirtual字節碼進入函數add中,根據JVM規範,需要做以下動作:

1)創建新的棧幀(包含操作數棧和局部變量)。

2)將對象和參數傳遞到目標函數的局部變量表。

3)PC指向調用方法的首條指令。實際上這涉及函數查找過程,解釋器需要從常量表中找到函數簽名,然後找到執行方法的對象,從對象找到Klass信息,然後再找到虛方法,此時才能找到方法執行的起始地址。

4)執行對象的虛函數。

當進入add函數中時,PC、操作數棧和局部變量的狀態如圖1-16所示。

此處的操作數棧和局部方法表是add函數的,與main函數無關。需要注意的是,在Java原始碼的編譯過程中,已經知道add函數所需要的局部變量表的大小和操作數棧的大小,在上述字節碼反編譯代碼中也可以看到這些信息,如MAXSTACK=2、MAXLOCALS=3,其中反編譯代碼中還有局部變量表存儲的對象及對象所在的槽位(slot)。

當執行iload 1和iload 2時,PC、操作數棧和局部變量狀態如圖1-17所示。

當執行iadd時,根據JVM規範會將操作數棧中的兩個對象彈出,然後執行add操作,並將執行的結果放入操作數棧頂。此時PC、操作數棧和局部變量的狀態如圖1-18所示。

執行字節碼ireturn時需要返回到調用者(caller)中,JVM規範中規定返回值從被調用者(callee)的棧幀出棧,然後入棧到caller的操作數棧中,callee棧幀中的其他值都被丟棄。解釋器會切換至caller的棧幀,並將執行權交給caller。執行ireturn後caller的PC、操作數棧和局部變量的狀態如圖1-19所示。

caller(此例中為main函數)接下來執行istore 2指令,將操作數棧中的值出棧並存放在局部變量表中的第2個槽位中。PC、操作數棧和局部變量的狀態如圖1-20所示。

解釋器的實現也非常簡單,執行過程中針對每一條字節碼執行一段相應的邏輯。一個典型的解釋器實現流程圖如圖1-21所示。

下面給出一個解釋器實現的偽代碼,使用vPC模擬程序執行下一條執行的指令,使用操作數棧模擬程序執行指令的操作數和執行結果,使用局部變量模擬store/load操作的內存空間。偽代碼如下:

interpreter() {

int *vPC;

while(1) {

switch(*vPC++) {

case ICONST:

int c= *vPC++;

//將結果C放入操作數棧

break;

case ILOAD:

// 加載局部變量數據到操作數棧中

break;

case ISTORE:

// 將操作數棧的數據存入局部變量表

break;

...

}

在偽代碼中,針對每一個字節碼都有一段相應的代碼,通常把代碼封裝在一個函數中,將所有的函數組成一個分發表(dispatch table)。在執行每個字節碼時,通過查詢分發表執行相應的函數,就可以實現一個優雅的解釋器。

對於解釋執行,針對上述的Switch方式有不少的優化實踐:

1)Direct Call Threading:將每條字節碼用函數的方式實現,通過函數指針的方式調用每條字節碼。

2)Direct Threading:在一個循環中實現每條字節碼,並用Label和Goto分隔開。將每個指令從Label標記的地址開始實現。在加載階段,將程序的字節碼轉換Label地址,存儲到Direct Threading Table(DTT)。用vPC指向DTT的一項,表示下一條要執行的字節碼。這種方式的主要問題是Goto會有分支預測失敗的代價。

3)Subroutine Threading:衍生自Direct Threading,在加載解析字節碼的時候生成Context Threading Table(CTT),根據CTT執行程序,可以認為是一個極簡的JIT。對於非虛擬跳轉有效果,但該方法無法提升虛擬跳轉的性能。

4)Context Threading:衍生自Subroutine Threading,並針對虛擬跳轉進行改進,相對Subroutine Threading有5%的性能提升。

更多關於解釋器優化的細節可以參考相關論文。

在JVM中解釋方式的實現主要是通過模板解釋器完成的。在模板解釋器中,每一個字節碼對應一段可以執行的機器代碼(本質上仍然是函數代碼,但是模板解釋器已經將函數使用機器碼實現)。目前JVM中提供了202個字節碼,在X86架構下字節碼對應的機器代碼如表1-1所示。

表1-1 字節碼正常執行對應的解釋模板表

例如,指令iload_1對應的代碼如表1-1所示。這個代碼的功能就是把棧中的對象加載到寄存器rax中(其中vtos和itos是棧頂執行的狀態,即該指令執行完成後,棧頂存放的是一個整數。指令中iaddress(n)最終會轉換成X86的地址尋址指令)。

所以,可以簡單地認為JVM在執行字節碼時,每一個字節碼都被替換成一段目標機器的代碼。

編譯執行

解釋執行是針對每一個字節碼執行一段函數,由此帶來的問題是執行效率低下。提高執行效率的手段就是將解釋執行轉換為編譯執行。由於將字節碼進行編譯需要花費資源和時間,一種有效的方法是僅僅針對熱點代碼進行編譯。JVM在執行過程中如果發現一個Java的函數或者函數中的某一塊代碼片段(代碼片段通常是控制流中的一個塊,或循環代碼片段)頻繁地被執行,就把這個函數或者代碼片段編譯成一個新的函數。這樣帶來的好處有兩個:

1)節約每一個字節碼調用時的成本。

2)整個函數或者代碼塊可以使用編譯優化的技術對代碼進行進一步的優化,從而提高執行效率。

目前JVM提供的優化方案主要有兩種:客戶端優化(也稱為C1優化)和伺服器優化(也稱為C2優化)。C1優化和C2優化的執行原理相同,只不過採用的優化方法不同,進而編譯優化所用的時間不同,優化後代碼的執行效率也不同。

另外,目前JVM的優化器都是採用C++編寫的,這就帶來一個問題,如果想優化Java代碼,必須熟悉C++。在JDK 9中啟動了一個新的項目Graal,該項目是使用Java代碼編寫一個優化器並替代C2優化器(Graal目前還是實驗性質的項目,但有不少公司評測認為其性能優於C2優化器。但出於項目活躍度及商業考慮,該項目在JDK 17中被移除)。

JVM中編譯優化的過程如圖1-22所示。

這個過程一般經歷三個階段並做不同的優化,分別為:

1)高級中間語言的生成及其優化。高級中間語言一般是進行語言相關、機器無關的描述,針對特定的語言進行的優化。

2)低級中間語言的生成及其優化。低級中間語言一般是進行語言無關、機器無關的描述,這是通用的中間語言描述,常見的編譯優化技術基本上都針對低級中間語言進行,例如常量摺疊、死代碼消除、循環不變量外提等。

由於編譯優化需要消耗時間和CPU等資源,因此在JVM中提供了TieredCompilation技術,即當發現代碼變成熱點後首先進行簡單的代碼優化,這樣的優化產生了初級優化的機器代碼並替代原來的解釋執行;如果熱點代碼繼續被反覆執行,會啟動高級的編譯優化,並用高級編譯優化後的代碼替換初級優化的機器代碼。使用該技術可以在編譯效率和執行效率間取得一個很好的平衡,從而提高應用整體執行的效率。

3)目標機器代碼的生成。一般是進行和目標機器相關的優化,最為典型的優化就是寄存器的分配。

當編譯優化完成後,JVM將在本地堆(更為準確的地方是指JVM的CodeCache)中存儲編譯優化後的代碼,同時把描述Java方法的Method對象(參考圖1-13)和編譯優化代碼進行關聯。當執行Java的方法時,如果發現有編譯優化後的代碼,則直接執行編譯優化後的代碼。

但是編譯執行的過程非常複雜,在整個編譯過程中需要考慮以下幾個方面:

(1)編譯的內容

虛擬機應該針對熱點代碼進行編譯以取得最好的收益。如何定義熱點代碼就是關鍵。最簡單的方式是以函數為粒度,如果發現函數被調用的次數足夠多,則可以將整個函數作為待編譯的內容進行編譯。但實際上還有一種情況,函數本身被調用的次數很少,函數內部存在一個很大的循環,並且在循環中做複雜的運算。對於該情況最好的處理方式是編譯循環相關的代碼片段,但這樣的處理方式會帶來額外的實現難度。例如如下代碼:

class Test {

static int sum(int c) {

int res = 0;

for(int i = 0;i < c; i++) {

res += i;

}

return res;

}

}

對於代碼片段中的for循環執行1萬次的數學運算,循環內部如果按照解釋模式執行,則需要多次訪問變量i,執行乘法和加法。假如函數sum本身不是熱點,即函數sum本身不會由調用者觸發執行編譯優化,則對於函數sum中的循環優化片段,即語句res += i進行編譯優化,並且可以執行優化後的代碼。現代的高級虛擬機通常都支持代碼片段的編譯替換和執行。

(2)編譯觸發的時機

編譯優化只有發現熱點代碼才能觸發。如何定義代碼是否是熱點?一個簡單的思路是代碼執行的次數到達一定閾值就認為代碼是熱點,但實現中需要考慮更多的內容,特別是在多線程執行的情況中。

如果一個線程執行一個循環,則可以通過對循環計數確定代碼達到閾值從而觸發編譯,在這種情況下只需要一個線程局部的計數器就可以達到目的;實際中還有其他的情況,例如多個線程都會執行同一段代碼,雖然每個線程執行代碼的次數不多,但是多個線程加起來執行代碼的次數就非常可觀了,對於這樣的情況,比較理想的設計是使用一個全局的計數器來記錄熱點代碼執行的次數,而這樣的設計需要考慮全局計數器的並發訪問問題。需要指出的是,編譯執行需要額外的計數器來記錄熱點代碼,而維護額外的計數器不僅需要額外的空間來存儲計數器,還會影響程序執行的效率。

所以只有在可能出現熱點代碼的地方才會維護計數器,一般是在循環的回邊(回邊指的是循環體中跳轉到循環起始位置繼續執行的路徑)中維護計數器。

(3)編譯執行的方式

在確定好待編譯的內容以後,需要考慮編譯是同步執行還是異步執行。

同步執行意味著應用程式需要等待編譯結果完成後才能執行編譯後的機器代

碼,異步執行意味著應用程式可以以解釋的方式或者初級優化的代碼繼續執

行,待編譯完成後執行新的編譯代碼。

(4)編譯代碼的替換執行

要執行新的編譯代碼涉及原有棧幀到新的編譯代碼棧幀的切換。最簡單的方式是當要執行新的編譯代碼時重新為新的代碼構建棧幀,並將編譯代碼中所使用的變量作為參數傳遞,當編譯代碼執行結束後再返回原來的棧幀繼續執行。當然,返回後需要更新原來棧幀的變量,這種方式也稱為棧頂替換技術(On-Stack-Replacement,OSR)。繼續使用上述sum函數進行演示,假設sum在執行到一定閾值後啟動編譯優化,並且在編譯優化完成後執行編譯優化後的代碼。由於解釋器是按照字節碼順序執行的,sum對應的字節碼如下所示:

0 ICONST_0

1 ISTORE 1 // res = 0

2 ICONST_0

3 ISTORE 2 // i =0

4 ILOAD 2 // load i

5 ILOAD 0 // load c

6 IF_ICMPGE 13 // 大於閾值退出循環

7 ILOAD 1 // load res

8 ILOAD 2 // load i

9 IADD // res + i

10 ISTORE 1 // store res

11 IINC 2 1 // i++

12 GOTO 4 // 回邊,執行循環

13 ILOAD 1 // load res,然後返回

14 IRETURN

假設循環執行50次後認定代碼片段為熱點並對代碼片段進行編譯,當編譯完成後執行。為了方便演示,使用字母A、L、B描述執行代碼。其中L對應的是熱點代碼片段,編譯執行時需要將L依賴或者使用的變量作為參數傳遞給編譯後的代碼,同時將L對應的代碼片段進行編譯。假設編譯後形成函數sum_osr,函數的入參為L代碼片段中使用的變量。替換執行時可以簡單地構造一個函數調用,跳轉到編譯後的代碼執行。代碼執行完成後返回原來的棧幀繼續執行。為了能夠讓原來的棧幀繼續執行,通常需要知道原來棧幀執行的下一條指令的地址。整個過程的示意圖如圖1-23所示。

圖中還有一個尚未解決的問題,從L處調用sum_osr時需要傳遞參數,那麼此時參數可能有哪些?由於函數sum已經執行了部分代碼,因此變量res和i已經不再是初值,並且res和i都將在編譯代碼中被使用,同時變量c也將在編譯代碼中被使用。這裡假設執行50次後開始執行編譯代碼,所以i=50,此時res=1225(res=1+2+…+49=1225),另外,在解釋執行時還會使用操作數棧,這些內容都將作為參數傳遞給編譯優化的代碼。

此外,虛擬機在執行編譯優化時可能會進行一些激進的優化動作,例如根據已經執行的類的信息優化函數的調用關係。這就會帶來額外的問題,如果類型信息發生變化,優化代碼就會變成無效的,此時需要從編譯優化後的代碼切換到原來的解釋執行方式(稱為退優化)。退優化的過程中也涉及何時允許觸發退優化,以及代碼的替換執行等問題。

本文給大家講解的內容是Java虛擬機和垃圾回收基礎知識:Java代碼執行過程簡介

  1. 下篇文章給大家講解的內容是Java虛擬機和垃圾回收基礎知識:內存+線程+JIT概述
  2. 覺得文章不錯的朋友可以轉發此文關注小編;
  3. 感謝大家的支持!
關鍵字: