1. 程式人生 > >深入JAVA虛擬機之字節碼執行引擎

深入JAVA虛擬機之字節碼執行引擎

內存布局 出現 編譯程序 方法調用 virt cdi ati special 成了

前言:
class文件結構、類加載機制、類加載器、運行時數據區這四個java技術體系中非常重要的知識,學習完了這些以後,我們知道一個類是通過類加載器加載到虛擬機,存儲到運行時數據區,而且我們也知道了我們方法體內的代碼被編譯成字節碼保存在方法表中的code屬性中,那麽虛擬機又是怎麽執行這些代碼的,得出方法輸出結果的呢?這一節我們就要來學習,關於虛擬機字節碼執行引擎的相關知識。通過這章節的學習,我們要掌握一下知識點:

1.運行時棧幀結構
2.方法調用
3.基於棧的字節碼執行引擎

運行時棧幀結構

棧幀是用於支持方法調用和方法執行的數據結構。他是虛擬機運行時數據區中虛擬機棧中的棧元素。棧幀存儲了:方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法調用從開始到執行完成的過程,就對應著一個棧幀在虛擬機棧裏從入棧到出棧的過程。

技術分享圖片

局部變量表
局部變量表是一組變量值存儲空間,用於存放方法參數和方法內定義的局部變量。在Java程序編譯為Class文件時,就在方法的Code屬性的max_locals數據項中確定了該方法所需要分配的最大局部變量表的容量。

操作數棧
操作數棧也常被稱為操作棧,它是一個後入先出棧。跟局部變量表一樣,操作數棧的最大深度也在編譯的時候被寫入Code屬性的max_stacks數據項中。當方法開始執行的時候,這個方法的操作棧是空的,在方法執行過程中,會有各種字節碼指令向操作數棧中寫入和提取內容,也就是入棧和出棧操作。比如說做算術運算的時候通過操作數棧來進行的,又或者在調用其他方法的時候是通過操作數棧來進行參數傳遞的。

動態連接
每一個棧幀都包含一個指向運行時常量池的該棧幀所屬的方法引用,持有這個引用是為了支持方法調用過程中的動態連接。通過Class文件結構我們知道,常量池中存在大量的符號引用,字節碼中方法調用指令就以常量池中指向方法的符號引用作為參數。這些符號引用一部分會在類加載階段或者第一次使用的時候轉化為直接引用,這種轉化叫做靜態解析。而另外一部分將在每一次運行期間轉化為直接引用,這部分叫做動態連接。

方法返回地址
當一個方法被執行後,有兩種方式退出該方法,一種是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會返回值給上層調用者,這種方式叫做:正常完成出口。
另外一種方式就是方法執行遇到異常,並且異常在方法體內沒有被處理,這時候就會導致退出方法,這種方式叫做:異常完成出口。異常完成出口方式是不會給上層調用者任何返回值的。

方法返回地址等同於當前棧幀出棧,恢復上層棧幀的局部變量表和操作數棧,把返回值壓入調用者棧幀的操作數棧,pc計數器加1,執行pc計數器的值指向的方法調用指令。

方法調用
方法調用並不等同於方法執行,方法調用階段唯一的任務就是確定被調用方法的版本(調用哪個方法),還沒有涉及到方法體內的具體運行過程。那麽我們都知道java有方法重載和方法重寫,那麽如何確定調用方法的版本呢?一切方法調用在Class文件裏面存儲的只是符號引用,而不是方法在實際運行時內存布局中的入口地址。這個特性給java帶來了更強大的動態擴展能力,但也使得java方法調用過程變得相對復雜起來,需要在類加載期間甚至到運行期間才能確定目標方法的直接引用。

在類加載的解析階段,會將一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個確定的調用版本,並且這個方法的調用版本在運行期間不可改變的,這種調用被稱為解析調用。符合“編譯期可知,運行期不可變"的方法主要有靜態方法和私有方法兩大類。這兩種方法都不可能通過繼承或者別的方式重寫出其他版本,因此他們都適合在類加載階段進行解析。

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

與之相對應,在JAVA虛擬機中提供了四條方法調用字節碼指令,分別是:
invokestatic:調用靜態方法
invokespecial:調用實例構造方法,私有方法和父類方法
invokevirtual:調用所有的虛方法
invokeinterface:調用接口方法,會在運行時再確定一個實現此接口的對象。

只要能被invokestatic \invokespecial指令調用的方法,都可以在解析階段確定唯一調用版本,比如說靜態方法、私有方法、實例構造器和父類方法四類,在類加載的時候就會把符號引用轉化為直接引用,這類方法又稱為非虛方法。final方法雖然是用invokevirtual調用的,但是它無法覆蓋,沒有其他版本,在java語言規範中明確說明了final方法是一種非虛方法。

因為java具備面向對象的三大基本特征:繼承、封裝、多態。多態的基本體現就是重載和重寫,那麽重載和重寫的方法在虛擬機如何確定正確的目標方法?

分派調用
分派調用可能是靜態的也可能是動態的,根據分派依據的宗量數可分為單分派和多分派,兩類組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派。我們來看一下下面這段代碼。

import com.sun.deploy.net.proxy.StaticProxyManager;

import java.util.Map;

/**
 * @Author:Administrator.
 * @CreatedTime: 2018/8/13.
 * @EditTime:2018/8/13.
 * @Version:
 * @Description:
 * @Copyright: 
 */
public class StaticDispatch {

    static abstract class Human {

    }

    static class Man extends Human {

    }

    static class Women 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(Women guy) {
        System.out.println("Hello,lady!");
    }

    public static void main (String[] args) {
        Human women = new Women();
        Human man = new Man();
        StaticDispatch sd = new StaticDispatch();
        sd.sayHello(women);
        sd.sayHello(man);
    }
}

執行結果是:
Hello,guy!
Hello,guy!

有經驗的開發者,一看就能看出結果來,那為什麽虛擬機會調用參數為Human的sayHello方法呢?在說明這個之前,我們先來理解兩個概念:
Human man = new Man();
Human是變量的靜態類型或者叫外觀類型,而Man是變量的實際類型。靜態類型和實際類型在程序中都可以發生一些變化,區別在於靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變。並且最終的靜態類型是在編譯期可知的,而實際類型變化的結果在運行期才可以確定,編譯器在編譯程序的時候並不知道一個對象實際類型是什麽。

所以回到上面代碼,main方法中兩次調用sayHello方法,使用哪一個版本就完全取決於傳入參數的數量和數據類型。代碼中刻意定義了兩個靜態類型相同,實際類型不同的變量,但是虛擬機(具體的說應該是編譯器)在重載時是通過參數的靜態類型而不是實際類型作為判斷依據的。並且靜態類型是在編譯期可知的,所以在編譯階段,javac編譯器就根據參數的靜態類型決定使用哪一個版本,所以選擇了sayHello(Human)作為調用目標。

靜態分派

所有依賴靜態類型來定位方法執行版本的分派動作,都稱為靜態分派。靜態分派最典型的例子就是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是虛擬機來執行的。更多時候重載的版本不是唯一的,往往只能確定一個更合適的版本。看下面代碼:

/**
 * @Author:Administrator.
 * @CreatedTime: 2018/8/13.
 * @EditTime:2018/8/13.
 * @Version:
 * @Description:
 * @Copyright: 
 */
public class StaticDispatch {

    public static void sayHello(Object obj) {
        System.out.println("Hello,object!");
    }

    public static void sayHello(int c) {
        System.out.println("Hello,int!");
    }

    public static void sayHello(double c) {
        System.out.println("Hello,double!");
    }

    public static void sayHello(float c) {
        System.out.println("Hello,float!");
    }

    public static void sayHello(long c) {
        System.out.println("Hello,long!");
    }

    public static void sayHello(Character c) {
        System.out.println("Hello,Character!");
    }

    public static void sayHello(char c) {
        System.out.println("Hello,char!");
    }

    public static void sayHello(char... c) {
        System.out.println("Hello,char...!");
    }

    public static void sayHello(Serializable c) {
        System.out.println("Hello,Serializable!");
    }

    public static void main (String[] args) {
        sayHello(‘c‘);
    }
}

執行結果是:Hello,char!,然後我們把sayHello(char c)註釋再執行,發現結果是:Hello,int!
這裏發生了自動轉換的過程,‘c’->65;我們繼續註釋掉這個方法繼續執行,輸出結果為Hello,long!這裏就發生了兩次自動轉換:c->65->65L.這一的方式自動轉換可以持續多次:char->int->long->float->double.註釋掉double參數重載方法後執行:Hello,Character!這裏就存在一次自動裝箱的過程。那麽我們繼續把這個方法註釋掉,繼續執行,發現:Hello,Serializable!這裏跟序列化有什麽關系呢?自動裝箱後,發現還是找不到匹配的參數類型,卻找到了裝箱類實現的接口Serializable,那麽就繼續自動轉型,註意這裏封裝類型Character是不能夠轉換成Integer的,它只能安全的轉換為它實現的接口或者父類。Character還實現了一個java.lang.comparable<Character>接口,如果同時出現Serializable、comparable<Character>的方法重載時,它的優先級是一樣的,這個時候編譯器就會報錯:類型模糊,編譯不通過。這個時候必須指定對應的接口才能通過編譯(如sayHello(comparable<Character> ‘c‘))。繼續註釋掉Serializable參數的重載方法,執行!這個時候就是Hello,object!自動裝箱後轉為父類類型,如果有多重繼承,那麽由下往上找,越往上優先級越高。繼續註釋,最後就執行char...變長參數的重載方法,由此可見變長參數的匹配優先級是最低的。這個例子就是java實現方法重載的本質,這個例子是個極端例子,通常工作中是幾乎沒有用的,一般都是放到面試題裏“為難”一下面試者。

動態分派
我們了解了靜態分派後,我們繼續看下動態分派是如何實現的.動態分派是多態特性的另一個重要體現重寫(override).看如下代碼:


/**
 * @Author:Administrator.
 * @CreatedTime: 2018/8/13.
 * @EditTime:2018/8/13.
 * @Version:
 * @Description:
 * @Copyright: 
 */
public class StaticDispatch {

    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 Women extends Human {
        @Override
        protected void sayHello() {
            System.out.println("Women Say Hello!");
        }
    }

    public static void main (String[] args) {
        Human man = new Man();
        Human women = new Women();
        man.sayHello();
        women.sayHello();
        man = new Women();
        man.sayHello();
    }
}

運行結果:
man Say Hello!
Women Say Hello!
Women Say Hello!

相信這個運行結果肯定都在你們預料之中的,因為習慣了面向對象編程的你們來說這是理所當然的了。但虛擬機怎麽知道該調用哪一個方法的呢?顯然這裏是無法通過參數靜態類型來確定!
Human man = new Man();
Human women = new Women();
這兩行在內存中分配了man和women的內存空間,調用man和women的實例構造器,把兩個實例放到局部變量表的第一和第二個slot位置上。
技術分享圖片

在運行期將符號引用轉化為直接引用,所以man和women被解析到不同的直接引用上,這過程就是方法重寫的本質。我們把運行期根據實際類型確定方法版本的分派過程叫做動態分派。

基於棧的字節碼解釋執行引擎

上面已經把java虛擬機是如何調用方法講完了,那麽接下來就是虛擬機是怎麽執行這些字節碼指令的.虛擬機在執行代碼時都有解釋執行和編譯執行兩種選擇。

解釋執行
java語言剛開始的時候被人們定義為解釋執行的語言,在jdk1.0來說是比較準確的,但隨著虛擬機的發展,虛擬機中開始包含了即時編譯器後,class文件中的代碼到底是解釋執行還是編譯執行恐怕只有虛擬機自己才能判斷了。

不過不管是解釋還是編譯,不管是物理機還是虛擬機,對於應用程序,機器肯定是無法像人一樣閱讀和理解,然後獲得執行能力。大部分的程序代碼到物理機或者虛擬機可執行的字節碼指令集,都需要經歷多個步驟,如下圖,而中間那條就是解釋執行的過程。
技術分享圖片

Java語言中,Javac編譯器完成了程序代碼經過詞法分析、語法分析到抽象語法樹,再遍歷樹生成線性的字節碼指令流的過程。因為一部分在虛擬機外,而解釋器在虛擬機的內部,所以java程序的編譯就是半獨立的實現。

基於棧的指令集與基於寄存器的指令集
Java編譯器輸出的指令流,基本上是一種基於棧的指令集架構,指令流裏面大部分都是零地址指令看,他們依賴操作數棧進行工作。與之相對的另外一套常用指令集架構是基於寄存器的指令集。

兩者優缺點:
1.基於棧的指令集主要優點就是可移植性,但因為相同的動作該指令集需要頻繁操作內存,且多於寄存器指令集,速度就慢。
2.基於寄存器指令集主要優點就是速度快,操作少。但是因為寄存器是依賴於硬件的,所以它的移植性受到影響。

基於棧的解釋器執行過程
這一內容通過一個一個四則運算進行講解,下面是代碼:

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

下面是字節碼執行過程圖(包含字節碼指令、pc計數器、操作數棧、局部變量表):

技術分享圖片

技術分享圖片
技術分享圖片
技術分享圖片
技術分享圖片

上面的演示,是一個概念模型,實際上肯定不會跟這個一樣的,因為虛擬機中的解釋器和即時編譯器都會對輸入的字節碼進行優化。

總結:
學到這裏,我們已經把Java程序是如何存儲(Class文件結構),如何加載(類加載機制、類加載器),運行時數據區、以及如何執行的相關知識都學習完了。接下來我們應該進行學習的章節就是垃圾收集器和內存分配策略。怎麽判斷該對象所持有的內存可以回收了?回收主要在運行時數據區的那些區域?以及內存分配與回收策略。

深入JAVA虛擬機之字節碼執行引擎