深入理解虛擬機器之虛擬機器位元組碼執行引擎

Guide哥發表於2018-05-12

1 概述

執行引擎是java虛擬機器最核心的組成部件之一。虛擬機器的執行引擎由自己實現,所以可以自行定製指令集與執行引擎的結構體系,並且能夠執行那些不被硬體直接支援的指令集格式。

所有的Java虛擬機器的執行引擎都是一致的:輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果。本節將主要從概念模型的角度來講解虛擬機器的方法呼叫和位元組碼執行

2 執行時棧幀結構

棧幀(Stack Frame) 是用於支援虛擬機器方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中虛擬機器棧(Virtual Machine Stack)的棧元素

棧幀儲存了方法的區域性變數表、運算元棧、動態連線和方法返回地址等資訊。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。

棧幀概念結構如下圖所示:

棧幀概念結構

2.1 區域性變數表

區域性變數表是一組變數值儲存空間,用於存放方法引數和方法內定義的區域性變數。 區域性變數表的容量以變數槽(Variable Slot)為最小單位。 一個Slot可以存放一個32位以內(boolean、byte、char、short、int、float、reference和returnAddress)的資料型別,reference型別表示一個物件例項的引用,returnAddress已經很少見了,可以忽略。

對於64位的資料型別(Java語言中明確的64位資料型別只有long和double),虛擬機器會以高位對齊的方式為其分配兩個連續的Slot空間。

虛擬機器通過索引定位的方式使用區域性變數表,索引值的範圍從0開始至區域性變數表最大的Slot數量。訪問的是32位資料型別的變數,索引n就代表了使用第n個Slot,如果是64位資料型別,就代表會同時使用n和n+1這兩個Slot。

為了節省棧幀空間,區域性變數Slot可以重用,方法體中定義的變數,其作用域並不一定會覆蓋整個方法體。如果當前位元組碼PC計數器的值超出了某個變數的作用域,那麼這個變數的Slot就可以交給其他變數使用。這樣的設計會帶來一些額外的副作用,比如:在某些情況下,Slot的複用會直接影響到系統的收集行為。

2.2 運算元棧

運算元棧(Operand Stack) 也常稱為操作棧,它是一個後入先出棧。當一個方法執行開始時,這個方法的運算元棧是空的,在方法執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是 出棧/入棧操作。

運算元棧

在概念模型中,一個活動執行緒中兩個棧幀是相互獨立的。但大多數虛擬機器實現都會做一些優化處理:讓下一個棧幀的部分運算元棧與上一個棧幀的部分區域性變數表重疊在一起,這樣的好處是方法呼叫時可以共享一部分資料,而無須進行額外的引數複製傳遞。

2.3 動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線

位元組碼中方法呼叫指令是以常量池中的指向方法的符號引用為引數的,有一部分符號引用會在類載入階段或第一次使用的時候轉化為直接引用,這種轉化稱為 靜態解析,另外一部分在每次的執行期間轉化為直接引用,這部分稱為動態連線

2.4 方法返回地址

當一個方法被執行後,有兩種方式退出這個方法:

  • 第一種是執行引擎遇到任意一個方法返回的位元組碼指令,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)

  • 另外一種是在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理(即本方法異常處理表中沒有匹配的異常處理器),就會導致方法退出,這種退出方式稱為異常完成出口(Abrupt Method Invocation Completion)。 注意:這種退出方式不會給上層呼叫者產生任何返回值。

無論採用何種退出方式,在方法退出後,都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,呼叫者的PC計數器的值可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址是通過異常處理器表來確定的,棧幀中一般不會儲存這部分資訊。

方法退出的過程實際上等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變數表和運算元棧,把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中,調整PC計數器的值以指向方法呼叫指令後面的一條指令等。

2.5 附加資訊

虛擬機器規範允許虛擬機器實現向棧幀中新增一些自定義的附加資訊,例如與除錯相關的資訊等。

3 方法呼叫

方法呼叫階段的目的:確定被呼叫方法的版本(哪一個方法),不涉及方法內部的具體執行過程,在程式執行時,進行方法呼叫是最普遍、最頻繁的操作。

一切方法呼叫在Class檔案裡儲存的都只是符號引用,這是需要在類載入期間或者是執行期間,才能確定為方法在實際 執行時記憶體佈局中的入口地址(相當於之前說的直接引用)

3.1 解析

“編譯期可知,執行期不可變”的方法(靜態方法和私有方法),在類載入的解析階段,會將其符號引用轉化為直接引用(入口地址)。這類方法的呼叫稱為“解析(Resolution)”。

在Java虛擬機器中提供了5條方法呼叫位元組碼指令:

  • invokestatic : 呼叫靜態方法
  • invokespecial:呼叫例項構造器方法、私有方法、父類方法
  • invokevirtual:呼叫所有的虛方法
  • invokeinterface:呼叫介面方法,會在執行時在確定一個實現此介面的物件
  • invokedynamic:先在執行時動態解析出點限定符所引用的方法,然後再執行該方法,在此之前的4條呼叫命令的分派邏輯是固化在Java虛擬機器內部的,而invokedynamic指令的分派邏輯是由使用者所設定的引導方法決定的。

3.2 分派

分派呼叫過程將會揭示多型性特徵的一些最基本的體現,如“過載”和“重寫”在Java虛擬中是如何實現的。

1 靜態分派

所有依賴靜態型別來定位方法執行版本的分派動作,都稱為靜態分派。靜態分派發生在編譯階段。

靜態分派最典型的應用就是方法過載。

package jvm8_3_2;

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("Human guy");

	}

	public void sayhello(Man guy) {
		System.out.println("Man guy");

	}

	public void sayhello(Woman guy) {
		System.out.println("Woman guy");
	}

	public static void main(String[] args) {
		Human man = new Man();
		Human woman = new Woman();
		StaticDispatch staticDispatch = new StaticDispatch();
		staticDispatch.sayhello(man);// Human guy
		staticDispatch.sayhello(woman);// Human guy
	}

}
複製程式碼

執行結果:

Human guy

Human guy

為什麼會出現這樣的結果呢?

Human man = new Man();其中的Human稱為變數的靜態型別(Static Type),Man稱為變數的實際型別(Actual Type)兩者的區別是:靜態型別在編譯器可知,而實際型別到執行期才確定下來。 在過載時通過引數的靜態型別而不是實際型別作為判定依據,因此,在編譯階段,Javac編譯器會根據引數的靜態型別決定使用哪個過載版本。所以選擇了sayhello(Human)作為呼叫目標,並把這個方法的符號引用寫到main()方法裡的兩條invokevirtual指令的引數中。

2 動態分派

在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。最典型的應用就是方法重寫。

package jvm8_3_2;

public class DynamicDisptch {

	static abstract class Human {
		abstract void sayhello();
	}

	static class Man extends Human {

		@Override
		void sayhello() {
			System.out.println("man");
		}

	}

	static class Woman extends Human {

		@Override
		void sayhello() {
			System.out.println("woman");
		}

	}

	public static void main(String[] args) {
		Human man = new Man();
		Human woman = new Woman();
		man.sayhello();
		woman.sayhello();
		man = new Woman();
		man.sayhello();
	}

}

複製程式碼

執行結果:

man

woman

woman

3 單分派和多分派

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

Java在進行靜態分派時,選擇目標方法要依據兩點:一是變數的靜態型別是哪個型別,二是方法引數是什麼型別。因為要根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派型別。

執行時階段的動態分派過程,由於編譯器已經確定了目標方法的簽名(包括方法引數),執行時虛擬機器只需要確定方法的接收者的實際型別,就可以分派。因為是根據一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派型別。

注:到JDK1.7時,Java語言還是靜態多分派、動態單分派的語言,未來有可能支援動態多分派。

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

由於動態分派是非常頻繁的動作,而動態分派在方法版本選擇過程中又需要在方法後設資料中搜尋合適的目標方法,虛擬機器實現出於效能的考慮,通常不直接進行如此頻繁的搜尋,而是採用優化方法。

其中一種“穩定優化”手段是:在類的方法區中建立一個虛方法表(Virtual Method Table, 也稱vtable, 與此對應,也存在介面方法表——Interface Method Table,也稱itable)。使用虛方法表索引來代替後設資料查詢以提高效能。其原理與C++的虛擬函式表類似。

虛方法表中存放的是各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的地址入口和父類中該方法相同,都指向父類的實現入口。虛方法表一般在類載入的連線階段進行初始化。

3.3 動態型別語言的支援

JDK新增加了invokedynamic指令來是實現“動態型別語言”。

靜態語言和動態語言的區別:

  • 靜態語言(強型別語言): 靜態語言是在編譯時變數的資料型別即可確定的語言,多數靜態型別語言要求在使用變數之前必須宣告資料型別。  例如:C++、Java、Delphi、C#等。
  • 動態語言(弱型別語言) : 動態語言是在執行時確定資料型別的語言。變數使用之前不需要型別宣告,通常變數的型別是被賦值的那個值的型別。  例如PHP/ASP/Ruby/Python/Perl/ABAP/SQL/JavaScript/Unix Shell等等。
  • 強型別定義語言 : 強制資料型別定義的語言。也就是說,一旦一個變數被指定了某個資料型別,如果不經過強制轉換,那麼它就永遠是這個資料型別了。舉個例子:如果你定義了一個整型變數a,那麼程式根本不可能將a當作字串型別處理。強型別定義語言是型別安全的語言。
  • 弱型別定義語言 : 資料型別可以被忽略的語言。它與強型別定義語言相反, 一個變數可以賦不同資料型別的值。強型別定義語言在速度上可能略遜色於弱型別定義語言,但是強型別定義語言帶來的嚴謹效能夠有效的避免許多錯誤。

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

虛擬機器如何呼叫方法的內容已經講解完畢,現在我們來探討虛擬機器是如何執行方法中的位元組碼指令。

4.1 解釋執行

Java語言經常被人們定位為 “解釋執行”語言,在Java初生的JDK1.0時代,這種定義還比較準確的,但當主流的虛擬機器中都包含了即時編譯後,Class檔案中的程式碼到底會被解釋執行還是編譯執行,就成了只有虛擬機器自己才能準確判斷的事情。再後來,Java也發展出來了直接生成原生程式碼的編譯器[如何GCJ(GNU Compiler for the Java)],而C/C++也出現了通過直譯器執行的版本(如CINT),這時候再籠統的說“解釋執行”,對於整個Java語言來說就成了幾乎沒有任何意義的概念,只有確定了談論物件是某種具體的Java實現版本和執行引擎執行模式時,談解釋執行還是編譯執行才會比較確切

解釋執行

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

4.2 基於棧的指令集和基於暫存器的指令集

Java編譯器輸出的指令流,基本上是一種基於棧的指令集架構(Instruction Set Architecture,ISA)依賴運算元棧進行工作。與之相對應的另一套常用的指令集架構是基於暫存器的指令集依賴暫存器進行工作

那麼,基於棧的指令集和基於暫存器的指令集這兩者有什麼不同呢?

舉個簡單例子,分別使用這兩種指令計算1+1的結果,基於棧的指令集會是這個樣子: iconst_1

iconst_1

iadd

istore_0

兩條iconst_1指令連續把兩個常量1壓入棧後,iadd指令把棧頂的兩個值出棧、相加,然後將結果放回棧頂,最後istore_0把棧頂的值放到區域性變數表中的第0個Slot中。

如果基於暫存器的指令集,那程式可能會是這個樣子:

mov eax, 1

add eax, 1

mov指令把EAX暫存器的值設定為1,然後add指令再把這個值加1,將結果就儲存在EAX暫存器裡面。

基於棧的指令集主要的優點就是可移植,暫存器是由硬體直接提供,程式直接依賴這些硬體暫存器則不可避免地要受到硬體的約束。

棧架構的指令集還有一些其他的優點,如程式碼相對更加緊湊,編譯器實現更加簡單等。 棧架構指令集的主要缺點是執行速度相對來說會稍微慢一些。

總結

本節中,我們分析了虛擬機器在執行程式碼時,如何找到正確的方法、如何執行方法內的位元組碼,以及執行程式碼時涉及的記憶體結構。

歡迎關注我的微信公眾號:"Java面試通關手冊"(一個有溫度的微信公眾號,期待與你共同進步~~~堅持原創,分享美文,分享各種Java學習資源):

微信公眾號

相關文章