虛擬機器位元組碼執行引擎 JVM筆記4
阿新 • • 發佈:2019-01-01
目錄
概述
- 輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果。
執行時棧幀結構
- 棧幀是用於支援虛擬機器進行方法呼叫和方法執行的資料結構。它是虛擬機器執行時資料區中的
虛擬機器棧
的棧元素。 - 儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址等資訊。
- 在編譯程式程式碼時,棧幀中需要多大的區域性變量表、多深的運算元棧
已經完全確定
區域性變量表
- 是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。
- 最小單位為
變數槽
(Variable Slot)。虛擬機器規範中說明每個Slot都應該能存放一個boolean、byte、char、short、int、float、reference或returnAdderss型別的資料。每個Slot可以存放一個32位以內的資料型別。對於64位的資料型別(long、double),虛擬機器會以高位在前的方式為其分配兩個連續的Slot空間。 - 虛擬機器採用
索引定位
- 在方法執行時,虛擬機器使用區域性變量表完成引數值到引數變數列表的傳遞過程,如果是例項方法,那麼區域性變量表第0位索引的Slot預設是用於傳遞方法所屬物件的引用,在方法中可以通過“this”來訪問這個隱含引數。
- 類變數因為有兩次賦初始值的過程,所以有準備階段的預設初始,所以在初始化階段即使不賦值也沒關係。但是區域性變數必須要有一個初始值,否則編譯會報錯。
- Slot是可重用的,並不一定會覆蓋整個方法體,如果PC計數器超出某個變數的作用域,那麼這個變數對應的Slot就可以交給其他變數使用。
運算元棧
- 也被稱為操作棧,是一個後進先出棧,其最大深度在編譯時便已經確定。
- 32位資料棧容量為1,64位資料棧容量為2。
- 在方法剛開始進行時,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令向運算元棧中寫入和提取內容,也就是出棧入棧操作。例如在進行算術運算和呼叫其他方法的時候時通過運算元棧來進行的。
例子
整數加法的位元組碼指令iadd在執行的時候要求運算元棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會將這兩個int值出棧並相加,然後將相加的結果入棧。
- 運算元棧中的元素型別必須與位元組碼指令的序列嚴格匹配,在編譯程式程式碼的時候,編譯器要嚴格保證。
- 大多數虛擬機器實現中會讓兩個棧幀出現一部分
重疊
,讓下面棧幀的部分運算元棧與上面棧幀的部分區域性變量表重疊在一起,狀態在進行方法呼叫時就可以公有一部分資料,而無需進行額外的引數傳遞。 - Java虛擬機器的解釋引擎稱為基於棧的執行引擎,這裡的
棧
就是指運算元棧。方法返回地址
- 當一個方法被執行後,有兩種方法退出這個方法:
- 正常完成出口:(Normal Method Invacation Completion)執行引擎遇到任意一個方法返回的位元組碼指令,這時可能會有返回值傳遞給上層的方法呼叫者,是否有返回值和返回值的型別將根據遇到何種方法返回指令來決定。
- 異常完成出口:(Abrupt Method Invocation Completion)在方法執行過程中遇到了異常,並且這個異常沒有在方法體得到異常處理,在本方法的異常表沒有搜尋到匹配的異常處理器,就會導致方法退出,不會給它的上層呼叫者返回任何返回值。
- 一般來說,方法正常退出時,呼叫者的PC計數器的值就可以作為返回值,棧幀中很可能儲存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器來確定的,棧幀中一般不會儲存這部分資訊。
- 方法退出的過程實際上等同於
當前棧幀出棧
,因此退出可能執行的操作有:恢復上層方法的區域性變量表和運算元棧,把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中,調整PC計數器的值以指向方法呼叫指令後面的一條指令等。附加資訊
- 虛擬機器規範允許具體的虛擬機器實現增加一些規範中沒有描述的資訊到棧幀中,例如與除錯有關的資訊,這部分資訊完全取決於具體的虛擬機器實現。一般把動態連線,方法返回資訊與其他附加資訊全部歸為一類,稱為
棧幀資訊
。方法呼叫
- 方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法)。
解析
- 方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。這類方法的呼叫稱為
解析
(Resolution)。 - 滿足以上條件的方法主要有
靜態方法
和私有方法
兩大類。前者與型別直接關聯,後者在外部不可被方法。它們都不可能通過繼承或別的方式重寫出其他版本。 - 只要能被invokestatic和invokespecial指令呼叫的方法,都能在解析階段確定唯一的呼叫版本,符合這個條件的有靜態方法、私有方法、例項構造器和父類方法四類。這些方法被稱為
非虛方法
。其他方法被稱為虛方法
(除去final方法)。在Java語言規範中明確說明final方法也是一種非虛方法。 - 解析呼叫一定是一個
靜態的過程
,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變為可確定的直接引用,不會延遲到執行期才去完成。
分派
- 分派呼叫可能是靜態也可能是動態的,根據分派的宗量數可分為單分派和多分派。綜合分為四種分派情況。
靜態分派
- 所有依賴靜態型別來定位方法執行版本的分派動作,都被稱為
靜態分派
。最典型的應用便是方法過載。
/**
*Woman和Man是繼承Human的子類
*/
puclic class Test{
public void sayHello(Human guy){
System.out.println("Hello,guy!");
}
public void sayHello(Woman guy){
System.out.println("Hello,woman!");
}
public void sayHello(Man guy){
System.out.println("Hello,man!");
}
Human man = new Man();
Human women = new Women();
public static void main(String args){
Test sr;
sr.sayHello(man);
sr.sayHello(woman);
}
}
實際輸出結果為:
hello,guy!
hello,guy!
Human man = new Man();
我們把以上程式碼“Human”稱為變數的靜態型別(Static Type)或者外觀型別(Apparent Type),後面的Man為實際型別(Actual Type)。靜態型別僅僅在使用時才可以變化,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的。而實際型別變數的結果在執行期才可以確定,編譯器在編譯程式時並不知道一個物件的實際型別時什麼。如以下程式碼:
//實際型別變化
Human man = new Man();
man = new Women();
//靜態型別變化
sr.sayHello((Man) man)
sr.sayHello((Women) man)
- 在方法確認是物件“sr”的前提下,使用哪個過載版本,完全取決於傳入引數的數量和資料型別。編譯器在過載是通過引數的
靜態型別
而不是實際型別作為判斷依據。原因在於靜態型別在編譯器可知。
動態分派
- 我們把在執行期根據實際型別確定方法執行版本的分派過程稱為
動態分派
。
/**
*動態分派演示
*/
puclic class Test{
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 Women extends Human{
@Override
protected void sayHello(){
System.out.println("women say hello");
}
}
public static void main(String args){
Human man = new Man();
Human women = new Women();
man.sayHello(man);
women.sayHello(woman);
man = new Women();
man.sayHello();
}
}
執行結果:
man say hello
women say hello
women say hello
- 在這裡不是通過靜態型別來決定的,導致這個現象的原因在於兩個變數的
實際型別
不同。 - Java虛擬機器根據實際型別來分派的方法,單從位元組碼的角度來看,都是用
invokevirtual
指令,步驟如下:- 找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
- 如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗(比如是否為private),如果通過則返回這個方法的直接引用,查詢過程結束;不通過則返回java.lang.IllegalAccessError異常。
- 否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜尋和驗證。
- 如果始終沒有找到合適的方法,則丟擲java.lang.AbractMethodError異常。
- 由於invokevirtual執行的第一步就是在執行期確定接收者的實際型別,所以在上面的程式碼演示中,解析到了不同的直接引用上,這個過程就是Java語言中
方法重寫
的實質。
單分派與多分派
- 方法的接收者與方法的引數統稱為
方法的宗量
。單分派
是根據一個宗量對目標進行選擇,多分派
則是根據多於一個的宗量對目標方法進行選擇。 - 今天的Java語言是一門
靜態多分派、動態單分派
的語言。
虛擬機器動態分派的實現
- 虛擬機器的實際實現中基於效能的考慮,大部分實現都不會進行繁瑣的搜尋。最常用的方法是為類在方法區中建立一個
虛方法表
。 - 虛方法表中存放著各個方法的實際入口地址。如果某個沒有被重寫,則子類的虛方法表裡面的地址入口和父類相同方法的地址入口一致,如果子類重寫了這個方法,那麼子類方法表的地址將會被替換為指向子類實現版本的入口地址。
- 除了方法表這種“穩定優化”手段,還會使用內聯快取(Inline Cache)和基於型別繼承關係分析(Class Hiserachy Analysis,CHA)技術的守護內聯(Guarded Inlining)兩種非穩定的“激進優化”手段來獲取更高的效能。
基於棧的位元組碼解釋執行引擎
- 基於棧的指令集最主要的優點就是可移植性,暫存器由硬體直接提供,程式直接依賴這些硬體暫存器則不可避免地受到硬體地約束。使程式碼相對更緊湊,編譯器實現更加簡單等。主要缺點是執行速度相對來說稍慢一些。
- 基於棧的指令集和基於暫存器的指令集的不同,以計算“1+1”為例:
基於棧的指令集
:
inconst_1
inconst_1
iadd
istore_0
連續將1壓入棧,取出棧頂兩個值出棧並相加,然後將結果放回棧頂。基於暫存器的指令集
:
mov eax,1 add eax,1
將暫存器的值設為1,然後再把這個值加1,結果就儲存在EAX暫存器裡面。