深刻學習Java虛擬機——虛擬機字節碼執行引擎

 

1. 運行時棧幀結構

1.1 認識棧幀

    1. 棧幀:用於支持虛擬機方法調用和方法執行的數據結構,它是由虛擬機運行時數據區中的虛擬機棧的棧元素。棧幀存儲了方法的局部變量表、操做數棧、動態鏈接和方法返回值地址等信息。每個方法從調用開始到執行完成的過程都對應着一個棧幀的入棧到出棧。在代碼編譯完成時,棧幀中須要多大的局部變量表,多深的操做數棧都已經徹底肯定,而且寫入到方法表的Code屬性中。對於執行引擎來講,在活動線程中,只有位於虛擬機棧頂的棧幀纔是有效的,或者說執行引擎的全部字節碼指令都只針對當前棧幀操做,最頂端的棧幀被稱爲當前棧幀,這個棧幀所對應的方法叫當前方法。棧幀結構的概念模型以下java

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」)this

 

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,而對於下一段代碼spa

 

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執行時也會有接口方法表),使用虛方法表索引來代替元數據查找。

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

    方法表通常在類加載的鏈接階段進行初始化,準備了類的變量初始值後,虛擬機會把該類的方法表也初始化完畢。

    除了上面分派調用的優化手段以外,還有內聯緩存和守護內聯兩種方法來獲取更高性能。

相關文章
相關標籤/搜索