深入學習Java虛擬機器——虛擬機器位元組碼執行引擎

江左煤郎發表於2018-08-31

1. 執行時棧幀結構

1.1 認識棧幀

    1. 棧幀:用於支援虛擬機器方法呼叫和方法執行的資料結構,它是由虛擬機器執行時資料區中的虛擬機器棧的棧元素。棧幀儲存了方法的區域性變數表、運算元棧、動態連線和方法返回值地址等資訊。每一個方法從呼叫開始到執行完成的過程都對應著一個棧幀的入棧到出棧。在程式碼編譯完成時,棧幀中需要多大的區域性變數表,多深的運算元棧都已經完全確定,並且寫入到方法表的Code屬性中。對於執行引擎來說,在活動執行緒中,只有位於虛擬機器棧頂的棧幀才是有效的,或者說執行引擎的所有位元組碼指令都只針對當前棧幀操作,最頂端的棧幀被稱為當前棧幀,這個棧幀所對應的方法叫當前方法。棧幀結構的概念模型如下

timg?image&quality=80&size=b9999_10000&s

1.2 棧幀中的資料區域之一——區域性變數表

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

    2. 區域性變數表的容量的最小單位:變數槽,即Slot,一個Slot所佔記憶體大小沒有明確指定,但每個Slot都應該能夠儲存一個32位以內的資料型別,比如boolean、byte、short、char、int、float、reference(也有64位的)和returnAddress8種型別。對於reference,虛擬機器應當能通過這個引用直接或間接地查詢物件在Java堆中資料存放的起始地址索引,還可以通過此引用直接或間接的查詢到物件所屬的資料型別在方法區中的儲存的型別資訊。

而對於long和double(還有64位的reference型別的資料)這類64位的資料型別,虛擬機器會以高位對齊的方式為其分配兩個連續的Slot空間,而這種分割儲存的方式也導致了在進行讀寫時也會分割為兩次32位讀寫,但對於區域性變數是執行緒私有的,不會出現資料安全問題,而且虛擬機器也不允許任何方式單獨的訪問64位資料的兩個Slot空間中的某一個,而之所以會出現在多執行緒中處理64位資料出現資料安全問題的原因在我的部落格的多執行緒部分也會有解釋。

對於例項方法(非static)的區域性變數表,其中的第一個也就是第0位索引的Slot儲存的是當前方法的類的例項物件的引用,在方法中可以通過關鍵字 this 來訪問這個隱含引數。然後其餘方法引數再按照參數列的順序進入區域性變數表,佔用從索引1開始的Slot,參數列分配完畢後,再分配方法體內的其他區域性變數。

    3. Slot的複用:為了儘可能節省棧空間,區域性變數表中的Slot可複用。方法體中定義的變數其作用域不一定會覆蓋整個方法體,如果程式計數器(程式計數器,當前棧中執行位元組碼的行號指示器)的值超過了某個變數的作用域,那麼該變數對應的Slot就可以交給其他變數使用。比如說以下程式碼

public void main(String[] args){
    int[] arr=new int[10];
    for(int i=0;i<10;i++){
        arr[i]=i;
    }
    int m=1;
    System.out.println(arr);
}

其中區域性變數m就有可能佔用變數i的Slot

    4. Slot複用對垃圾回收工作的影響:以三段程式碼的比較為例

public void main(String[] args){
    byte[] arr=new byte[1024*1024];
    System.gc();
}

這段程式碼很簡單,即向記憶體填充的1Mb的資料,然後呼叫gc進行垃圾回收,但是並不會回收arr所佔的記憶體空間,因為gc執行時arr還在作用域內,或者說main方法還沒有返回退出,所以虛擬機器不能回收arr的記憶體。(觀察GC過程可以新增執行引數“-verbose:gc”)

 

public void main(String[] args){
    {
        int[] arr=new int[1024*1024];
    }
    System.gc();
}

 這段程式碼中,arr的作用域被限制在花括號之中,從程式碼邏輯上看,執行gc時arr已經不可能被訪問,gc應該可以對arr進行回收工作,但是實際上卻沒有,因為即使位元組碼執行已經超過了arr的作用域,但是在區域性變數表的Slot中並沒有進行新的Slot讀寫操作,也就是說arr這個引用仍然佔用著原來的Slot空間,那麼arr仍然引用著他的陣列物件,所以此時gc判斷對於arr引用所指向的陣列物件仍然與arr存在關聯,也就無法進行gc,而對於下一段程式碼

 

public void main(String[] args){
    {
        int[] arr=new int[1024*1024];
    }
    int m=1;
    System.gc();
}

在新增一行int m=1的程式碼之後,執行程式,可以發現arr可以被gc回收了。因為 int m=1 這行程式碼就對arr所佔用的Slot空間進行了複用,或者說對arr所佔據的Slot空間進行的讀寫操作,刪除了arr引用在Slot空間中的資料,導致arr的陣列物件失去了關聯的引用,此時gc就可以進行回收了。所以,在日常應用中,如果遇到像arr這種前一部分程式碼定義了一些佔據較大空間且後面不在使用的變數,而後面的程式碼又會有耗時較長的操作,在這種情況下推薦將arr這種型別的引用設定為null值。

1.3  棧幀中的資料區域之一——運算元棧

    1. 一個先入後出的棧結構。運算元棧的最大深度在編譯後便已經確定,並寫入Code屬性的max_stacks資料項中。運算元棧中的每一個元素可以是任意的Java資料型別,包括long,double。但是,對於32位長度的資料型別,佔一個棧容量,64位的資料型別佔2個。

    2. 運算元棧的執行:方法剛開始執行時,運算元棧為空,在方法執行過程中會有各種位元組碼指令向棧中寫入或讀取內容,也就是出/入棧操作。做算數運算用運算元棧執行,或者呼叫其他方法時通過運算元棧來進行引數傳遞。比如執行整數相加的位元組碼指令iadd,會將運算元棧存放在最頂端的兩個int型別數值進行相加並且將這兩個值出棧,然後將相加的結果入棧,將結果賦予某變數時就會將該結果值出棧。

1.3  棧幀中的資料區域之一——動態連線

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

1.4 棧幀中的資料區域之一——方法返回地址

    1. 方法返回的方式:第一種是執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的呼叫者,這種退出方式叫正常完成出口;另一種方式是方法執行過程中出現異常,且方法體內沒有任何對這個異常的處理,就會導致方法退出,這種退出方式叫異常完成出口,異常完成不會給上層呼叫者任何返回值。

    2. 方法返回地址:如論何種方式退出方法,都要返回到被呼叫的位置,程式才能繼續執行,所以棧幀中會儲存一些資料來恢復上層方法的執行狀態,這一部分資料就是方法返回地址。一般來說,呼叫者的程式計數器的值可以作為返回地址,方法返回地址可能就會儲存這個值,而方法異常退出時,棧幀一般不會儲存這個資訊。

    3. 當前方法退出時可能執行的操作步驟有:恢復上層方法的區域性變數表和運算元棧,把返回值(如果有)壓入呼叫者棧幀的運算元棧中,調整呼叫者程式計數器的值指向方法呼叫的下一條指令

2. 方法呼叫

    方法呼叫不是方法執行,而是確定執行的是哪一個方法,或者說是哪一個版本的方法。

2.1 解析

所有方法在Class檔案中都是常量池中的一個符號引用,在類載入過程的解析階段中,會將其中一部分符號引用替換為直接引用,而實現這一步的前提是編譯時就能確定所執行的方法版本(執行的是哪一個方法),並且這個方法的呼叫版本在執行期不可更改,這類方法的呼叫就叫解析。

滿足這兩種條件(編譯器可知,執行期不可變)的方法主要是靜態方法和私有方法兩類,也就是說不可能通過繼承或其他方式被重寫的方法,都適合在類載入階段解析。

    1. 虛擬機器中5中方法呼叫指令:

(1)invokestatic:呼叫靜態方法

(2)invokespecial:呼叫例項構造器方法、私有方法、父類方法

(3)invokevirtual:呼叫虛方法。

(4)invokeinterface:呼叫介面方法。

(5)invokedynamic:先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法。

invokestatic和invokespecial指令呼叫的方法都可以在解析階段確定唯一的呼叫版本,比如靜態方法、私有方法、例項構造器、父類方法4類,他們在類載入時就會將符號引用替換為直接引用,這些方法被稱為非虛方法。其他方法(final方法除外)為虛方法。

fianl方法也是非虛方法的一種,雖然final方法由invokevirtual指令呼叫,但其符合非虛方法的特點,即無法覆蓋,沒有其他版本,多型選擇的結果肯定是唯一的,所以final方法是非虛方法。

解析呼叫一定是一個靜態的過程,在編譯期就完全確定,類載入過程中將涉及的符號引用全部替換為確定的直接引用。而分派呼叫可能是靜態也可能是動態,還可分為單分派和多分派,這兩類分派方式組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派。

2.2 分派

Java具有物件導向的3個基本特徵:繼承、封裝和多型,對於方法的過載與重寫,分派是虛擬機器正確定位目標方法的關鍵。

    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 man){
		System.out.println("hello man");
	}
	public void sayHello(Woman woman){
		System.out.println("hello woman");
	}
	public static void main(String[] args) {
		StaticDispatch s=new StaticDispatch();
		Human m1=new Man();
		Human m2=new Woman();
		s.sayHello(m1);
		s.sayHello(m2);
	}
}
//輸出結果
hello guy
hello guy

在上面這段程式碼中,“Human”稱為變數的靜態型別,或者叫做外觀型別,後面的“Man”則稱為變數的實際型別,靜態型別和實際型別在程式中都可以發生一些變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的;而實際型別變化的結果在執行期才可以確定,編譯程式在編譯期並不知道一個物件的具體型別是什麼。對於過載方法的呼叫,完全取決於引數數量和資料型別。編譯期在過載時是通過引數的靜態型別而不是實際型別作為判斷依據的,並且靜態型別是編譯期可知的,因此,在編譯階段編譯器就會根據靜態型別決定用哪個過載版本,所以選擇了sayHello(Human)作為呼叫目標,並把這個方法的符號引用寫到main方法裡的兩條invokevirtual指令的引數中。

所有依賴靜態型別(引用的型別)來定位具體執行方法版本的分派動作稱為靜態分派,靜態分派的典型應用就是過載。靜態分派發生在編譯階段,因此確定靜態分派的動作是由編譯器執行。另外,編譯器能確定方法過載的版本,但過載版本有時並不是唯一的,往往只能選擇一個更加合適的版本。比如sayHello(int)、sayHello(long)、sayHello(char),如果方法呼叫為sayHello(‘a’),那麼首先會呼叫sayHello(char),如果沒有sayHello(char)方法,就會呼叫sayHello(int),然後才是sayHello(long)。

    2. 動態分派——重寫

public class DynamicDispatch {
	static abstract class Human{
		public abstract void sayHello();
	}
	static class Man extends Human{
		public void sayHello(){
			System.out.println("man");
		}
	}
	static class Woman extends Human{
		public 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
woman

(1)在這裡自然不可能根據靜態型別來決定方法的呼叫,而是通過物件的實際型別來找到相應的方法。

man和woman這兩個物件是將要執行的sayHello方法的所有者,也成為接收者,而編譯後的位元組碼檔案中兩行sayHello方法的呼叫指令invokevirtual執行的方法通過索引值(索引值指向常量池中的符號引用,該符號引用對應方法 Human.sayHello())來看是同一個方法,但最終執行的目標方法卻不同。這就是因為invokevirtual指令在執行時解析方法的符號引用的過程大概如下

  • 找到運算元棧頂的第一個元素所指向的物件的實際型別(因為呼叫方法首先會把引用從區域性變數表壓入運算元棧頂,然後通過引用找到物件),記為型別C。
  • 如果在型別C中找到與索引值對應的常量池中的常量中描述符和簡單名稱都相符的方法,則進行許可權校驗,如果通過則返回這個方法的符號引用所對應的直接引用,查詢過程結束;如果許可權校驗不通過,則丟擲java.lang.IllegalAccessError異常。
  • 否則按照繼承關係從子類向上對C的父類進行第2步的查詢和驗證過程。
  • 如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。

invokevirtual指令的執行就是方法重寫的本質,在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。

    3. 單分派與多分派

方法的接收者和方法的引數統稱為方法的宗量,單分派就是根據一個宗量對目標方法進行選擇,多分派就是根據多個宗量來對目標方法進行選擇。Java中,靜態分派(比如過載)通過接收者的靜態型別以及方法引數進行選擇目標方法,所以Java的靜態分派是多分派型別。而動態分派(重寫)只依據接收者的實際型別來選擇目標方法,也就是一個宗量,所以動態分派也是單分派型別。所以,Java語言是一門靜態多分派,動態單分派的語言。

    4. 動態分派的優化實現

    動態分配的方法選擇過程中需要執行時在類的方法後設資料中搜尋合適的目標方法,而且動態分派動作很頻繁,所以為了優化虛擬機器效能,會為類在虛擬機器的方法區中建立一個虛方法表(專門儲存虛方法索引的,呼叫該方法時會執行invokevirtual位元組碼指令的方法,而對應的,在invokeinterface執行時也會有介面方法表),使用虛方法表索引來代替後設資料查詢。

    虛方法表中存放著各個方法的實際入口地址,如果某個方法在子類中沒有被重寫,那麼子類的虛方法表裡面的該方法地址入口和父類中的虛方法表裡面的該方法是一樣的,都指向父類的實現入口;如果過子類重寫了該方法,那麼子類方法表中的地址將會替換為指向子類實現版本的入口地址。

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

    除了上面分派呼叫的優化手段之外,還有內聯快取和守護內聯兩種方法來獲取更高效能。


相關文章