1. 程式人生 > >【深入Java虛擬機器】之七:深入JVM位元組碼執行引擎

【深入Java虛擬機器】之七:深入JVM位元組碼執行引擎

我們都知道,在當前的Java中(1.0)之後,編譯器講原始碼轉成位元組碼,那麼位元組碼如何被執行的呢?這就涉及到了JVM的位元組碼執行引擎,執行引擎負責具體的程式碼呼叫及執行過程。就目前而言,所有的執行引擎的基本一致:

  1. 輸入:位元組碼檔案
  2. 處理:位元組碼解析
  3. 輸出:執行結果。

物理機的執行引擎是由硬體實現的,和物理機的執行過程不同的是虛擬機器的執行引擎由於自己實現的。


執行時候的棧結構

每一個執行緒都有一個棧,也就是前文中提到的虛擬機器棧,棧中的基本元素我們稱之為棧幀。棧幀是用於支援虛擬機器進行方法呼叫和方法執行的資料結構。每個棧幀都包括了一下幾部分:區域性變量表、運算元棧、動態連線、方法的返回地址 和一些額外的附加資訊。棧幀中需要多大的區域性變量表和多深的運算元棧在編譯程式碼的過程中已經完全確定,並寫入到方法表的Code屬性中。在活動的執行緒中,位於當前棧頂的棧幀才是有效的,稱之為當前幀,與這個棧幀相關聯的方法稱為當前方法。執行引擎執行的所有位元組碼指令只針對當前棧幀進行操作。需要注意的是一個棧中能容納的棧幀是受限,過深的方法呼叫可能會導致StackOverFlowError,當然,我們可以認為設定棧的大小。其模型示意圖大體如下:
執行時棧結構


針對上面的棧結構,我們重點解釋一下區域性變量表,操作棧,指令計數器幾個概念:

1、區域性變量表

是變數值的儲存空間,由方法引數和方法內部定義的區域性變數組成,其容量用Slot1作為最小單位。在編譯期間,就在方法的Code屬性的max_locals資料項中確定了該方法所需要分配的區域性變量表的最大容量。由於區域性變量表是建立線上程的棧上,是執行緒的私有資料,因此不存在資料安全問題。在方法執行時,虛擬機器通過使用區域性變量表完成引數值到引數變數列表的傳遞過程。如果是例項方法,那區域性變量表第0位索引的Slot儲存的是方法所屬物件例項的引用,因此在方法內可以通過關鍵字this來訪問到這個隱含的引數。其餘的引數按照引數表順序排列,引數表分配完畢之後,再根據方法體內定義的變數的順序和作用域分配。我們知道類變量表有兩次初始化的機會,第一次是在“準備階段”,執行系統初始化,對類變數設定零值,另一次則是在“初始化”階段,賦予程式設計師在程式碼中定義的初始值。和類變數初始化不同的是,區域性變量表不存在系統初始化的過程,這意味著一旦定義了局部變數則必須人為的初始化,否則無法使用。舉例說明:

public void test(){
    call(2,3);
    ...
    call2(2,3);
}

public void call(int i,int j){
    int b=2;
      ...
}

public static void call2(int i,int j){
    int b=2;
    ...
}
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

為了方便起見,假設以上兩段程式碼在同一個類中。這時call()所對應的棧幀中的區域性變量表大體如下:
例項方法區域性變量表


而call2()所對應的棧幀的區域性變量表大體如下:
類方法區域性變量表


2、運算元棧

後入先出棧,由位元組碼指令往棧中存資料和取資料,棧中的任何一個元素都是可以任意的Java資料型別。和區域性變數類似,運算元棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks資料項中。當一個方法剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元中寫入和提取內容,也就是出棧/入棧操作。運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配2,這由編譯器在編譯器期間進行驗證,同時在類載入過程中的類檢驗階段的資料流分析階段要再次驗證。另外我們說Java虛擬機器的解釋引擎是基於棧的執行引擎,其中的棧指的就是運算元棧。


3、動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有該引用是為了支援方法呼叫過程中的動態連線。


4、方法返回地址

存放呼叫呼叫該方法的pc計數器的值。當一個方法開始之後,只有兩種方式可以退出這個方法:1、執行引擎遇到任意一個方法返回的位元組碼指令,也就是所謂的正常完成出口。2、在方法執行的過程中遇到了異常,並且這個異常沒有在方法內進行處理,也就是隻要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種方式成為異常完成出口。正常完成出口和異常完成出口的區別在於:通過異常完成出口退出的不會給他的上層呼叫者產生任何的返回值。
無論通過哪種方式退出,在方法退出後都返回到該方法被呼叫的位置,方法正常退出時,呼叫者的pc計數器的值作為返回地址,而通過異常退出的,返回地址是要通過異常處理器表來確定,棧幀中一般不會儲存這部分資訊。本質上,方法的退出就是當前棧幀出棧的過程。


方法呼叫

方法呼叫的主要任務就是確定被呼叫方法的版本(即呼叫哪一個方法),該過程不涉及方法具體的執行過程。按照呼叫方式共分為兩類:

  1. 解析呼叫是靜態的過程,在編譯期間就完全確定目標方法。
  2. 分派呼叫即可能是靜態,也可能是動態的,根據分派標準可以分為單分派和多分派。兩兩組合有形成了靜態單分派、靜態多分派、動態單分派、動態多分派

解析

在Class檔案中,所有方法呼叫中的目標方法都是常量池中的符號引用,在類載入的解析階段,會將一部分符號引用轉為直接引用,也就是在編譯階段就能夠確定唯一的目標方法,這類方法的呼叫成為解析呼叫。此類方法主要包括靜態方法和私有方法兩大類,前者與型別直接關聯,後者在外部不可訪問,因此決定了他們都不可能通過繼承或者別的方式重寫該方法,符合這兩類的方法主要有以下幾種:靜態方法、私有方法、例項構造器、父類方法。虛擬機器中提供了以下幾條方法呼叫指令:

  1. invokestatic:呼叫靜態方法,解析階段確定唯一方法版本
  2. invokespecial:呼叫<init>方法、私有及父類方法,解析階段確定唯一方法版本
  3. invokevirtual:呼叫所有虛方法
  4. invokeinterface:呼叫介面方法
  5. invokedynamic:動態解析出需要呼叫的方法,然後執行

前四條指令固化在虛擬機器內部,方法的呼叫執行不可認為干預,而invokedynamic指令則支援由使用者確定方法版本。其中invokestatic指令和invokespecial指令呼叫的方法稱為非虛方法,其餘的(final修飾的除外[^footnote4])稱為虛方法。

分派

jvm中分配Dispatch的概念 分派是針對方法而言的,指的是方法確定的過程,通常發生在方法呼叫的過程中。分派根據方法選擇的發生時機可以分為靜態分派和動態分派,其中對於動態分派,根據宗量種數又可以分為單分派和多分派。實際上指的是方法的接收者和屬性的所有者的型別確定(determine by atual type or determine by static type)。根據型別確定發生在執行期還是編譯期以及依據實際型別還是靜態型別,可以將Dispatch分為動態分配Dynamic Dispatch和靜態分配Static Dispatch兩類。

虛方法和非虛方法

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

靜態分配Static Dispatch

靜態分派的典型應用是方法過載overlord。靜態分派指的是在編譯期間進行的方法選擇,通常以方法名稱,方法接收者和方法引數的靜態型別來作為方法選擇的依據。這些可以靜態分派的方法一般都具有“簽名唯一性”的特點(簽名只考慮引數的靜態型別而不管引數的實際型別),即不會出現相同簽名的方法,因此可以在編譯期就實現方法確定。Java中的非虛方法(主要包括靜態方法,私有方法,final方法等,這些方法一般不可重寫,故而不會有相同簽名的情況出現)通常僅需要靜態分派就可以實現方法的最終確定,更特別一點的例子是靜態方法的隱藏,也是利用了靜態分派,後面會專門講解。虛方法的過載在編譯時也用到了靜態分派(儘管虛方法的呼叫在執行時還會涉及動態分派)。靜態分配例項:

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

執行結果:

hello,guy!
hello,guy!

動態分配Dynamic Dispatch

動態分派的典型應用是方法重寫override動態分派是指方法的確定在run-time才能最終完成。使用動態分派來實現方法確定的方法一般在編譯期間都是一些“不明確”的方法(比如一些重寫方法,擁有相同的方法簽名並且方法接收者的靜態型別可能也相同),因此只能在執行時期根據方法接收者和方法引數的實際型別最終實現方法確定。Java中的虛方法(主要指例項方法) 通常需要在執行期採用動態分派來實現方法確定(利用invokevirtual指令獲取方法接收者的實際型別)。動態分配例項:

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();
    }
}

執行結果:

man say hello
woman say hello
woman say hello

相關筆試面試題

class Person{
  int age = 30;
  int getAge(){
    return age;
  }
}
class Man extends Person{
  int age = 40;
  int height = 160;
  int getAge(){
    return age;
  }
}
public class Demo{
  public static void main(String[] args){
    Person a = new Man();
    //  a.age內部主要通過如下位元組碼實現:
    //  getfield      #5                  // Field test/Person.age:I
    System.out.println(a.age);
    //  a.getAge()內部主要通過如下位元組碼實現:
    //  invokevirtual #7                  // Method test/Person.getAge:()I
    System.out.println(a.getAge());
  }
}

執行結果:

30
40

上面題目不僅涉及到Dispatch而且涉及到了Binding,

Static Binding

型別在編譯期就已經可以確定,並且該型別確定在執行期保持不變,即最終通過靜態型別確定該變數型別。Java中,在Java中,靜態繫結通常用於屬性所有者的型別繫結,非虛方法(類方法,私有方法,構造器方法,final方法)接收者的型別繫結,以及方法引數的型別繫結。


上例中,**age屬性是物件屬性,age屬性的所有者(物件a)在此次訪問中是靜態繫結**,因此這裡物件a的型別在編譯期被確定為a的靜態型別Person,並且該型別確定後在執行期執行getfield指令時也不會發生改變,最後”a.age”呼叫的是a的靜態型別Person的age屬性值。這裡也涉及到了屬性隱藏的問題:父類和子類有同名域時,域的訪問是通過域的所有者的靜態型別決定的。比如上面例子中如果想訪問子類Man中的age,則必須將物件a強制轉型為Man,或者在當時建立之初就宣告為Man型別而非Person型別。

通過靜態繫結來實現訪問物件屬性所有者型別繫結的好處在於:編譯期就可以確定最終型別,避免了動態查詢,高效快速,但是是以犧牲一部分靈活性為代價的。

Dynamic Binding

型別在執行時才能最終確定,通過最終實際型別(執行時型別)來確定變數型別。Java中,動態繫結通常用於虛方法(如非私有的例項方法等)接收者的型別繫結。

某些動態型別語言將動態繫結作為預設的內部實現。Java作為一種靜態型別語言,採取了一些其他的方法來實現動態繫結(比如invokevirtual指令動態識別物件的實際型別)。


上面例子中,**getAge()屬於虛方法, getAge()方法的接收者(物件a)在此次訪問中是動態繫結**,因此這裡物件a的型別儘管在編譯期被標記為Person,最後在執行期會被invokevirtual指令重新確定為a的實際型別Man,並在Man中查詢能夠匹配符號引用中方法名和描述符的方法,因此”a.getAge()”呼叫的是a的實際型別Man的getAge方法。

JVM實現動態分派

動態分派在Java中被大量使用,使用頻率及其高,如果在每次動態分派的過程中都要重新在類的方法元資料中搜索合適的目標的話就可能影響到執行效率,因此JVM在類的方法區中建立虛方法表(virtual method table)來提高效能。每個類中都有一個虛方法表,表中存放著各個方法的實際入口。如果某個方法在子類中沒有被重寫,那子類的虛方法表中該方法的地址入口和父類該方法的地址入口一樣,即子類的方法入口指向父類的方法入口。如果子類重寫父類的方法,那麼子類的虛方法表中該方法的實際入口將會被替換為指向子類實現版本的入口地址。
那麼虛方法表什麼時候被建立?虛方法表會在類載入的連線階段被建立並開始初始化,類的變數初始值準備完成之後,JVM會把該類的方法表也初始化完畢。


方法的執行

解釋執行

在jdk 1.0時代,Java虛擬機器完全是解釋執行的,隨著技術的發展,現在主流的虛擬機器中大都包含了即時編譯器(JIT)。因此,虛擬機器在執行程式碼過程中,到底是解釋執行還是編譯執行,只有它自己才能準確判斷了,但是無論什麼虛擬機器,其原理基本符合現代經典的編譯原理,如下圖所示:

(注:JIT是一種提高程式執行效率的方法。通常,程式有兩種執行方式:靜態編譯與動態解釋。靜態編譯的程式在執行前全部被翻譯為機器碼,而動態解釋執行的則是一句一句邊執行邊翻譯。
        在Java程式語言和環境中,即時編譯器(JIT compiler,just-in-time compiler)是一個把Java的位元組碼(包括需要被解釋的指令的程式)轉換成可以直接傳送給處理器的指令的程式。當你寫好一個Java程式後,源語言的語句將由Java編譯器編譯成位元組碼,而不是編譯成與某個特定的處理器硬體平臺對應的指令程式碼(比如,Intel的Pentium微處理器或IBM的System/390處理器)。位元組碼是可以傳送給任何平臺並且能在那個平臺上執行的獨立於平臺的程式碼。)

此處輸入圖片的描述

在Java中,javac編譯器完成了詞法分析、語法分析以及抽象語法樹的過程,最終遍歷語法樹生成線性位元組碼指令流的過程,此過程發生在虛擬機器外部。

基於棧的指令集與基於暫存器的指令集

Java編譯器輸入的指令流基本上是一種基於的指令集架構,指令流中的指令大部分是零地址指令,其執行過程依賴於操作棧。另外一種指令集架構則是基於暫存器的指令集架構,典型的應用是x86的二進位制指令集,比如傳統的PC以及Android的Davlik虛擬機器。兩者之間最直接的區別是,基於棧的指令集架構不需要硬體的支援,而基於暫存器的指令集架構則完全依賴硬體,這意味基於暫存器的指令集架構執行效率更高,單可移植性差,而基於棧的指令集架構的移植性更高,但執行效率相對較慢,初次之外,相同的操作,基於棧的指令集往往需要更多的指令,比如同樣執行2+3這種邏輯操作,其指令分別如下:
基於棧的計算流程(以Java虛擬機器為例):

iconst_2  //常量2入棧
istore_1  
iconst_3  //常量3入棧
istore_2
iload_1
iload_2
iadd      //常量2、3出棧,執行相加
istore_0  //結果5入棧
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

而基於暫存器的計算流程:

mov eax,2  //將eax暫存器的值設為1
add eax,3  //使eax暫存器的值加3
  
  • 1
  • 2

基於棧的程式碼執行示例

下面我們用簡單的案例來解釋一下JVM程式碼執行的過程,程式碼例項如下:


public class MainTest {
    public  static int add(){
        int result=0;
        int i=2;
        int j=3;
        int c=5;
        return result =(i+j)*c;
    }

    public static void main(String[] args) {
        MainTest.add();
    }
}

  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

使用javap指令檢視位元組碼:

{
  public MainTest();
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 2: 0

  public static int add();
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=0     //棧深度2,區域性變數4個,引數0個
         0: iconst_0  //對應result=0,0入棧
         1: istore_0  //取出棧頂元素0,將其存放在第0個區域性變數solt中
         2: iconst_2  //對應i=2,2入棧
         3: istore_1  //取出棧頂元素2,將其存放在第1個區域性變數solt中
         4: iconst_3  //對應 j=3,3入棧
         5: istore_2  //取出棧頂元素3,將其存放在第2個區域性變數solt中
         6: iconst_5  //對應c=5,5入棧
         7: istore_3  //取出棧頂元素,將其存放在第3個區域性變數solt中
         8: iload_1   //將區域性變量表的第一個slot中的數值2複製到棧頂
         9: iload_2   //將區域性變量表中的第二個slot中的數值3複製到棧頂
        10: iadd      //兩個棧頂元素2,3出棧,執行相加,將結果5重新入棧
        11: iload_3   //將區域性變量表中的第三個slot中的數字5複製到棧頂
        12: imul      //兩個棧頂元素出棧5,5出棧,執行相乘,然後入棧
        13: dup       //複製棧頂元素25,並將複製值壓入棧頂.
        14: istore_0  //取出棧頂元素25,將其存放在第0個區域性變數solt中
        15: ireturn   //將棧頂元素25返回給它的呼叫者
      LineNumberTable:
        line 4: 0
        line 5: 2
        line 6: 4
        line 7: 6
        line 8: 8

  public static void main(java.lang.String[]);
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=1
         0: invokestatic  #2                  // Method add:()I
         3: pop
         4: return
      LineNumberTable:
        line 12: 0
        line 13: 4
}

  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50

執行過程中程式碼、運算元棧和區域性變量表的變化情況如下:
指令0執行

指令1執行

指令2執行

指令3執行

指令4執行

指令5執行

指令6執行

指令7執行

指令8執行

指令9執行

指令10執行

指令11執行

指令12執行

指令13執行

指令14執行

指令15執行


  1. 也成為容量槽,虛擬規範中並沒有規定一個Slot應該佔據多大的記憶體空間。
  2. 這裡的嚴格匹配指的是位元組碼操作的棧中的實際元素型別必須要位元組碼規定的元素型別一致。比如iadd指令規定操作兩個整形資料,那麼在操作棧中的實際元素的時候,棧中的兩個元素也必須是整形。
  3. Animal dog=new Dog();其中的Animal我們稱之為靜態型別,而Dog稱之為動態型別。兩者都可以發生變化,區別在於靜態型別只在使用時發生變化,變數本身的靜態型別不會被改變,最終的靜態型別是在編譯期間可知的,而實際型別則是在執行期才可確定。
  4. Animal dog=new Dog();其中的Animal我們稱之為靜態型別,而Dog稱之為動態型別。兩者都可以發生變化,區別在於靜態型別只在使用時發生變化,變數本身的靜態型別不會被改變,最終的靜態型別是在編譯期間可知的,而實際型別則是在執行期才可確定。
  5. 宗量:方法的接受者與方法的引數稱為方法的宗量。
    舉個例子:
    public void dispatcher(){
    int result=this.execute(8,9);
    }
    public void execute(int pointX,pointY){
    //TODO
    }

    在dispatcher()方法中呼叫了execute(8,9),那此時的方法接受者為當前this指向的物件,8、9為方法的引數,this物件和引數就是我們所說的宗量。