【JVM】JVM系列之執行引擎(五)

leesf發表於2016-03-14

一、前言

  在瞭解了類載入的相關資訊後,有必要進行更深入的學習,瞭解執行引擎的細節,如位元組碼是如何被虛擬機器執行從而完成指定功能的呢。下面,我們將進行深入的分析。

二、棧幀

  我們知道,在虛擬機器中與執行方法最相關的是棧幀,程式的執行對應著棧幀的入棧和出棧,所以棧幀對於執行引擎而言,是很重要的基礎。棧幀的基本結構之前已經有所介紹,這裡只是再簡單的過一遍。

  棧幀主要包括了區域性變數表、運算元棧、動態連線、方法返回地址等資訊。

  2.1 區域性變數表

  用於存放方法引數和方法內部的區域性變數。區域性變數表的大小在方法的Code屬性中就已經定義好了,為max_locals的值,區域性變數表的單位為slot,32位以內的型別只佔用一個slot(包括returnAddress型別),64位的型別佔用兩個slot。注意,對於例項方法而言,索引為0的slot存放的是this引用,之後再依次存放方法引數,定義的區域性變數;slot可以被重用,當區域性變數已經超出了作用域時,在作用域外在定義區域性變數時,可以重用之前的slot空間。同時,區域性變數沒有賦值是不能夠使用的,這和類變數和例項變數是有不同的,如下面程式碼:

public void test() {
    int i;
    System.out.println(i);
}

  這樣的程式碼是錯誤的,沒有賦值不能夠使用。

  2.2 運算元棧

  執行方法時,存放運算元的棧,棧的深度在方法的Code屬性中已經定義好了,為max_stack的值,32位以內的型別佔用一個棧單位,64為的型別佔用兩個棧單位。運算元棧可以與其他棧的區域性變數表共享區域,這樣可以共用一部分資料。

  2.3 動態連線

  動態連線是為了支援在執行期間將符號引用轉化為直接引用的操作。我們知道,每一個方法對應一個棧幀,而每一個棧幀,都包含指向對應方法的引用,這個引用就是為了支援動態連線,如invokedynamic指令。動態連線與靜態解析對應,靜態解析是在類載入(解析階段)或者第一次使用時將符號引用轉化為直接引用,動態連線則是每一次執行的時候都需要進行轉化(invokedynamic指令)。

  2.4 方法返回地址

  正常方法返回,返回地址為到呼叫該方法的指令的下一條指令的地址;異常返回,返回地址由異常表確定。方法返回時,需要恢復上層方法的區域性變數表、運算元棧、將返回值壓入呼叫者棧幀的運算元棧、設定PC值。

三、方法呼叫

  在分析了棧幀後,我們接著分析方法呼叫,方法呼叫會導致棧幀入棧,而方法呼叫會確定呼叫哪一個方法,還不會涉及到具體的方法體執行。

  3.1 解析

  在程式執行前就已經確定了方法呼叫的版本,即編譯期就確定了呼叫方法版本,這個版本在執行時是不可變的。靜態方法、私有方法、final方法在編譯時就可以確定具體的呼叫版本,靜態方法直接與型別相關、私有方法在外部不可訪問、final不可被繼承,也可唯一確定,這些方法稱為非虛方法,其他方法稱為虛方法。在類載入的解析階段就可以進行解析,如下方法呼叫在編譯期就可以確定方法呼叫的版本。  

class Father {
    public static void print(String str) {
        System.out.println("father " + str);
    }
    
    private void show(String str) {
        System.out.println("father " + str);
    }
}

class Son extends Father {

}

public class Test {
    public static void main(String[] args) {
        Son.print("coder");
        //Father fa = new Father();
        //fa.show("cooooder");
    }
}
View Code

  執行結果:

  fatcher coder

  說明:Son.print實際上呼叫的是Father的print方法,print方法與Father型別是相關的,而show方法時私有的方法,在Main中無法呼叫,只能在Father的內部呼叫,也是確定的。

  invokestatic(呼叫靜態方法)、invokespecial(呼叫例項構造器<init>方法、私有方法、父類方法)都是在編譯期就可以確定版本的。

  3.2 分派

  分派呼叫與多型密切相關,分為靜態分派、動態分派、單分派、多分派。

  1. 靜態分派

  與靜態分派相關的就是方法的過載,過載時根據引數的靜態型別引用型別而非實際型別決定呼叫哪個版本。 

package com.leesf.chapter8;
/*
 * 過載方法在編譯器就可以進行確定,不需要等到執行期間
 * */
public class StaticDispatch {
    static class Human {
        //
    }
    
    static class Women extends Human {
        //
    }
    
    static class Men extends Human {
        //
    }
    
    public void sayHello(Human human) {
        System.out.println("say human");
    }
    
    public void sayHello(Women women) {
        System.out.println("say women");
    }
    
    public void sayHello(Men men) {
        System.out.println("say men");
    }
    
    public static void main(String[] args) {
        StaticDispatch ds = new StaticDispatch();
        Human women = new Women();
        Human men = new Men();
        
        ds.sayHello(women);
        ds.sayHello(men);
    }
}
View Code

  執行結果:

  say human
  say human

  說明:由於靜態型別(引用型別)為Human,在編譯期就可以確定方法的呼叫版本是以Human引數的方法,和實際型別無關。

  2. 動態分派

  與動態分派相關的就是方法的重寫,在子類中我們會重寫父類的方法,而在呼叫的時候根據實際型別來宣和適合的呼叫版本。 

package com.leesf.chapter8;

public class DynamicDispatch {
    abstract static class Human {
        abstract public void sayHello();
    }
    
    static class Women extends Human {
        @Override
        public void sayHello() {
            System.out.println("say women");
        }
    }
    
    static class Men extends Human {
        @Override
        public void sayHello() {
            System.out.println("say men");
        }
    } 
    
    public static void main(String[] args) {
        Human women = new Women();
        Human men = new Men();
        women.sayHello();
        men.sayHello();
            
    }
}
View Code

  執行結果:

  say women
  say men

  說明:此時根據實際型別選擇合適的方法呼叫,分別呼叫了women和men的sayHello()方法。

  方法的接收者(方法的所有者)與方法的引數統稱為方法的宗量,根據分派基於多少種宗量,可以將分派劃分為單分派和多分派。

  3. 單分派與多分派

  單分派根據一個宗量確定呼叫方法的版本;多分派根據多個宗量確定呼叫方法的版本。  

package com.leesf.chapter8;

import com.leesf.chapter8.Test.Son;

public class Dispatch {
    static class QQ {};
    static class _360{};
    
    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("father choose qq");
        }
        
        public void hardChoice(_360 arg) {
            System.out.println("father choose 360");
        }
    }
    
    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }
        
        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }
    
    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}
View Code

  執行結果:

  father choose 360
  son choose qq

  說明:靜態分派過程如下,在編譯期階段,會根據靜態型別與引數型別確定呼叫版本,產生兩條分別指向Father.hardChoice(QQ)和Father.hardChoice(_360)的指令,可以知道,在編譯期,是由多個宗量確定呼叫版本,是靜態多分派。動態分派過程如下,在執行期,在執行hardChoice(QQ)或者hardChoice(_360)時,已經確定了引數必須為QQ、_360,方法簽名確定,靜態型別和實際型別此時都不會對方法本身產生任何影響,而虛擬機器會根據實際型別來確定呼叫版本,只根據一個宗量進行確定,因此,在執行時,是動態單分派。

  3.3 動態分派的實現

  在物件導向的程式設計中,會很頻繁的使用到動態分派,如何實現動態分派呢?虛擬機器採用在類的方法區建立一個虛方法表(非虛方法不會出現在表中)來實現。上面程式的虛方法表如下圖所示。

  說明:從Object類繼承的方法都會指向Object型別資料中各方法的實際入口地址。類自身的方法會指向類的資料型別中方法的實際入口地址。父類的沒有被重寫的方法在虛方法表中的索引與子類方法表中的索引相同,這樣,當型別變化時,只需要改變方法表就行,索引還是相同。方法表一般在類載入的連線階段進行初始化,準備了類變數的初始值後,方法表也初始化完畢。

  下面我們再通過一個例子更加深入瞭解方法表。

  下面幾個類的繼承關係如下

  1. Friendly  

package com.leesf.chapter8;

public interface Friendly {
    void sayHello();
    void sayGoodbye();
}
View Code

  2. Dog  

package com.leesf.chapter8;

public class Dog {
    private int wagCount = ((int) (Math.random() * 5.0)) + 1;
    
    public void sayHello() {
        System.out.println("Wag");
        for (int i = 0; i < wagCount; i++)
            System.out.println(", wag");
    }
    
    public String toString() {
        return "Woof";
    }
}
View Code

  3. Cat  

package com.leesf.chapter8;

public class Cat implements Friendly {
    public void eat() {
        System.out.println("Chomp, chomp, chomp");
    }
    
    public void sayHello() {
        System.out.println("Rub, rub, rub");
    }
    
    public void sayGoodbye() {
        System.out.println("Samper");
    }
    
    protected void finalze() {
        System.out.println("Meow");
    }
}
View Code

  4. CockerSpaniel  

package com.leesf.chapter8;

public class CockerSpaniel extends Dog implements Friendly {
    private int woofCount = ((int) (Math.random() * 4.0)) + 1;
    
    private int wimperCount = ((int) (Math.random() * 3.0)) + 1;
    
    public void sayHello() {
        super.sayHello();
        
        System.out.println("Woof");
        for (int i = 0; i < woofCount; i++) {
            System.out.println(", woof");
        }
    }
    
    public void sayGoodbye() {
        System.out.println("Wimper");
        for (int i = 0; i < wimperCount; i++) {
            System.out.println(", wimper");
        }
    }
}
View Code

  物件映像如下圖所示

  說明:忽略了Object的例項變數。來自超類的例項變數出現在子類例項變數之前,可以看到,Dog的例項變數wagCount的索引為1,與CockerSpaniel的索引相同,之後按照變數出現的順序依次存放。

  Dog虛方法表

  說明:強調,非虛方法不會出現在此表中,沒有重寫的Object的方法指向Object型別資料中的方法的實際入口地址。

  CockerSpaniel方法表    說明:可以看到被覆蓋的sayHello方法指向了CockerSpaniel型別資料,sayHello方法的索引在Dog與CockerSpaniel中是相同的。

  Cat方法表

  說明:由CockerSpaniel和Cat的方法表我們可以知道,sayHello方法與sayGoodbye方法在兩者的方法表中的索引是不相同的,CockerSpaniel與Cat兩個類之間沒有關係,所以介面的方法的索引可以不相同。

  示例:

package com.leesf.chapter8;

public class Tester {
    public static void main(String[] args) {
        Dog dog = new CockerSpaniel();
        dog.sayHello();
        Friendly fr = (Friendly) dog;
        fr.sayGoodbye();
        fr = new Cat();
        fr.sayGoodbye();
    }
}
View Code

  說明:fr首先指向CockerDaniel,然後呼叫了方法sayGoodbye,方法的索引為11。然後fr又指向了Cat,呼叫了方法sayGoodbye,方法索引為12,索引值不同,不能僅僅切換方法表,還要重新搜尋(Cat的)方法表。虛擬機器從介面引用呼叫一個方法時,它必須搜尋類的方法表來找到合適的方法,並不能像之前那樣根據父類方法的索引直接可以確定子類方法的索引(因為父類的方法與子類的方法的索引相同),而是需要掃描整個方法表才能確定。這種呼叫介面引用的例項方法比類引用呼叫例項方法慢得多。

四、總結

  通過執行引擎的學習,明白了方法在虛擬機器內部如何被執行的。只有寫下來的知識理解才會更深刻,謝謝各位園友的觀看~

相關文章