1. 程式人生 > >[深入理解JVM 六]---虛擬機器位元組碼執行系統

[深入理解JVM 六]---虛擬機器位元組碼執行系統

前邊介紹了jvm的執行時記憶體分配,類檔案的結構,以及類載入機制,這樣,一個編譯好的class二進位制位元組碼檔案就已經被載入完畢,等待下一步的執行。接下來分幾個部分來介紹這部分內容。首先方法的呼叫和執行依賴於虛擬機器棧,第一部分詳細介紹一下虛擬機器棧的棧幀結構。第二部分介紹方法的呼叫。第三部分介紹方法的執行。寫的過程中參照了《深入理解java虛擬機器》的虛擬機器執行子系統和之前提到的大牛的這篇部落格http://blog.csdn.net/ns_code/article/details/17965867,這裡註明出處

棧幀結構

棧幀(Stack Frame)是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中的虛擬機器棧(Virtual Machine Stack)的棧元素。棧幀儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址

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

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

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

這裡寫圖片描述

區域性變量表

區域性變量表是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數,其中存放的資料的型別是編譯期可知的各種基本資料型別、物件引用(reference)和returnAddress型別(它指向了一條位元組碼指令的地址)。區域性變量表所需的記憶體空間在編譯期間完成分配,即在Java程式被編譯成Class檔案時,就確定了所需分配的最大區域性變量表的容量當進入一個方法時,這個方法需要在棧中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變量表的大小。

區域性變量表的容量以變數槽(Slot)為最小單位。在虛擬機器規範中並沒有明確指明一個Slot應占用的記憶體空間大小(允許其隨著處理器、作業系統或虛擬機器的不同而發生變化),一個Slot可以存放一個32位以內的資料型別:boolean、byte、char、short、int、float、reference和returnAddresss。reference是物件的引用型別,returnAddress是為位元組指令服務的,它執行了一條位元組碼指令的地址。對於64位的資料型別(long和double),虛擬機器會以高位在前的方式為其分配兩個連續的Slot空間

虛擬機器通過索引定位的方式使用區域性變量表,索引值的範圍是從0開始到區域性變量表最大的Slot數量,對於32位資料型別的變數,索引n代表第n個Slot,對於64位的,索引n代表第n和第n+1兩個Slot。

在方法執行時,虛擬機器是使用區域性變量表來完成引數值到引數變數列表的傳遞過程的,如果是例項方法(非static),則區域性變量表中的第0位索引的Slot預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字“this”來訪問這個隱含的引數。其餘引數則按照引數表的順序來排列,佔用從1開始的區域性變數Slot,引數表分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的Slot。

區域性變量表中的Slot是可重用的,方法體中定義的變數,作用域並不一定會覆蓋整個方法體,如果當前位元組碼PC計數器的值已經超過了某個變數的作用域,那麼這個變數對應的Slot就可以交給其他變數使用。這樣的設計不僅僅是為了節省空間,在某些情況下Slot的複用會直接影響到系統的而垃圾收集行為。

運算元棧

運算元棧又常被稱為操作棧,運算元棧的最大深度也是在編譯的時候就確定了。32位資料型別所佔的棧容量為1,64為資料型別所佔的棧容量為2。當一個方法開始執行時,它的操作棧是空的,在方法的執行過程中,會有各種位元組碼指令(比如:加操作、賦值元算等)向操作棧中寫入和提取內容,也就是入棧和出棧操作。

Java虛擬機器的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的“棧”就是運算元棧。因此我們也稱Java虛擬機器是基於棧的,這點不同於Android虛擬機器,android虛擬機器是基於暫存器的。

基於棧的指令集最主要的優點是可移植性強,主要的缺點是執行速度相對會慢些;而由於暫存器由硬體直接提供,所以基於暫存器指令集最主要的優點是執行速度快,主要的缺點是可移植性差。

動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用持有這個引用是為了支援方法呼叫過程中的動態連線。Class檔案的常量池中存在有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用為引數。這些符號引用,一部分會在類載入階段或第一次使用的時候轉化為直接引用(如final、static域等),稱為靜態解析(解析發生在初始化前),另一部分將在每一次的執行期間轉化為直接引用,這部分稱為動態連線。(解析發生在初始化後)

方法返回地址

當一個方法被執行後,有兩種方式退出該方法

  • 執行引擎遇到了任意一個方法返回的位元組碼指令

  • 執行引擎遇到遇到了異常,並且該異常沒有在方法體內得到處理。

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

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

方法呼叫

Class檔案的編譯過程中不包含傳統編譯中的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址。這個特性給Java帶來了更強大的動態擴充套件能力,使得可以在類執行期間才能確定某些目標方法的直接引用,稱為動態連線(分派呼叫),也有一部分方法的符號引用在類載入階段或第一次使用時轉化為直接引用,這種轉化稱為靜態解析(解析呼叫)。

解析呼叫

靜態解析成立的前提是:方法在程式真正執行前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。換句話說,呼叫目標在編譯器進行編譯時就必須確定下來,這類方法的呼叫稱為解析。

在Java語言中,符合“編譯器可知,執行期不可變”這個要求的方法主要有靜態方法和私有方法兩大類,前者與型別直接關聯,後者在外部不可被訪問,這兩種方法都不可能通過繼承或別的方式重寫出其他的版本,因此它們都適合在類載入階段進行解析。

Java虛擬機器裡共提供了四條方法呼叫位元組指令,分別是:

invokestatic:呼叫靜態方法。
invokespecial:呼叫例項構造器方法、私有方法和父類方法。
invokevirtual:呼叫所有的虛方法。
invokeinterface:呼叫介面方法,會在執行時再確定一個實現此介面的物件。

只要能被invokestatic和invokespecial指令呼叫的方法,都可以在解析階段確定唯一的呼叫版本,符合這個條件的有靜態方法、私有方法、例項構造器和父類方法四類,它們在類載入時就會把符號引用解析為該方法的直接引用。這些方法可以稱為非虛方法(還包括final方法),與之相反,其他方法就稱為虛方法(final方法除外)。這裡要特別說明下final方法,雖然呼叫final方法使用的是invokevirtual指令,但是由於它無法覆蓋,沒有其他版本,所以也無需對方發接收者進行多型選擇。Java語言規範中明確說明了final方法是一種非虛方法。

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

分派呼叫

分派呼叫則可能是靜態的(編譯期完成)也可能是動態的(執行期完成),根據分派依據的宗量數(方法的呼叫者和方法的引數統稱為方法的宗量)又可分為單分派和多分派。兩類分派方式兩兩組合便構成了靜態單分派、靜態多分派、動態單分派、動態多分派四種分派情況。
這裡寫圖片描述

靜態分派

所有依賴靜態型別來定位方法執行版本的分派動作,都稱為靜態分派,靜態分派的最典型應用就是多型性中的方法過載。靜態分派發生在編譯階段,因此確定靜態分配的動作實際上不是由虛擬機器來執行的。下面通過一段方法過載的示例程式來更清晰地說明這種分派機制:

class Human{
}  
class Man extends Human{
}
class Woman extends Human{
}

public class StaticPai{

    public void say(Human hum){
        System.out.println("I am human");
    }
    public void say(Man hum){
        System.out.println("I am man");
    }
    public void say(Woman hum){
        System.out.println("I am woman");
    }

    public static void main(String[] args){
        Human man = new Man();
        Human woman = new Woman();
        StaticPai sp = new StaticPai();
        sp.say(man);
        sp.say(woman);
    }
}

執行結果如下

   I am human
   I am human

在分析為什麼會選擇引數型別為Human的過載方法去執行之前,先看如下程式碼:

                       Human man = new Man();

我們把上面程式碼中的“Human”稱為變數的靜態型別,後面的“Man”稱為變數的實際型別。靜態型別和實際型別在程式中都可以發生一些變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的,而實際型別變化的結果在執行期才可確定。

回到上面的程式碼分析中,在呼叫say()方法時,方法的呼叫者(回憶上面關於宗量的定義,方法的呼叫者屬於宗量)都為sp的前提下,使用哪個過載版本,完全取決於傳入引數的數量和資料型別(方法的引數也是資料宗量)。程式碼中刻意定義了兩個靜態型別相同、實際型別不同的變數,可見編譯器(不是虛擬機器,因為如果是根據靜態型別做出的判斷,那麼在編譯期就確定了)在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。並且靜態型別是編譯期可知的,所以在編譯階段,Javac編譯器就根據引數的靜態型別決定使用哪個過載版本。這就是靜態分派最典型的應用。

注意:解析與分派這兩者之間的關係並不是二選一的排他關係,它們是在不同層次上去篩選、確定目標方法的過程。例如,前面說過,靜態方法會在類載入期就進行解析,而靜態方法顯然也是可以擁有過載版本的,選擇過載版本的過程也是通過靜態分派完成的。

動態分派

動態分派與多型性的另一個重要體現——方法覆寫有著很緊密的關係。向上轉型後呼叫子類覆寫的方法便是一個很好地說明動態分派的例子。這種情況很常見,因此這裡不再用示例程式進行分析。很顯然,在判斷執行父類中的方法還是子類中覆蓋的方法時,如果用靜態型別來判斷,那麼無論怎麼進行向上轉型,都只會呼叫父類中的方法,但實際情況是,根據對父類例項化的子類的不同,呼叫的是不同子類中覆寫的方法,很明顯,這裡是要根據變數的實際型別來分派方法的執行版本的。而實際型別的確定需要在程式執行時才能確定下來,這種在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。

由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在類的
方法元資料中搜索合適的目標方法,因此在虛擬機器的實際實現中基於效能的考慮,大部分實現都不會真正地進行如此頻繁的搜尋。面對這種情況,最常用的“穩定優化”手段就是為類在方法區中建立一個虛方法表(Vritual Method Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到介面方法表——Inteface Method Table,簡稱itable),使用虛方法表索引來代替元資料查詢以提高效能。

這裡寫圖片描述

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

單分派與多分派

前面給出:方法的接受者(亦即方法的呼叫者)與方法的引數統稱為方法的宗量。單分派是根據一個宗量對目標方法進行選擇,多分派是根據多於一個宗量對目標方法進行選擇。

class Eat{
}
class Drink{
}

class Father{
    public void doSomething(Eat arg){
        System.out.println("爸爸在吃飯");
    }
    public void doSomething(Drink arg){
        System.out.println("爸爸在喝水");
    }
}

class Child extends Father{
    public void doSomething(Eat arg){
        System.out.println("兒子在吃飯");
    }
    public void doSomething(Drink arg){
        System.out.println("兒子在喝水");
    }
}

public class SingleDoublePai{
    public static void main(String[] args){
        Father father = new Father();
        Father child = new Child();
        father.doSomething(new Eat());
        child.doSomething(new Drink());
    }
}
爸爸在吃飯
兒子在喝水

我們首先來看編譯階段編譯器的選擇過程,即靜態分派過程。這時候選擇目標方法的依據有兩點:一是方法的接受者(即呼叫者)的靜態型別是Father還是Child,二是方法引數型別是Eat還是Drink。因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派型別。

再來看執行階段虛擬機器的選擇,即動態分派過程。由於編譯期已經了確定了目標方法的引數型別(編譯期根據引數的靜態型別進行靜態分派(過載)),因此唯一可以影響到虛擬機器選擇的因素只有此(方法的接受者的實際型別是Father還是Child(重寫))。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派型別

方法執行

基於棧的直譯器執行過程
執行如下方法:

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

javap提示這段程式碼需要深度為2的運算元棧和4個Slot的區域性變數空間,下邊的幾張圖來描述執行過程中的程式碼、運算元棧和區域性變量表的變化情況。
這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述