1. 程式人生 > >讀書筆記 ---- 《深入理解Java虛擬機器》---- 第7篇:虛擬機器位元組碼執行引擎

讀書筆記 ---- 《深入理解Java虛擬機器》---- 第7篇:虛擬機器位元組碼執行引擎

上一篇:虛擬機器類載入機制:https://blog.csdn.net/pcwl1206/article/details/84260914

第7篇:虛擬機器位元組碼執行引擎

執行引擎是Java虛擬機器最核心的組成部分之一。“虛擬機器”是一個相對於“物理機”的概念,這兩種機器都有程式碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、硬體、指令集和作業系統層面上的,而虛擬機器的執行引擎則是由自己實現的,因此可以自行制定指令集與執行引擎的結構體系,並且能夠執行那些不被硬體直接支援的指令集格式。

所有的Java虛擬機器的執行引擎都是一致的:輸入的是位元組碼檔案,處理的過程是位元組碼解析的等效過程,輸出的是執行結果

1、執行時棧幀結構

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

  • 棧幀儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址等資訊。

  • 每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。

  • 在編譯程式碼的時候,棧幀中需要多大的區域性變量表,多深的運算元棧都已經完全確定了,並且寫入到方法表的Code屬性中,因此一個棧幀需要分配多少記憶體,不會受到執行期變數資料的影響,而僅僅取決於具體的虛擬機器實現。

  • 一個執行緒中的方法呼叫鏈可能會很長,很多方法都同時處於執行狀態。對於執行引擎來說,在活動執行緒中,只有位於棧頂的棧幀才是最有效的,稱為當前棧(Current  Stack  Frame),與這個棧幀相關聯的方法稱為當前方法(Current  Method)。執行引擎執行的位元組碼指令都只針對當前棧幀進行操作。

1.1  區域性變量表

區域性變量表(Local  Variable  Table)是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。在Java程式編譯為Class檔案時,就在方法的Code屬性的max_locals資料項中確定了該方法所需要分配的區域性變量表的最大容量。

  • 區域性變量表的容量以變數槽(Slot)為最小單位,每個Slot都應該能存放下一個boolean、byte、char、short、int、float、reference或returnAddress型別的資料,對於64位的資料型別只有double和long
    兩種(reference可能是32位也可能是64位),這兩種資料型別佔兩個Slot。
  • 虛擬機器是使用區域性變量表完成引數值到引數變數列表的傳遞過程,如果執行的是例項方法(非static方法),那區域性變量表中的第0位索引的Slot預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字“this”來訪問到這個隱含的引數。其餘引數則按照引數列表排列,佔用從1開始的區域性變量表Slot,引數表分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的Slot
  • 區域性變量表中的Slot是可以重用的,方法體中定義的變數,其作用域並不一定覆蓋整個方法體,如果當前位元組碼PC計數器的值已經超出了某個變數的作用域,那這個變數對應的Slot就可以交給其他變數使用。但是Slot的複用會影響到垃圾回收。具體影響這裡不再詳解,可看書上241頁案例講解。
  • 類變數有兩次的賦值過程,一次在準備階段,賦予系統初始值(比如:int預設值為0,booelan預設值為false,object型別預設為null),另外一次在初始化階段,賦予程式設計師定義的初始值。因此即使在初始化階段,程式設計師沒有對類變數再進行賦值也沒有關係,類變數仍然具有一個預設的初始值。但是區域性變數如果定義了但是沒有在初始化階段賦予初始值,那麼將導致類載入失敗。如下所示:
// 未賦值的區域性變量表,將導致載入失敗
public static void main(String[] args){
    int a;  // 定義了,但是未賦值
    System.out.println(a);
} 

1.2  運算元棧

運算元棧(Operand)也常稱為操作棧,它是一個後入先出(Last  In  First  Out,LIFO)棧。同區域性變量表一樣,運算元棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks資料項中。

當一個方法剛剛開始的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是出棧/入棧操作。

兩個棧幀之間的資料共享:下面的棧幀的部分運算元棧與上面棧幀的部分區域性變量表重疊一起,這樣在進行方法呼叫時就可以共用一部分資料,無須進行額外的引數賦值傳遞。

  • Java虛擬機器的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的“棧”就是運算元棧。

1.3  動態連線

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

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

1.4  方法返回地址

當一個方法開始執行後,只有兩種方式可以退出這個方法:

第一種是:執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者,是否有返回值和返回值型別將根據遇到何種方法返回指令來決定,這種退出的方法稱為正常完成出口

第二種是:在方法的執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是Java虛擬機器內部產生的異常,還是程式碼中使用athrow位元組碼指令產生的異常,只要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口。一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者產生任何返回值的。

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

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


2、方法呼叫

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

Class檔案的編譯過程中不包括傳統編譯中的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址(相當於之前說的直接引用)。

2.1  解析

所有方法呼叫中的目標方法在Class檔案裡面都是一個常量池的符號引用,在類載入的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。符合這個條件的有:靜態方法、私有方法、例項構造器和父類方法四類,它們在載入的時候會把符號引用解析為該方法的直接引用。

解析呼叫一定是一個靜態的過程,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變為可確定的直接引用,不會延遲到執行期再去完成。

2.2  分派

分派呼叫可以是靜態的也可以是動態的,根據分派依據的宗量數可分為單分派和多分派。多分派機制與Java的多型(過載和重寫)機制關係密切。比如:過載的方法有多個,那麼應該選擇那一個呢?如何選擇?

分派的具體分類如下:

  1. 靜態分派:依賴靜態型別來定位方法執行版本的分派動作,稱為靜態分派。靜態分派的最典型的應用就是方法過載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機器來執行的;
  2. 動態分派:在執行期根據實際型別來確定方法執行版本的分派呼叫過程稱為動態分派。這和多型的另一個體現----重寫有著密切的關係;
  3. 單分派:根據一個宗量對目標方法進行選擇;
  4. 多分派:根據多於一個的宗量對目標方法進行選擇。

1、靜態分派

如下程式碼中定義了兩個靜態型別相同但實際型別不同的變數Man和Woman,但虛擬機器(準確說是編譯器)在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。在編譯階段,Javac編譯器會根據引數的靜態型別決定使用哪個過載版本,所以選擇了sayHello(Human)作為呼叫目標。

Human man = new Man();

程式碼中的“Human”稱為:變數的靜態型別/外觀型別;“Man”稱為:變數的實際型別。

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);    // hello, guy!
		sd.sayHello(woman);  // hello, guy!
	}
}
// 實際型別變化
Human man = new Man();
man = new Woman();

// 靜態型別變化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

說明:書上對過載方法匹配的優先順序做了案例講解,這裡不再貼程式碼了,需要可以看書249頁。

2、動態分派

看下面的案例中的程式碼:顯然這裡不可能再根據靜態型別來決定,因為靜態型別同樣是Human的兩個變數man和woman在呼叫sayHello()方法時執行了不同的行為。導致這個現象的原因是這兩個變數的實際型別不同,Java虛擬機器根據實際型別來分派方法的執行版本。

public class DynamicDispatch {

	static abstract class Human{
		protected abstract void sayHello();
	}
	
	static class Man extends Human{

		@Override
		protected void sayHello() {
			System.out.println("man say hello");
		}
	}
	
	static 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();       // man say hello
		woman.sayHello();     // woman say hello
		man = new Woman();
		man.sayHello();       // woman say hello
	}
}

3、單分派與多分派

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

public class Dispatch {

	static class QQ{}
	
	static class _360{}
	
	public static class Father{
		public void hardChoice(QQ args){
			System.out.println("father choice qq");
		}
		
		public void hardChoice(_360 args){
			System.out.println("father choice 360");
		}
	}
	
	public static class Son extends Father{
		public void hardChoice(QQ args){
			System.out.println("son choice qq");
		}
		
		public void hardChoice(_360 args){
			System.out.println("son choice 360");
		}
	}
	
	public static void main(String[] args) {
		Father father = new Father();
		Father son = new Son();
		father.hardChoice(new _360());    // father choice 360
		son.hardChoice(new QQ());         // son choice qq
	}
}

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

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

綜上:現在的Java語言是一門靜態多分派、動態單分派的語言

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

虛擬機器在分派中“會做什麼?”上面已經講了,那麼虛擬機器“具體是如何做到的?”,不同虛擬機器會有所差別。

動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在類的方法元資料中搜索合適的目標方法,因此在虛擬機器實際實現中基於效能的考慮,大部分實現都不會真正進行如此頻繁的搜尋。

常用的穩定優化手段是為類在方法區中建立一個虛方法表。虛方法表存放著各個方法的實際入口地址。

2.3  動態語言支援

動態型別語言的關鍵特徵是:它的型別檢查的主體過程是在執行期而不是在編譯期

在編譯期就進行型別檢查過程的語言(Java和C++等)就是最常用的靜態語言。JDK1.7中新增invokedynamic指令用於支援動態語言。

super關鍵字可以訪問到父類中的方法,如果要訪問祖父的方法可以使用invokedynamic指令的分派,它的邏輯就是按照方法接收者的實際型別進行分派。


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

上面主要講訴瞭如何呼叫方法的內容,本節對虛擬機器是如何執行方法中的位元組碼指令進行講解。虛擬機器的執行引擎在執行Java程式碼的時候都有解釋執行編譯執行兩種選擇。

3.1  解釋執行  

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

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

Java編譯器輸出的指令流,基本上是一種基於棧的指令集架構。

基於棧的指令集的優缺點:

優點

1、可移植;

2、程式碼相對更加緊湊;

3、編譯實現更加簡單。

缺點

1、指令數多,出棧入棧產生太多的指令;

2、頻繁訪問記憶體,增加CPU壓力,執行速度相對較慢

3.3 基於棧的解釋執行過程

看下下面這個例子,再把執行過程圖貼出來,但是注意這裡的執行過程僅僅是一種概念模型,虛擬機器往往會對其中的執行過程進行優化以提高效能。

先看一段簡單的算術程式碼:

public int calc(){
    int a = 100;
    int b = 200;
    int c = 300;
    return (a+b)*c;
}

使用javap命令檢視它的位元組碼指令:

javap提示這段程式碼需要深度為2的運算元棧和4個Slot的區域性變數空間。

具體執行過程如下:


上一篇:虛擬機器類載入機制:https://blog.csdn.net/pcwl1206/article/details/84260914

下一篇:

參考及推薦:

1、虛擬機器位元組碼執行引擎:https://blog.csdn.net/a724888/article/details/78404643