1. 程式人生 > >深入理解JVM虛擬機器(七):虛擬機器位元組碼執行引擎

深入理解JVM虛擬機器(七):虛擬機器位元組碼執行引擎

程式碼編譯的結果就是從本地機器碼轉變為位元組碼。我們都知道,編譯器將Java原始碼轉換成位元組碼?那麼位元組碼是如何被執行的呢?這就涉及到了JVM位元組碼執行引擎,執行引擎負責具體的程式碼呼叫及執行過程。就目前而言,所有的執行引擎的基本一致:

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

所有的Java虛擬機器的執行引擎都是一致的:輸入的是位元組碼執行檔案,處理的過程是位元組碼解析的等效過程,輸出的是執行結果。物理機的執行引擎是由硬體實現的,和物理機的執行過程不同的是虛擬機器的執行引擎由於自己實現的。

1.方法呼叫

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

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

Class檔案的編譯過程不包含傳統編譯中的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體不急的入口地址(相當於說的是直接引用)。

我們知道class檔案是原始碼經過編譯後得到的位元組碼,如果學過編譯原理會知道,這個僅僅完成了一半的工作(詞法分析、語法分析、語義分析、中間程式碼生成),接下來就是實際的運行了。而Java選擇的是動態連結的方式,即用到某個類再載入進記憶體

,而不是像C++那樣使用靜態連結:將所有類載入,不論是否使用到。當然了,孰優孰劣不好判斷。靜態連結優點在速度,動態連結優點在靈活。下面我們來詳細介紹一下動態連結和靜態連結。

2. 靜態連結

如上面的概念所述,在C/C++中靜態連結就是在編譯期將所有類載入並找到他們的直接引用,不論是否使用到。而在Java中我們知道,編譯Java程式之後,會得到程式中每一個類或者介面的獨立的class檔案。雖然獨立看上去毫無關聯,但是他們之間通過介面(harbor)符號互相聯絡,或者與Java API的class檔案相聯絡。

我們之前也講述了類載入機制中的一個過程—解析,並在其中提到了解析就是將class檔案中的一部分符號引用直接解析為直接引用的過程,但是當時我們並沒有詳細說明這種解析所發生的條件,現在我給大家進行補充:

方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。可以概括為:編譯期可知、執行期不可變。此類方法主要包括靜態方法和私有方法兩大類,前者與型別直接關聯,後者在外部不可訪問,因此決定了他們都不可能通過繼承或者別的方式重寫該方法,符合這兩類的方法主要有以下幾種:靜態方法、私有方法、例項構造器、父類方法。

3. 動態連結

如上所述,在Class檔案中的常量持中存有大量的符號引用。位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用作為引數。這些符號引用一部分在類的載入階段(解析)或第一次使用的時候就轉化為了直接引用(指向資料所存地址的指標或控制代碼等),這種轉化稱為靜態連結。而相反的,另一部分在執行期間轉化為直接引用,就稱為動態連結。

與那些在編譯時進行連結的語言不同,Java型別的載入和連結過程都是在執行的時候進行的,這樣雖然在類載入的時候稍微增加一些效能開銷,但是卻能為Java應用程式提供高度的靈活性,Java中天生可以動態擴充套件的語言特性就是依賴動態載入和動態連結這個特點實現的。

4. 解析

在Java虛擬機器中提高了5中方法呼叫位元組碼指令:

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

前四條指令固化在虛擬機器內部,方法的呼叫執行不可認為干預,而invokedynamic指令則支援由使用者確定方法版本。

非虛方法:其中invokestatic指令和invokespecial指令呼叫的方法稱為非虛方法,符合這個條件的有靜態方法、私有方法、例項構造器、分類方法這4類。Java中的非虛方法除了使用invokestatic指令和invokespecial指令呼叫的方法之外還有一種,就是final修飾的方法。雖然final方法是使用invokevirtual指令來呼叫的,但是由於它無法被覆蓋沒有其他版本,所以也無須對方法接受者進行多型選擇,又或者多型選擇的結果是唯一的。Java語言規範中明確說明了final方法也是一直用非虛方法。所以對於非虛方法中,Java通過編譯階段,將方法的符號引用轉換為直接引用。因為它是編譯器可知、執行期不可變得方法。

解析呼叫一定是一個靜態過程,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉換為確定的直接引用,不會延遲到執行期再去完成。而分派呼叫則可能是靜態的也可能是動態的,根據分派依據的宗量數量可以分為單分派和多分派。

5. 分派

分派呼叫更多的體現在多型上。

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

  • 靜態分派:所有依賴靜態型別3來定位方法執行版本的分派成為靜態分派,發生在編譯階段,典型應用是方法過載。
  • 動態分派:在執行期間根據實際型別4來確定方法執行版本的分派成為動態分派,發生在程式執行期間,典型的應用是方法的重寫。
  • 單分派:根據一個宗量對目標方法進行選擇。
  • 多分派:根據多於一個宗量對目標方法進行選擇。

介紹分派之前我們先來對靜態型別實際型別進行定義:

Human man = new Man();

如上程式碼,Human被稱為靜態型別,Man被稱為實際型別。

//實際型別變化
Human man = new Man();
man = new Woman();

//靜態型別變化
StaticDispatch sr = new StaticDispatch();
sr.sayHello((Human) man);
sr.sayHello((Woman) man);

可以看到的靜態型別和實際型別都會發生變化,但是有區別:靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的,而實際型別變化的結果在執行期才可確定。

5.1 靜態分派(過載 靜態型別)

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

我們來看一下下面這個應用程式:

class Human {
}

class Man extends Human {
}

class Woman extends Human {
}

public class StaticDispatch {

    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!

如上程式碼與執行結果,在呼叫 sayHello()方法時,方法的呼叫者都為sr的前提下,使用哪個過載版本,完全取決於傳入引數的數量和資料型別。程式碼中刻意定義了兩個靜態型別相同、實際型別不同的變數,可見編譯器(不是虛擬機器,因為如果是根據靜態型別做出的判斷,那麼在編譯期就確定了)在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。並且靜態型別是編譯期可知的,所以在編譯階段,javac 編譯器就根據引數的靜態型別決定使用哪個過載版本。因此,在編譯期間,Javac編譯器會根據引數的靜態型別決定使用哪個過載版本,所以選擇了sayHello(Human)作為呼叫目標,並把這個方法的符號引用寫到main()方法的兩條invokevirtual指令引數中。

所謂依賴靜態型別來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法過載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是有虛擬機器表執行的。

5.2 動態分派(重寫 實際型別)

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

我們再來看一下下下面應用程式:

/**
 * locate com.basic.java.classExecution
 * Created by MasterTj on 2018/12/14.
 * 方法動態分派演示
 */
public class DynamicDispatch {
    static abstract class Human{
        protected abstract void sayHello();
    }

    static class Man extends Human{

        @Override
        protected void sayHello() {
            System.out.println("man SayHello!!");
        }
    }

    static class Woman extends Human{

        @Override
        protected void sayHello() {
            System.out.println("Woman SayHello!!");
        }
    }

    public static void main(String[] args) {
        Human man=new Man();
        Human woman=new Woman();

        man.sayHello();;
        woman.sayHello();

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

執行結果:
man SayHello!!
Woman SayHello!!
Woman SayHello!!

對於虛擬函式的呼叫,在JVM指令集中是呼叫invokevirtual指令。下面我們來介紹一下invokevirtual指令的動態查詢過程,invokevirtual指令的執行時解析過程大致可以分為以下幾個步驟:

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

由於invokevirtual指令執行把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言的方法重寫的本質。

5.3 單分派與多分派

單分派是根據一個宗量對目標方法進行選擇,多分派是根據多於一個宗量對目標方法進行選擇。

我們再來看一下下面應用程式:

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 語言的動態分派屬於單分派型別

目前的 Java 語言(JDK1.6)是一門靜態多分派(方法過載)、動態單分派(方法重寫)的語言。

6. 方法的執行

下面我們來探討虛擬機器是如何執行方法中的位元組碼指令的,上文提到,許多Java虛擬機器的執行引擎在執行Java程式碼的時候都用解釋執行(通過直譯器執行)和編譯執行(通過及時編譯器產生原生代碼)

6.1 解釋執行

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

在這裡插入圖片描述

大多數虛擬機器都會遵循這種基於現代經典編譯原理的思路,在執行對程式原始碼進行詞法分析和語法分析處理,把原始碼轉換為抽象語法樹。對於一門具體語言的實現來說,詞法分析、語法分析至後面的優化器和後面的程式碼生成器都可以選擇獨立於執行引擎,形成一個完整意義的編譯器去實現,這類代表就是C/C++語言。也可以選擇一部分步驟(如生成語法樹之前的步驟)實現為一個半獨立的編譯器,這類代表就是Java語言。又或者把這些步驟和執行引擎全部集中在一個封閉的黑匣子裡面,如大多數的JavaScript執行器。

Java語言中,Javac編譯器完成了程式程式碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流的過程。這一部分動作是在java虛擬機器之外進行的,而直譯器(JTI)在虛擬機器內部,所以Java程式的編譯就是半獨立的實現。

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

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

基於棧的指令集執行的就是經過JIT直譯器解釋執行的指令流,基於暫存器的指令集執行的就是目標機器程式碼的指令。

基於棧的指令集的優勢和缺點:

  • 優點:可以移植性強,暫存器由硬體進行保護,程式直接依賴這些應將暫存器而不可避免地要受到硬體的約束。
  • 缺點:棧架構指令集的程式碼非常緊湊,但是完成相同功能所需要的指令數量一般會比暫存器的架構多,因為出棧、入棧操作本身就產生了相當多的指令數量。更重要的是,棧實現在記憶體之中,頻繁的棧訪問也就意味著頻繁的記憶體訪問,相對於處理器來說,記憶體始終是執行速度的瓶頸。

基於棧的計算流程(以Java虛擬機器為例):

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

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

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

6.3 基於棧的程式碼執行示例

下面我們用簡單的案例來解釋一下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();
    }
}

使用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
}

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

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述