天天看點

深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行

代碼編譯的結果就是從本地機器碼轉變為位元組碼。我們都知道,編譯器将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被稱為實際類型。

//實際類型變化
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)。是以,虛拟機在執行代碼過程中,到底是解釋執行還是編譯執行,隻有它自己才能準确判斷了,但是無論什麼虛拟機,其原理基本符合現代經典的編譯原理,如下圖所示: 大部分程式代碼到實體機的目标代碼或虛拟機能執行的指令之前,都需要經過以下各個步驟。

深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行

大多數虛拟機都會遵循這種基于現代經典編譯原理的思路,在執行對程式源碼進行詞法分析和文法分析處理,把源碼轉換為抽象文法樹。對于一門具體語言的實作來說,詞法分析、文法分析至後面的優化器和後面的代碼生成器都可以選擇獨立于執行引擎,形成一個完整意義的編譯器去實作,這類代表就是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
}

           

執行過程中代碼、操作數棧和局部變量表的變化情況如下:

深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行
深入了解JVM虛拟機(七):虛拟機位元組碼執行引擎1.方法調用2. 靜态連結3. 動态連結4. 解析5. 分派6. 方法的執行

繼續閱讀