JVM學習筆記(四)—— 虛擬機器位元組碼執行引擎

Eric_Gui發表於2021-01-03

1. 概述

物理機與虛擬機器的區別:物理機的執行引擎是直接建立在處理器、快取、指令集和作業系統層面上的,而虛擬機器的執行引擎則是由軟體自行實現的,因此可以不受物理條件制約地定製指令集與執行引擎的結構體系,能夠執行那些不被硬體直接支援的指令集格式。

在不同的虛擬機器實現中,執行引擎在執行位元組碼的時候,通常會有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生程式碼執行)兩種選擇,也可能兩者兼備,還可能會有同時包含幾個不同級別的即時編譯器一起工作的執行引擎。但從外觀上來看,所有的Java虛擬機器的執行引擎輸入、輸出都是一致的:輸入的是位元組碼二進位制流,處理過程是位元組碼解析執行的等效過程,輸出的是執行結果。

2. 執行時棧幀結構

Java虛擬機器以方法作為最基本的執行單元,“棧幀”則是用於支援虛擬機器進行方法呼叫和方法執行背後的資料結構,它也是虛擬機器執行時資料區中的虛擬機器棧的棧元素。

棧幀儲存了方法的區域性變數表運算元棧動態連線方法返回地址等資訊。在編譯Java程式原始碼的時候,棧幀中需要多大的區域性變數表,需要多深的運算元棧就已經被分析計算出來,並且寫入方法表的Code屬性之中。換言之,一個棧幀需要分配多少記憶體,並不會受到程式執行期變數資料的影響,而僅僅取決於程式原始碼和具體的虛擬機器實現的棧記憶體佈局形式。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-VrwPCfpu-1609652589251)(/Users/guisc/Documents/JVM/圖片/棧幀的概念結構.png)]

2.1 區域性變數表

區域性變數表是一組變數值的儲存空間,用於存放方法引數和方法內部定義的區域性變數。方法表的Code屬性的max_locals資料項中確定了該方法所需分配的區域性變數表的最大容量。

區域性變數表的容量以變數槽為最小單位,每個變數槽都應該能存放一個boolean、byte、char、short、int、float、reference或returnAddress型別的資料,它允許變數槽的長度可以隨著處理器、作業系統或虛擬機器實現的不同而發生變化,保證了即使在64位虛擬機器中使用了64位的實體記憶體空間去實現一個變數槽,虛擬機器仍要使用對齊和補白的手段讓變數槽在外觀上看起來與32位虛擬機器中的一致

reference型別表示對一個物件例項的引用,沒有說明它的長度,也沒有明確指出這種引用應有的怎樣的結構。但是一般來說,虛擬機器實現至少都應當能通過這個引用做到兩件事情,一是根據引用直接或間接查詢到物件在Java堆中的資料存放的起始地址或索引,二是根據引用直接或間接地查詢到物件所屬資料型別在方法區中的儲存的型別資訊(Java能提供反射的原因)。

對於64位的資料型別,Java虛擬機器會以高位對齊的方式為其分配兩個連續的變數槽空間。不過由於區域性變數表是建立線上程堆疊中的,屬於執行緒私有的資料,無論讀寫兩個連續的變數槽是否為原子操作,都不會引起資料競爭和執行緒安全問題。而且對於兩個相鄰的共同存放一個64位資料的兩個變數槽,虛擬機器不允許採用任何方式單獨訪問其中的某一個,如果出現了,會在類載入的檢驗階段丟擲異常。

當一個方法被呼叫時,Java虛擬機器會使用區域性變數表來完成引數值到引數變數列表的傳遞過程,即實參到形參的傳遞。如果執行的是例項方法,那區域性變數表中第0位索引的變數槽預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過“this”來訪問到這個隱含的引數。其餘引數則按照參數列順序排列,佔用從1開始的區域性變數槽,參數列分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的變數槽

區域性變數表中的變數槽是可以重用的,方法體中定義的某些變數,其作用域不一定覆蓋整個方法體,如果當前位元組碼PC計數器的值已經操過了某個變數的作用域,那該變數佔用的變數槽就可以交給其他變數來重用了。不過,這樣做會帶來一些副作用,在某些情況下會直接影響到系統的垃圾收集行為。

// placeholder的記憶體沒有被回收
public static void main(String[] args) {
  {
    byte[] placeholder = new byte[64*1024*1024];
  }
  System.gc();
}

// placeholder的記憶體被回收了
public static void main(String[] args) {
  {
    byte[] placeholder = new byte[64*1024*1024];
  }
  int a = 0;
  System.gc();
}

原因分析:placeholder能否被回收的根本原因在於區域性變數表中的變數槽是否還存有關於placeholder陣列物件的引用。第一次修改中,程式碼雖然已經離開了placeholder的作用域,但在此之後,再沒有發生過任何對區域性變數表的讀寫操作,placeholder原本佔用的變數槽還沒有被其他變數所複用,所以作為GC Roots一部分的區域性變數表仍然保持著對它的關聯。

2.2 運算元棧

運算元棧的最大深度在編譯階段也被寫入到方法表的Code屬性的max_stacks資料項之中。運算元棧的每一個元素都可以是包括long和double在內的任意Java資料型別,32位資料型別所佔的棧容量為1,64位資料型別所佔的棧容量為2。

運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配。

在概念模型中,兩個不同棧幀作為不同方法的虛擬機器棧的元素,是完全相互獨立的。但是在大多數虛擬機器的實現裡都會進行一些優化處理,令兩個棧幀出現一部分重疊,讓下面棧幀的部分運算元棧與上面棧幀的部分區域性變數表重疊在一起,這樣做不僅節省空間,更重要的是在進行方法呼叫時就可以直接共用一部分資料,無須進行額外的引數複製傳遞了

Java虛擬機器的解釋執行引擎被稱為“基於棧的執行引擎”,裡面的“棧”就是運算元棧。

2.3 動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線。Class檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池裡指向方法的符號引用作為引數。這些符號引用一部分會在類載入階段或者第一次使用的時候就被轉化為直接引用,這種轉化被稱為靜態解析。另外一部分將在每一次執行期間都轉化為直接引用,這部分就稱為動態連線

2.4 方法返回地址

當一個方法開始執行後,只有兩種方式退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者,方法是否有返回值以及返回值的型別將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為“正常呼叫完成”。另外一種退出方式是在方法執行的過程中遇到了異常,並且這個異常沒有在方法體內得到妥善處理(只要在本方法的異常表中沒有搜尋到匹配的異常處理器),就會導致方法退出,這種退出方法的方式稱為“異常呼叫完成”。一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者提供任何返回值的。

無論採用何種方式退出,在方法退出之後,都必須返回到最初方法被呼叫時的位置,程式才能繼續執行。方法正常退出時,主調方法的PC計數器的值就可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中就一般不會儲存這部分資訊。

3. 方法呼叫

方法呼叫並不等同於方法中的程式碼被執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還未涉及方法內部的具體執行過程。

Class檔案的編譯過程中不包含傳統程式語言編譯的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址(也就是直接引用)。這個特性給Java帶來了更強大的動態擴充套件能力,但也使得Java方法呼叫過程變得相對複雜,某些呼叫需要在類載入期間,甚至到執行期間才能確定目標方法的直接引用。

3.1 解析

所有方法呼叫的目標方法在Class檔案裡面都是一個常量池中的符號引用,在類載入的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能夠成立的前提是:方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。這類方法的呼叫被稱為解析

在Java語言中符合“編譯器可知,執行期不可變”這個要求的方法,主要有靜態方法私有方法兩大類,前者與型別直接關聯,後者在外部不可被訪問,這兩種方法各自的特點都決定了它們不可能通過繼承或別的方式重寫出其他版本,因此它們都適合在類載入階段進行解析

5條不同的方法呼叫位元組碼指令,分別是:

  • invokestatic:呼叫靜態方法。
  • invokespecial:呼叫例項構造器()方法、私有方法和父類中的方法。
  • invokevirtual:呼叫所有的虛方法。
  • invokeinterface:呼叫介面方法,會在執行時再確定一個實現該介面的物件。
  • invokedynamic:先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法。

前四條呼叫指令,分派邏輯都固化在Java虛擬機器內部,而invokedynamic指令的分派邏輯是由使用者設定的引導方法來決定的。

只要能被invokestaticinvokespecial指令呼叫的方法,都可以在解析階段中確定唯一的呼叫版本,Java語言中符合這個條件的方法共有靜態方法私有方法例項構造器父類方法4種,再加上被final修飾的方法(儘管它使用invokevirtual指令呼叫,但它無法被覆蓋,無其他版本的可能),這5種方法呼叫會在類載入的時候就可以把符號引用解析為該方法的直接引用。這些方法被稱為“非虛方法”,其他方法就被稱為“虛方法”。

3.2 分派

解析呼叫一定是個靜態的過程,在編譯期間就完全確定,在類載入的解析階段就會把涉及的符號引用全部轉變為明確的直接引用,不必延遲到執行期再去完成。而另一種主要的方法呼叫形式:分派呼叫則複雜得多,它可能是靜態的也可能是動態的,按照分派依據的宗量數還可分為單分派和多分派。

3.2.1 靜態分派(方法過載的本質)

// 方法靜態分派演示
public class StaticDispatch {
  static abstract class Human {}
  
  static class Man extends Human {}
  
  static class Woman extends Human {}
  
  public void sayHello(Human guy) {
    System.out.println("hello,guy!");
  }
  
  public void sayHello(Man guy) {
    System.out.println("hello,gentleman!");
  }
  
  public void sayHello(Woman guy) {
    System.out.println("hello,lady!");
  }
  
  public static void main(String[] args) {
    Human man = new Man();
    Human woman = new Woman();
    StaticDispatch sd = new StaticDispatch();
    sd.sayHello(man);
    sd.sayHell0(woman);
  }
}

// 程式輸出結果(這裡是過載,而不是多型)
// hello,guy!
// hello,guy!
// 在這裡,兩個sayHello()方法的接受者是sd,然後選擇它的過載方法,選擇哪個過載版本,取決於傳入引數的數量和資料型別(這裡的資料型別是指的是引數的靜態型別),man和woman的靜態型別都是Human,所以選擇的過載版本是sayHello(Human guy)。

Human man = new Man();

“Human”稱為變數的“靜態型別”,後面的“Man”被稱為變數的“實際型別”。靜態型別和實際型別在程式中都可能會發生變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯器可知的;而實際型別變化的結果在執行期才可確定,編譯器在編譯程式的時候並不知道一個物件的實際型別是什麼。

// 實際型別變化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();

// 靜態型別變化
sd.sayHello((Man) human);
sd.sayHello((Woman) human);

虛擬機器(更準確地說是編譯器)在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。由於靜態型別在編譯期可知,所以在編譯階段,Javac編譯器就根據引數的靜態型別決定了使用哪個過載版本。

所有依賴靜態型別來決定方法執行版本的分派動作,都稱為靜態分派。靜態分派最典型的應用表現就是方法過載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機器來執行的。

編譯期間選擇靜態分派目標的過程,實際上是Java語言實現方法過載的本質。

Javac編譯器雖然能確定出方法的過載版本,但在很多情況下這個過載版本並不是唯一的,往往只能確定一個相對更合適的版本。

// 過載方法匹配優先順序
public class Overload {
  public static void sayHello(Object arg) {
    System.out.println("hello Object");
  }
  public static void sayHello(int arg) {
    System.out.println("hello int");
  }
  public static void sayHello(long arg) {
    System.out.println("hello long");
  }
  public static void sayHello(Character arg) {
    System.out.println("hello Character");
  }
  public static void sayHello(char arg) {
    System.out.println("hello char");
  }
  public static void sayHello(char... arg) {
    System.out.println("hello char...");
  }
  public static void sayHello(Serializable arg) {
    System.out.println("hello Serializable");
  }
  
  public static void main(String[] args){
    sayHello('a');
  }
}
/* 
	程式輸出結果:
	'a'是一個char型別的資料,首先肯定會先尋找引數型別為char的過載方法 char arg,如果註釋這個方法,
	則會找 int arg ,此時發生了一次自動型別轉換,'a'轉換成數字97,
	接下來 long arg,此時發生了兩次自動型別轉換,轉換成int後再轉換成long,
	(char>int>long>float>double的順序轉型進行匹配,但不會匹配到byte和short)
	接下來 Character arg,發生了一次自動裝箱,
	接下來 Serializable arg,java.lang.Serializable是java.lang.Character類實現的一個介面,
	(char可以轉型成int,但是Character是絕對不可能轉型為Integer的)
	接下來 Object arg,char裝箱後轉型為父類了,
	最後是 char... arg,可變長引數的過載優先順序是最低的
*/

靜態方法會在編譯期確定、在類載入期就進行解析,而靜態方法顯然也是可以擁有過載版本的,選擇過載版本的過程也是通過靜態分派完成的。

3.2.2 動態分派(方法重寫的本質)

public class DynamicDispatch {
  static abstract Human {
    protected abstract void sayHello();
  }
  
  static class Man extends Human {
    @Override
    protected void sayHello() {
      System.out.println("man say hello");
    }
  }
  
  statci class Woman extends Human {
    @Override
    protected void sayHello() {
      System.out.println("woman say hello");
    }
  }
  
  public static void main(String[] args) {
    Human man = new Man();
    Human woman = new Woman();
    man.sayHello();
    woman.sayHello();
    man = new Woman();
    man.sayHello();
  }
}
/*
	程式執行結果:(這裡是方法重寫,依據的是變數的實際型別)
	man say hello
	woman say hello
	woman say hello
*/

invokevirtual指令,是如何確定呼叫方法版本、如何實現多型查詢的?

invokevirtual指令的執行時解析過程大致分為以下幾步:

  1. 找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
  2. 如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;不通過則返回java.lang.IllegalAccessError異常。
  3. 否則,按照繼承關係從下往上依次對C的各個父類進行第二步的搜尋和驗證過程。
  4. 如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。

正是因為invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令並不是把常量池中方法的符號引用解析到直接引用上就結束了,還會根據方法接收者的實際型別來選擇方法版本,這個過程就是Java語言中方法重寫的本質。

把這種在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派

欄位不參與多型,因為欄位不使用invokevirtual指令。

// 欄位不參與多型
public class FieldHasNoPolymorphic {
  static class Father {
    public int money = 1;
    public Father {
      money = 2;
      showMeTheMoney();
    }
    public void showMeTheMoney() {
      System.out.println("I am Father, i have $" + money);
    }
  }
  
  static class Son extends Father {
    public int money = 3;
    public Son {
      money = 4;
      showMeTheMoney();
    }
    public void showMeTheMoney() {
      System.out.println("I am Son, i have $" + money);
    }
  }
  
  public static void main(String[] args) {
    Father guy = new Son();
    System.out.println("This guy has $" + guy.money);		// guy.money結果是2。欄位時,看變數的靜態型別。
  }
}
/*
	程式輸出結果:
	I am Son, i have $0
	I am Son, i have $4
	This guy has $2
*/

Son類在建立的時候,首先隱式呼叫了Father的建構函式,而Father建構函式中對showMeTheMoney()的呼叫是一次虛方法呼叫,實際執行的版本是Son::showMeTheMoney()方法,所以輸出“I am Son”,但這時候雖然父類的money欄位已經被初始化成2了,但Son::showMeTheMoney()方法中訪問的卻是子類的money欄位,這時候自然還是0,因為它要到子類的建構函式執行時才會被初始化。main()的最後一句通過靜態型別訪問到了父類中的money,輸出了2。

3.2.3 單分派與多分派

方法的接收者與方法的引數統稱為方法的宗量。根據分派基於多少種宗量,可以將分派分為單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。

// 單分派、多分派
public class Dispatch {
  static class QQ {}
  static class _360 {}
  
  public static class Father {
    public void hardChoice(QQ arg) {
      System.out.println("father choose qq");
    }
    
    public void hardChoice(_360 arg) {
      System.out.println("father choose 360");
    }
  }
  
  public static class Son extends Father {
    public void hardChoice(QQ arg) {
      System.out.println("son choose qq");
    }
    
    public void hardChoice(_360 arg) {
      System.out.println("son choose 360");
    }
  }
  
  public static void main(String[] args) {
    Father father = new Father();
    Father son = new Son();
    father.hardChoice(new _360());
    son.hardChoice(new QQ());
  }
}
/*
	程式執行結果:
	father choose 360
	son choose qq
*/

原因分析:

1)首先是編譯階段中編譯器的選擇過程,也就是靜態分派的過程。這時候選擇目標方法的依據有兩點:一是靜態型別是Father還是Son,二是方法引數是QQ還是360。這次選擇結果的最終產物是產生了兩條invokevirtual指令,兩條指令的引數分別為常量池中指向Father::hardChoice(360)及Father::hardChoice(QQ)方法的符號引用。因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派型別

2)再是執行階段中虛擬機器的選擇,也就是動態分派的過程。在執行“son.hardChoice(new QQ())”這行程式碼時,更準確地說,是在執行這行程式碼對應的invokevirtual指令時,由於編譯器已經決定目標方法的簽名必須是hardChoice(QQ),虛擬機器此時不會關心傳遞過來的引數到底是什麼,因為此時引數的靜態型別、實際型別都對方法的選擇不會構成任何影響,唯一可以影響虛擬機器選擇的因素只有該方法的接收者的實際型別是Father還是Son。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派型別

Java語言是一門靜態多分派、動態單分派的語言。

3.2.4 虛擬機器動態分派的實現

動態分派是執行非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在接收者型別的方法後設資料中搜尋合適的目標方法,因此,Java虛擬機器基於執行效能的考慮,真正執行時一般不會如此頻繁地去反覆搜尋型別後設資料。優化手段:為型別在方法區中建立一個虛方法表(Virtual Method Table,與此對應的,在invokeinterface執行時也會用到介面方法表——Interface Method Table),使用虛方法表索引來代替後設資料查詢以提高效能。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-XINYg4nO-1609652589253)(/Users/guisc/Documents/JVM/圖片/虛方法表結構.png)]

虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表中的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類虛方法表中的地址會被替換為指向子類實現版本的入口地址。

虛方法表一般在類載入的連線階段進行初始化,準備了類的變數初始值後,虛擬機器會把該類的虛方法表也一同初始化完畢。

4. 動態型別語言支援

4.1 動態型別語言

動態型別語言的關鍵特徵是它的型別檢查的主體過程是在執行期而不是在編譯器進行的,滿足這個特徵的語言有:Groovy、JavaScript、Lua、PHP、Python、Ruby等等。在編譯期就進行型別檢查過程的語言,譬如C++和Java等就是最常用的靜態型別語言

Java能夠做到型別檢查的原因:Java在編譯期間已將方法完整的符號引用生成出來,並作為方法呼叫指令的引數儲存到Class檔案中。如:invokevirtual #4; //Method java/io/PrintStream.println;(Ljava/lang/String;) V 這個符號引用包含了該方法定義在哪個具體型別之中、方法的名字以及引數順序、引數型別和方法返回值等資訊,通過這個符號引用,Java虛擬機器就可以翻譯出該方法的直接引用。而動態型別語言的變數本身並沒有型別,變數的值才具有型別,所以編譯器在編譯時最多隻能確定方法名稱、引數、返回值等資訊,而不會去確定方法所在的具體型別。“變數無型別而變數值才有型別”是動態型別語言的一個核心特徵。

靜態型別語言能夠在編譯期確定變數型別,最顯著的好處是編譯器可以提供全面嚴謹的型別檢查,這樣與資料型別相關的潛在問題在編碼時就能及時發現。而動態型別語言在執行期才確定型別,這可以為開發人員提供極大的靈活性。

4.2 Java與動態型別

4.3 java.lang.invoke包

JDK 7時加入的java.lang.invoke包的主要目的是在之前單純依靠符號引用來確定呼叫的目標方法這條路之外,提供一種新的動態確定目標方法的機制,稱為“方法控制程式碼”。

方法控制程式碼類似於C/C++中的函式指標,比如用函式指標來把謂詞傳遞到排序方法中,void sort(int list[], const int size, int (*compare)(int,int)) 但在Java中沒有辦法單獨把一個函式作為引數進行傳遞。普遍的做法是設計一個帶有compare()方法的Comparator介面,以實現這個介面的物件作為引數,如:void sort(List list, Comparator c)。

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
// 方法控制程式碼演示
public class MethodHandleTest {
  static class ClassA {
    public void println(String s) {
      System.out.println(s);
    }
  }
  
  public static void main(String[] args) throws Throwable {
    Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
    // 無論obj最終是哪個實現類,下面這句都能正確呼叫到println方法
    getPrintlnMH(obj).invokeExact("icyfenix");
  }
  
  private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
    MethodType mt = MethodType.methodType(void.class, String.class);
    return lookup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver);
  }
}

有了MethodHandle就可以寫類似C/C++那樣的函式宣告瞭:void sort(List list, MethodHandle compare)

4.4 invokedynamic 指令

invokedynamic指令是為了解決原有4條“invoke*”指令方法分派規則完全固化在虛擬機器之中的問題,把如何查詢目標方法的決定權從虛擬機器轉嫁到具體使用者程式碼之中,讓使用者有更高的自由度。

每一處含有invokedynamic指令的位置都被稱為“動態呼叫點”。

4.5 實戰:掌控方法分派規則

5. 基於棧的位元組碼解釋執行引擎

5.1 解釋執行

當主流的虛擬機器中都包含了即時編譯器後,Class檔案中的程式碼到底會被解釋執行還是編譯執行,就成了只有虛擬機器自己才能準確判斷的事。再後來,Java業發展出可以直接生成原生程式碼的編譯器(如Jaotc、GCJ、Excelsior JET)。所以,只有確定了談論物件是某種具體的Java實現版本和執行引擎執行模式,談解釋執行還是編譯執行才會比較合理確切。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-cdpDnzLx-1609652589254)(/Users/guisc/Documents/JVM/圖片/編譯過程.png)]

在Java語言中,Javac編譯器完成了程式程式碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流的過程。因為這一部分動作是在Java虛擬機器之外進行的,而直譯器在虛擬機器的內部,所以Java程式的編譯就是半獨立的實現。

5.2 基於棧的指令集與基於暫存器的指令集

Javac編譯器輸出的位元組碼指令流,基本上是一種基於棧的指令集架構(ISA),位元組碼指令流裡面的指令大部分都是零地址指令,它們依賴運算元棧進行工作,與之相對的就是基於暫存器的指令集。

分別使用這兩種指令集去計算“1+1”的結果,

// 1、基於棧的指令集會是這樣的:
iconst_1
iconst_1
iadd
istore_0
兩條iconst_1指令連續把兩個常量1壓入棧後,iadd指令把棧頂的兩個值出棧、相加 ,然後把結果放回棧頂 ,最後istore_0把棧頂的值放到區域性變數表的第0個Slot中。這種指令流中的指令通常都是不帶引數的,使用運算元棧中的資料作為指令的運算輸入,指令的運算結果也儲存在運算元棧之中。
  
// 2、基於暫存器的指令集:
mov eax ,1 
add eax ,1
mov指令把EAX暫存器的值設為1 ,然後add指令再把這個值加1 ,結果就儲存在EAX暫存器裡面。

兩種指令集的優缺點對比:

  • 基於棧的指令集主要的優點就是可移植,暫存器由硬體直接提供,程式直接依賴這些硬體暫存器則不可避免地要受到硬體的約束。
  • 棧架構的指令集還有一些其他的優點,如程式碼相對更加緊湊(位元組碼中每個位元組就對應一條指令,而多地址指令集中還需要存放引數)、編譯器實現更加簡單(不需要考慮空間分配的問題,所需空間都在棧上操作 ) 等。
  • 棧架構指令集的主要缺點是執行速度相對來說會稍慢一些。雖然棧架構指令集的程式碼非常緊湊,但是完成相同功能所需的指令數量一般會比暫存器架構多,因為出棧、入棧操作本身就產生了相當多的指令數量。更重要的是,棧實現在記憶體之中,頻繁的棧訪問也就意味著頻繁的記憶體訪問,相對於處理器來說,記憶體始終是執行速度的瓶頸。

5.3 基於棧的直譯器執行過程

相關文章