1. 程式人生 > >SinPing's Special column

SinPing's Special column

前述

本來這邊博文是在上月釋出的,由於儲存圖片的七牛雲圖床域名過期,在申請域名和備案過程中耗了時間。後面的博文依然每月更新一篇。

1、介紹

執行引擎說白點就是執行程式碼,在瞭解虛擬機器如何執行程式碼之前,來看看方法執行的過程,如下圖執行簡單的類所示: 方法執行過程

這裡涉及到的執行時資料區域有方法區、堆、虛擬機器棧。方法區存放類,堆中存放類的物件、虛擬機器棧存放需要執行的方法。 Java執行程式碼是按照方法為基本單位的,執行方法的同時會建立一個棧幀,方法的執行開始到結束,對應著該方法的棧幀在虛擬機器棧中入棧和出棧操作。

2 虛擬機器棧

虛擬機器棧描述的是Java方法執行的記憶體模型,棧中存放的是棧幀。和程式計數器、本地方法棧一樣,虛擬機器棧也是執行緒私有的,它的生命週期與執行緒相同。一個執行緒的方法呼叫鏈可能很長,很多方法都同時處於執行狀態。對於執行引擎來說,活動執行緒中,只有棧頂的棧幀有效,稱為當前棧幀,這個棧幀所關聯的方法稱為當前方法。

3 執行時棧幀結構

棧幀中存放的資訊有區域性變量表、運算元棧、動態連結、方法返回地址以及一些其他資訊,存放這些資訊所佔的記憶體是固定的,在虛擬機器建立棧幀的同時就確定了,它不會隨執行期而改變,僅取決於虛擬機器的實現。棧幀用於支援虛擬機器進行方法呼叫和方法執行。 棧幀概念圖

3.1 區域性變量表

區域性變量表是一組儲存空間,用於存放方法引數和區域性變數,其容量大小在棧幀建立的時候就確定了,在方法的Code屬性的locals資料項中指明瞭該容量大小。

變數分為類變數和區域性變數,類變數在類載入過程中會經歷準備和初始化兩個階段,在準備階段賦予類變數系統預設值,在初始化階段賦予類變數程式設計師定義的值,且不進行人為賦值同樣可以使用;而區域性變數需要人為賦值在進行使用。

區域性變量表的容量以變數槽(Slot)為最小基本單位,JVM規範中並未指明Slot佔用的記憶體大小,但可以存放佔用32位以內的資料,包括6種基本資料型別、物件引用(reference)和returnAddress。區域性變量表的第0位索引預設儲存的內容為方法所屬物件例項的引用,可以使用this關鍵字訪問此隱含引數。區域性變量表中的變數槽是可以重複使用的,當局部變數超出作用域,且方法中繼續操作區域性變量表的指令,則回收超出作用域的區域性變數所佔的變數槽供重新分配使用,便於節省棧空間。

3.2 運算元棧

運算元棧通常稱為操作棧,與區域性變量表一樣,其做大深度在棧幀建立時以確定,在方法的Code屬性的max_stacks資料項中指明。

操作棧能夠儲存任意的Java資料型別,32位資料型別所佔的棧容量為1,64位資料型別所佔的棧容量為2。

3.3 動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有該引用是為了支援方法呼叫過程中的動態連線。在常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中執行方法的符號引用作為引數。

這些符號引用有些在類載入階段或第一次使用時轉化為直接引用,這種轉化稱為靜態解析,例如static 方法;另一部分將在每一次的執行期間轉化為直接引用,這種轉化稱為動態連結,例如介面方法。

3.4 方法返回地址

方法執行結束過程相當於棧幀出棧操作,因此在退出操作的同時可能會執行的操作有:恢復上層方法的區域性變量表和運算元棧、把返回值壓入運算元棧、調整程式計數器的值以其指向方法呼叫指令後面的一條指令。

4 方法呼叫

方法呼叫不等同於方法執行,方法呼叫階段的唯一任務是確定被呼叫方法的版本(即呼叫哪一個方法)。 15402884363611

例如上圖所示的偏移地址為17的指令,呼叫符號引用為Human.SayHello()方法,那它呼叫的直接引用的方法是Man.sayHello()方法,還是Woman.SayHello()方法,方法呼叫解決的就是這個問題。

4.1 解析呼叫

前面已提過,方法呼叫中的目標方法在Class檔案裡都是一個執行時常量池中的符號引用。在類載入階段,部分符號引用會轉化為直接引用,這個轉化的前提是:

方法在程式真正執行之前就有一個可以確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。也就是說,呼叫目標在程式程式碼中寫好、編譯器進行編譯時就必須確定下來,這類方法的呼叫稱為解析。

滿足解析的方法主要有靜態方法和私有方法兩大類,這兩種方法都不可通過繼承或別的方式重寫出其他版本,因此它們都適合在類載入階段進行解析。

解析呼叫一定是個靜態的過程,在編譯期就完全確定,在類裝載的解析階段就會把涉及的服藥引用全部轉變為可確定的直接引用,不會延遲到執行期再去完成。而分派呼叫則可能是靜態的,也有肯能是動態的,根據分派依據的宗量數可分為單分派和多分派。這兩類分派方式兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派四種情況。

4.1.1 方法呼叫位元組碼指令

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

只要能被invokestatic和invokespecial指令呼叫的方法,都可以在解析階段確定唯一的呼叫版本,符合這個條件的有靜態方法、私有方法、例項構造器和父類方法四類,它們在類載入的時候就會把符號引用解析為該方法的直接引用。這些方法可以稱為非虛方法,反之稱為虛方法。

4.2 分派呼叫

在分派呼叫中揭露Java中是如何實現過載和重寫的。

4.2.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 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 staticDispatch = new StaticDispatch();
        staticDispatch.sayHello(man);
        staticDispatch.sayHello(woman);
    }
}

上述程式碼的執行結果為 15404782853101

上面程式碼中"Human"稱為變數的靜態型別,"Man"和"Woman"稱為變數的實際型別。man和woman兩個物件被轉型後,通過特徵簽名匹配,只能匹配到對應的父類的過載方法。至於man和woman的具體型別是什麼,需要在執行期才能確定。

依據靜態型別來定位方法執行版本的分派動作,稱為靜態分派,典型應用就是方法過載。

雖然編譯器能夠找到方法的過載版本,這種找到只是一種"最合適"的版本。產生這種現象的原因是字面量不需要定義,所有字面量沒有顯式的靜態型別,它的靜態型別只能通過語言上的規則去理解和推斷。如下程式碼所示:

public class Overload {

    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }

    public static void sayHello(int arg) {
        System.out.println("hello int");
    }

    public static void sayHello(long arg) {
        System.out.println("hello long");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}

main方法中呼叫sayHello()方法,傳的引數是字元’a’,那這個’a’是 char 的’a’,還是int的65,還是 long型的65l,還是Object的65呢,通過對’a’字面量的理解,在上述程式碼中,所有編譯器能找到最合理的執行版本應該是sayHello(int arg)。 15404794905641

4.2.2 動態分派

在執行期間,根據實際型別確定方法執行版本的分派動作,稱為動態分派,典型應用–方法重寫。

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();
        woman.sayHello();

        man = new Woman();
        man.sayHello();
    }
}

執行結果 15404798113453

上述程式碼的位元組碼如下所示: 15404801473156

0~15行是準備動作,作用是建立man和woman的記憶體空間,呼叫Man和Woman型別的例項構造器,並將兩個例項的引用存放在第1和第2個區域性變量表Slot中。

16和20行程式碼分別將剛剛建立的兩個物件的引用壓入棧頂;第17和21行是方法呼叫指令,從位元組碼的角度看,兩條呼叫指令無論是指令還是引數都完全一樣,但是兩條指令最終執行的目標方法並不同,其原因是invokevirtual指令的多型查詢導致的。

4.2.3 多型查詢

invokevirtual指令的執行時解析過程大致分為:

  1. 找到運算元棧頂的第一個元素所致的物件的實際型別,記作C.
  2. 如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;不通過則返回java.lang.IllegalAccessError異常。
  3. 否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜尋和驗證過程。
  4. 如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。

由於invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令把常量池中的呼叫該方法的符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。

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

5.1 編譯執行

將程式碼編譯成可執行的中間檔案,並儲存,以便下次直接使用的技術–即時編譯器技術(JIT)。

但是編譯執行不是拿到程式碼後立即開始編譯程式碼的,原因是無法判斷程式碼是否需要被重複執行,無法選擇優化的編譯策略。編譯執行需要將程式碼編譯成中間可執行檔案,這中間勢必會消耗額外的時間和記憶體,所以待確定需要採用編譯執行的時候再採用該執行方式。

5.2 指令集

指令集分為基於棧的指令集和基於暫存器的指令集。

基於棧的指令集依賴運算元棧進行工作。優點是可移植性強,程式碼相對緊湊,編譯器實現簡單等,缺點是執行速度相對較慢,因為完成相同功能所需指令數較多,而且在這過程中頻繁的進行記憶體訪問。

基於暫存器的指令集依賴暫存器進行工作。

5.3 執行過程

public static void foo() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 5;
    }

基於棧的Hotspot的執行過程如下: o_609956274533007378

基於棧的DalvikVM執行過程如下所示: o_17

基於組合語言的執行過程: o_18

基於JVM的邏輯運算模型如下圖所示: o_19

因此執行到JVM上的過程就是下面的形式: o_20

總結

位元組碼執行引擎