深刻理解虛擬機之虛擬機字節碼執行引擎

1 概述

執行引擎是java虛擬機最核心的組成部件之一。虛擬機的執行引擎由本身實現,因此能夠自行定製指令集與執行引擎的結構體系,而且可以執行那些不被硬件直接支持的指令集格式。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空間。jvm

虛擬機經過索引定位的方式使用局部變量表,索引值的範圍從0開始至局部變量表最大的Slot數量。訪問的是32位數據類型的變量,索引n就表明了使用第n個Slot,若是是64位數據類型,就表明會同時使用n和n+1這兩個Slot。ide

爲了節省棧幀空間,局部變量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學習資源):

微信公衆號
相關文章
相關標籤/搜索