天天看點

深入了解JVM虛拟機——JVM運作時棧結構和方法調用

作者:天之藍3385

JVM對于方法的執行是基于棧的,方法調用——入棧,方法調用完畢——出棧,了解JVM的運作時棧結構,有助于我們更加深入的分析、了解位元組碼和方法調用的執行過程。

而對于方法調用的學習,可以幫助我們從位元組碼層面了解方法的重載和重寫調用的規則。

1 運作時棧結構

1.1 棧幀

棧幀(Stack Frame)是用于支援虛拟機進行方法調用和方法執行的資料結構,它是虛拟機運作時資料區中的虛拟機棧的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動态連接配接和方法傳回位址等資訊。每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛拟機棧裡面從入棧到出棧的過程。

在編譯程式代碼時,棧幀需要最大多大的局部變量表、最深多深的操作數棧都已經完全确定,并寫入方法表Code屬性中,是以一個棧幀需要配置設定多少記憶體,不會受程式運作期資料的影響。

一個線程中的方法調用鍊可能會很長,很多方法都同時處于執行狀态。但是對于執行引擎來說,隻有位于棧頂的棧幀才是有效的,稱為目前棧幀,與這個棧幀關聯的方法稱為目前方法,定義這個方法的類稱為目前類。對局部變量表和操作數棧的各種操作,通常都指的是對目前棧幀的對局部變量表和操作數棧進行的操作。 如果目前方法調用了其他方法,或者目前方法執行結束,那這個方法的棧幀就不再是目前棧幀了。當一個新的方法被調用,一個新的棧幀也會随之而建立,并且随着程式控制權移交到新的方法而成為新的目前棧幀。當方法傳回的之際,目前棧幀會傳回此方法的執行結果給前一個棧幀,在方法傳回之後,目前棧幀就随之被丢棄,前一個棧幀就重新成為目前棧幀了。

棧幀是線程私有的資料,不可能在一個棧幀之中引用另外一條線程的棧幀。

典型的虛拟機棧幀結構如下圖所示:

深入了解JVM虛拟機——JVM運作時棧結構和方法調用
深入了解JVM虛拟機——JVM運作時棧結構和方法調用

1.2 局部變量表

局部變量表是一組變量值的存儲空間,用于存放方法參數和方法内部定義的局部變量。局部變量表中的變量隻在目前方法調用中有效, 當方法調用結束後, 随着方法棧幀的銷毀, 局部變量表也會随之銷毀。

在class類編譯的時候,某個方法的局部變量表的最大容量,就在方法的 Code 屬性的 max_locals 資料項中确定了下來,而局部變量則是儲存在Code屬性内的LocalVariableTable屬性表中。

局部變量表的容量以變量槽(Variable Slot,下稱 Slot)為最小機關,虛拟機規範中并沒有明确指明一個 Slot 應占用的記憶體空間大小,隻是很有導向性地說到每個 Slot 都應該能存儲一個 boolean、byte、char、short、int、float、reference 或 returnAddress 類型的資料,這 8 種資料類型,都可以使用 32 位或更小的實體記憶體來存放,在 Java 虛拟機的資料類型中,64 位的資料類型隻有 long 和 double 兩種,關于這幾種局部變量表中的資料有兩點需要注意:

  1. reference 資料類型,虛拟機規範并沒有明确指明它的長度,也沒有明确指明它的資料結構,但是虛拟機通過 reference資料可以做到兩點:1. 通過此 reference 引用,可以直接或間接的查找到對象在 Java 堆上的其實位址索引;2. 通過此reference 引用,可以直接或間接地查找到對象所屬資料類型在方法區中的存儲的類型資訊。
  2. 對于 64 位的 long 和 double 資料,虛拟機會以高位對齊的方式為其配置設定兩個連續的 Slot 空間。

在方法執行時,虛拟機是使用局部變量表完成參數變量清單的傳遞過程,如果是執行個體方法,那麼局部變量表中的每 0 位索引的 Slot 預設是用于傳遞方法所屬對象執行個體的引用,在方法中可以通過關鍵字 “this” 來通路這個隐藏的局部變量,其餘參數則按照參數清單的順序來排列,占用從 1 開始的Slot位置,參數表配置設定完畢後,再跟進方法體内部定義的變量順序和作用域來配置設定其餘的 Slot。

需要注意的是局部變量并不存在如類變量的"準備"階段,類變量會在類加載的時候經過“準備”和“初始化”階段,即使程式員沒有為類變量在 "初始化" 賦予初始值,也還是會在"準備"階段賦予系統的類型預設值,但是局部變量不會這樣,局部變量表沒有"準備"階段,是以需要程式員手動的為局部變量賦予初始值。

1.2.1 局部變量表對方法調用的影響

由于局部變量表在棧幀之中,會占用棧空間記憶體, 是以,如果方法的參數和局部變量較多,使得局部變量膨脹,進而每一次方法數調用就會占用更多的棧空間,最終導緻方法數的嵌套調用(比如遞歸)次數減少。

public class TestStackDeep {
    private static int count = 0;

    /**
     * 該方法内部有更多的局部變量,方法的最大遞歸調用次數将會更少
     * @param a
     * @param b
     * @param c
     */
    public static void recursion(long a, long b, long c) {
        long e = 1, f = 2, g = 3, h = 4, i = 5, k = 6, q = 7, x = 8, y = 9, z = 10;
        count++;
        recursion(a, b, c);
    }

    /**
     * 該方法内部有更少的局部變量,方法的最大遞歸調用次數将會更多
     */
    public static void recursion() {
        count++;
        recursion();
    }

    public static void main(String[] args) {
        try {
            //recursion(); //切換分别注釋這兩個方法,運作,觀察count的值
           recursion(0, 0, 0);
        } finally {
            System.out.println(count);
        }
    }
}           

運作後,可以看出來,局部變量更少的方法的遞歸調用深度可以更深。

1.2.2 局部變量表的Solt的複用

每一個局部變量都有自己的作用範圍(作用位元組碼範圍),為了盡可能節省棧幀空間, 局部變量表中的變量所在的Slot是可以重用的,方法體中定義的變量,其作用域并不一定會覆寫整個方法體,如果目前位元組碼PC計數器的值巳經超出了某個變量的作用域,那這個變量對應的Slot就可以交給其他變量使用。

public class SoltReuse {
    public static void solt1() {
        //a、b變量作用域都是該方法
        int a = 1;
        System.out.println(a);
        int b = 1;
    }

    public static void solt2() {
        //a變量作用域在該方法的代碼塊之中
        {
            int a = 1;
            System.out.println(a);
        }
        //b變量在a變量作用域之後
        int b = 1;
    }

    public static void main(String[] args) {

    }
}           

使用jclasslib打開class檔案,找到兩個方法的局部變量表:

深入了解JVM虛拟機——JVM運作時棧結構和方法調用
深入了解JVM虛拟機——JVM運作時棧結構和方法調用

可以看到,solt2方法的局部變量表Solt實作了複用。

局部變量表的變量也被垃圾回收器作為根節點來判斷,隻要被局部變量表直接或間接引用到的對象都不會被回收。在某些情況下,Slot的複用會直接影響到系統的垃圾收集行為。

如下案例,vm參數設定為 -XX:+PrintGC,分别運作下面幾個方法。

public class SoltGC {

    public void SoltGC0() {
        System.gc();
    }

    public void SoltGC1() {
        byte[] b = new byte[5 * 1024 * 1024];
        System.gc();
    }

    public void SoltGC2() {
        byte[] b = new byte[5 * 1024 * 1024];
        b = null;
        System.gc();
    }

    public void SoltGC3() {
        {
            byte[] b = new byte[5 * 1024 * 1024];
        }
        System.gc();
    }

    public void SoltGC4() {
        {
            byte[] b = new byte[5 * 1024 * 1024];
        }
        int c = 10;
        System.gc();
    }

    public void SoltGC5() {
        SoltGC1();
        System.gc();
    }

    public static void main(String[] args) {
        new SoltGC().SoltGC5();
    }
}           

其中solt0()方法用作對照。

運作solt0(),本人GC資訊為:

[GC (System.gc()) 5202K->848K(249344K), 0.0011430 secs] [Full GC (System.gc()) 848K->651K(249344K), 0.0046617 secs]

在空方法時,Young GC回收大約5000k,以此作為對照。後面的例子需要排除5000k

運作solt1(),本人GC資訊為:

[GC (System.gc()) 10322K->6000K(249344K), 0.0029231 secs] [Full GC (System.gc()) 6000K->5776K(249344K), 0.0044659 secs]

可以看到,Young GC後還剩下6000k,說明byte數組所占用的記憶體沒有被回收,因為byte數組被局部變量b引用,是以沒有回收記憶體。

運作solt2(),本人GC資訊為:

[GC (System.gc()) 10322K->912K(249344K), 0.0011081 secs] [Full GC (System.gc()) 912K->680K(249344K), 0.0048601 secs]

在垃圾回收前,先将變量b置為null,這樣byte就沒有了引用。

可以看到,Young GC後還剩下1000k左右,Young GC時把byte數組回收了。

運作solt3(),本人GC資訊為:

[GC (System.gc()) 10322K->6000K(249344K), 0.0036167 secs] [Full GC (System.gc()) 6000K->5800K(249344K), 0.0049001 secs]

我們在變量b的作用域之後進行了垃圾回收,由于變量b的作用域已經結束了,按理說GC應該會回收數組的記憶體,但是發現byte數組的記憶體并沒有被回收,這是為什麼呢?

代碼雖然已經離開了變量b的作用域,但在此之後,沒有任何對屁部變量表的讀寫操作——變量b原本所占用的Slot還沒有被其他變量複用,是以作為Gc Roots 根節點一部分的局部變量表仍然保持對它的關聯,這種關聯沒有被及時打斷,是以記憶體沒有被回收。

運作solt4(),本人GC資訊為:

[GC (System.gc()) 10322K->848K(249344K), 0.0014418 secs] [Full GC (System.gc()) 848K->656K(249344K), 0.0048550 secs]

可以看到記憶體被回收了,因為垃圾回收時在變量b的作用域之外,并且聲明了新變量c,此時變量c會複用變量b的槽位,對數組的引用此時被測底清除,是以随後的GC可以回收數組的記憶體。

運作solt5(),本人GC資訊為:

[GC (System.gc()) 10322K->6000K(249344K), 0.0030734 secs] [Full GC (System.gc()) 6000K->5800K(249344K), 0.0046043 secs] [GC (System.gc()) 5800K->5800K(249344K), 0.0006343 secs] [Full GC (System.gc()) 5800K->680K(249344K), 0.0041057 secs]

可以看到記憶體在外部方法調用GC方法時被回收了,雖然SoltGC1()犯法不會回收記憶體,但是SoltGC1()方法傳回後,它對應的棧幀也被銷毀了,自然局部變量表的的局部變量也不存在了,是以在第二個GC時,數組的記憶體可以被回收了。

1.3 操作數棧

操作數棧也常被稱為操作棧,它也是一個後入先出的棧結構。許多的位元組碼都需要通過操作數棧進行參數傳遞,是以它主要用于儲存計算過程的中間結果,同時作為計算過程中變量臨時的存儲空間。

當一個方法剛剛開始執行的時候,操作數棧是空的,在方法執行過程中,會有各種位元組碼指令往操作數棧中寫入和提取内容,也就是出棧、入棧操作。操作數棧中的資料類型必須與位元組碼指令序列比對,在編譯程式代碼時,編譯時必須嚴格保證這一點,在類校驗階段的資料流分析中還要在此驗證這一點。

例如,整數加法的位元組碼指令 iadd 在運作的時候,需要保證操作數棧棧頂兩個元素存入int 類型的值。iadd 會取出棧頂兩個元素,然後相加,之後把結果再存入操作數棧。

同局部變量表一樣,操作數棧的最大深度也是在編譯時期就寫入到方法表的 Code 屬性的 max_stacks 資料項中。操作數棧的每一個元素可以是可以是任意 Java 資料類型,包括 long 和 double,long 和 double 的資料類型占用兩個機關的棧深度,其他資料類型則占用以個機關的棧深度。

虛拟機的執行引擎又被稱為“基于棧的執行引擎”,其中的“棧”就是操作數棧。

兩個棧幀作為虛拟機棧的元素,理論上是完全互相獨立的。但在大多數虛拟機的實作裡,都會做一些優化處理,令兩個棧幀出現一部分重疊。讓下面棧幀的操作數棧和上面棧幀的部分局部變量表重疊在一起,這樣在進行方法調用時可以共用一部分資料,無需進行額外的參數複制傳遞,重疊過程如下圖所示:

深入了解JVM虛拟機——JVM運作時棧結構和方法調用

1.4 棧幀資訊

除了局部變量表和操作數棧外,Java 棧幀還需要一些資料來支援動态連結、方法傳回位址等資訊,他們統稱為棧幀資訊。

1.4.1 動态連結

每個棧幀都包含一個指向運作時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法調用過程中的動态連結(Dynamic Linking)。Class 檔案的常量池中存有大量的符号引用,位元組碼中的方法調用指令就以常量池中指向方法的符号引用作為參數。這些符号引用一部分會在類加載階段或第一次使用的時候就轉化為直接引用,這種轉化稱為靜态解析。另一部分在每次運作期間轉化為直接引用,這部分稱為動态連結。

1.4.2 方法傳回位址

一個方法開始執行後,隻有兩種方式可以退出這個方法:

  1. 當執行遇到傳回指令,會将傳回值傳遞給上層的方法調用者,這種退出的方式稱為正常完成出口(Normal Method Invocation Completion),一般來說,調用者的PC計數器可以作為傳回位址。
  2. 當執行遇到異常,并且目前方法體内沒有得到處理,就會導緻方法退出,此時是沒有傳回值的,稱為異常完成出口(Abrupt Method Invocation Completion),傳回位址要通過異常表來确定。

方法退出時,需要傳回到方法被調用的位置,程式才能繼續執行。方法正常退出時,調用者的 PC 計數器的值可以作為傳回位址,棧幀中很可能會儲存這個計數器值;而方法異常退出時,傳回位址是要通過異常器表來确定的,棧幀中一般不會儲存這部分資訊,在傳回調用方法中後會在調用方法中抛出相同的異常,并嘗試查找調用方法的異常表,來解決這個異常。

方法退出的過程實際上等同于把目前棧幀出棧,是以退出時可能執行的操作有:

  1. 恢複上層方法的局部變量表和操作數棧。
  2. 把傳回值壓入調用者棧幀的操作數棧。
  3. 調整 PC 計數器的值以指向方法調用指令後面的一條指令。

2 方法調用

方法調用即指确認調用哪個方法的過程,并不是指執行方法的過程。 由于在 java 代碼編譯成 class 檔案之後,在 class 檔案中存儲的是方法的符号引用(方法在常量池中的符号),并不是方法的直接引用(方法在記憶體布局中的入口位址),是以需要在加載或運作階段才會确認目标方法的直接引用。

2.1 解析調用

在類加載的解析階段,會将一部分方法符号引用轉化為直接引用,這種解析成立的前提是:方法在程式運作之前就有一個可确定的調用版本,并且這個方法的調用版本在運作期間不可變。換句話說,調用目标在程式寫好,編譯器編譯時就可以确定下來了。這類方法調用稱為解析。

在 Java 語言中符合“編譯期可知,運作期不可變”的方法主要包含靜态方法和私有方法兩大類。前者與類型直接關聯,後者在外部不可見,這兩種方法各自的特點決定了他們不可能通過繼承或别的方式重寫其他版本,是以适合在類加載階段進行解析。

Java 虛拟機規範裡提供了 5 條方法調用位元組碼指令:

invokestatic:調用靜态方法。

invokespecial:調用執行個體構造器方法、私有方法和父類方法。

invokevirturl:調用(執行個體)虛方法。

invokeinterface:調用接口方法,會在運作時确定一個實作此接口的對象。

invokedynamic:先在運作時動态解析出調用點限定符所引用的方法,然後再執行該方法,在此之前的 4 條調用指令,分派邏輯是固化在 Java 虛拟機内部的。而invokedynamic 的分派邏輯由使用者設定的引導方法決定。

隻要是被 invokestatic 和 invokespecial 指令調用的方法,都可以在解析階段确定唯一的調用版本,符合這個條件的有靜态方法、私有方法、執行個體構造器方法、父類方法,他們在類加載的時候就會把符号引用轉化為直接引用。這一類方法被稱為非虛方法,相對的其他方法就是虛方法(final 方法除外)。

除了使用 invokestatic 和 invokespecial 調用的方法之外,還有一種就是被 final 修飾的方法。雖然 final 方法是使用 invokevirtual 來調用的,但由于它無法被覆寫,又沒有其他版本,是以無需對方法接收者進行多态選擇。在 Java 虛拟機規範中,明确說明了 final 方法是非虛方法。

解析調用是一個靜态的過程,在編譯期間就完全确定,在類裝載的解析階段就會把涉及的符号引用全部轉變為可确定的直接引用,不會延遲到運作期才去完成。而分派 (Dispatch )調用則可能是靜态的,也可能是動态的,根據分派的宗量數又可以分為單分派、多分派。這兩類分派方式的兩兩組合就構成了靜态單分派、靜态多分派、動态單分派、動态多分派 4 種分派組合情況。

2.1 分派調用

Java 是一門面向對象的程式語言,因為 Java 具備面向對象的基本特征:繼承、封裝、多态。分派調用将會揭示多态性特征的一些最基本的展現,比如“重載”和“重寫”在 Java 虛拟機之中是如何實作的。

2.1.2 靜态分派

案例:

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();
        //hello guy
        sr.sayHello(man);
        //hello guy
        sr.sayHello(woman);
    }

    static abstract class Human {
    }
    static class Man extends Human {
    }
    static class Woman extends Human {
    }
}           

該程式運作結果如下:

hello, guy hello, guy

為什麼會選擇參數類型為 Human 的重載方法呢?在解決這個問題前,我們先看兩個重要的概念。

Human man = new Man();

對于上面的代碼,Human 是變量的靜态類型,而 Man 是變量的實際類型。 變量的靜态類型是編譯期就可以确定的,而實際類型需要等到運作時才能确定。虛拟機(準确的說是編譯器)在重載時是通過參數的靜态類型而非實際類型來作為判定依據的。因為靜态類型是編譯期可知的,javac 編譯器會根據參數的靜态類型決定使用哪個重載版本,是以選擇了 sayHello(Human) 作為調用目标,并把這個方法的符号引用寫到了 invokevirtual 的參數中。

利用 javap -v StaticDispatch.class檢視位元組碼檔案可以驗證這一點:

深入了解JVM虛拟機——JVM運作時棧結構和方法調用

所有依賴靜态類型來定位方法執行版本的分派動作稱為靜态分派,靜态分派的典型應用是方法重載。靜态分派發生在編譯期間,是以确定靜态分派的動作實際上不是由虛拟機來執行的。

另外,編譯器雖然能确定出方法的重載版本,但在很多情況下這個重載版本并不是“唯一的”,往往隻能确定一個“更加合适的”版本。 這種模糊的結論在由0和1構成的計算機世界中算是比較“稀罕”的事情,産生這種模糊結論的主要原因是字面量不需要定義,是以字面量沒有顯示的靜态類型,它的靜态類型隻能通過語言上的規則去了解和推斷。

下面代碼示範了何為“更加合适的”版本:

/**
 * 重載方法比對優先級
 *
 * @author lx
 */
public class Overload {

    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }

    public static void sayHello(int arg) {
        System.out.println("hello int");
    }

    public static void sayHello(long arg) {
        System.out.println("hello long");
    }

    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }

    public static void sayHello(char arg) {
        System.out.println("hello char");
    }

    public static void sayHello(char... arg) {
        System.out.println("hello char...");
    }

    public static void sayHello(long... arg) {
        System.out.println("hello Character...");
    }

    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}           

直接運作代碼,上面會輸出: hello char

因為‘a’是一個char類型的資料,自然會尋找參數類型為char的重載方法。

如果注釋掉sayHello(char arg)方法,那輸出會變為: hello int

這時發生了一次自動類型轉化,‘a’除了可以代表一個字元串,還可以代表數字97(字元‘a’的Unicode數值為十進制數字97),是以參數類型為int的重載也是合适的。

繼續注釋掉sayHello(int arg)方法,那輸出會變為: hello long

這時發生了兩次自動類型轉換,‘a’轉型為整數97之後,進一步轉型為長整數97L,比對了參數類型為long的重載。筆者在代碼中沒有寫其他的類型如float、double等的重載,不過實際上自動轉型還能繼續發生多次,按照char->int->long->float->double的順序轉型進行比對。但不會比對到byte和short類型的重載,因為char到byte或short的轉型是不安全的。

繼續注釋掉sayHello(long arg)方法,那輸出會變為: hello Character

這時發生了一次自動裝箱,‘a’被包裝為它的封裝類型java.lang.Character,是以比對到了參數類型為Character的重載。

繼續注釋掉sayHello(Character arg)方法,那輸出會變為: hello Serializable

出現hello Serializable,是因為java.lang.Serializable是java.lang.Character類出現的一個接口,當自動裝箱之後,發現還是找不到裝箱類,但是找到了裝箱類實作了的接口類型,是以緊接着又發生一次自動轉型。char可以轉型成int,但是Character是絕對不會轉型為Integer的,它隻能安全地轉型為它實作的接口或父類。Character還實作了另外一個接口java.lang.Comparable,如果同時出現兩個參數分别為Serializable和Comparable的重載方法,那它們在此時的優先級是一樣的。編譯器無法确定要自動轉型為哪種類型,會提示類型模糊,拒絕編譯。程式必須在調用時顯示地指令字面量的靜态類型,如:sayHello((Comparable)''a),才能編譯通過。

下面繼續注釋掉sayHello(Serializable arg)方法,輸出會變為: hello Object

這時是char裝箱後轉型為父類了,如果有多個父類,那将在繼承關系中從下往上開始搜尋,越接近上層的優先級越低。即使方法調用傳入的參數值為null時,這個規則仍然适用。

把sayHello(Object arg)也注釋掉,輸出将會變為: hello char...

7個重載方法已經被注釋得隻剩一個了,可見變長參數的重載優先級是最低的,這時候字元‘a’被當做一個數組元素。

靜态方法會在類加載期就進行解析,而靜态方法顯然也是可以擁有重載版本的,選擇重載版本的過程也是通過靜态分派完成的。

補充: 使用idea開發的工作者,如果不知道具體使用的那個一版本的方法,可以将滑鼠放在調用方法上,然後按住ctrl,然後左鍵點選該方法,就會自動跳轉到具體調用的方法處。

2.1.3 動态分派

動态分派和多态性的另一個重要展現:重寫 Override 有着密切的關聯。 先看一下動态分派的例子:

/**
 * 動态分派
 */
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 say hello
        man.sayHello();
        //woman say hello
        woman.sayHello();
        //改變實際類型不改變靜态類型
        man = new Woman();
        //woman say hello
        man.sayHello();
    }
}           

執行程式,輸出如下所示:

hello man hello woman hello woman

虛拟機是怎樣去調用哪個方法的?顯然這裡是不能根據靜态類型來決定的,因為靜态類型同樣都是Human的兩個變量man和woman在調用sayHello()方法時執行了不同的行為,并且變量man在兩次調用中執行了不同的方法。導緻這個現象的原因很明顯,是這兩個變量的實際類型不同,Java虛拟機是如何根據實際類型來分派方法執行版本的呢?使用javap -v DynamicDispatch.class指令輸出這段代碼的位元組碼,嘗試從中尋找答案:

深入了解JVM虛拟機——JVM運作時棧結構和方法調用

可以看到,位元組碼中執行 DynamicDispatch$Human.sayHello 的是 invokevirtual 指令,執行之前通過 aload_1 和 aload_2 把相關對象從局部變量表複制到了操作棧棧頂。invokevirtual 指令的運作時解析過程大緻分為以下幾個步驟:

  1. 找到操作數棧棧頂第一個元素所指向的對象的實際類型,記作 C;
  2. 如果在類型 C 中找到與常量中的描述符和簡單名稱都相符的方法,則進行通路權限校驗。如果通過則傳回這個方法的直接引用,查找過程結束;如果不通過,則傳回 java.lang.IllegalAccessError 異常;
  3. 否則,按照繼承關系從下往上依次對 C 的各個父類進行第 2 步的搜尋和驗證過程。
  4. 如果始終沒有找到合适的方法,則抛出 java.lang.AbstractMethodError 異常。

由于 invokevirtual 指令的第一步就是在運作期确定接收者的實際類型,是以兩次調用中的 invokevirtual 指令把相同的類符号引用解析到了不同的直接引用上,這個過程就是 Java 語言中方法重寫的本質。我們把這種在運作期根據實際類型确定方法執行版本的過程稱為動态分派。

2.1.4 單分派與多分派

方法的接收者與方法的參數統稱為方法的宗量,根據分派基于多少種宗量,可以将分派劃分為單分派和多分派兩種。 單分派是根據一個宗量對目标方法進行選擇,多分派是根據多餘一個宗量對目标方法進行選擇。

在 Java 語言中靜态分派要同時考慮實際類型和方法參數,是以 Java 語言中的靜态分派屬于多分派類型。而在執行 invokevirtual 指令時,唯一影響虛拟機選擇的隻有實際類型,是以 Java 語言中的動态分派屬于單分派類型。

2.1.5 虛拟機動态分派的實作

由于動态分派是非常頻繁的動作,而動态分派在方法版本選擇過程中又需要在方法中繼資料中搜尋合适的目标方法,虛拟機實作出于性能的考慮,通常不直接進行如此頻繁的搜尋,而是采用優化方法。

其中一種“穩定優化”手段是:在類的方法區中建立一個虛方法表(Virtual Method Table, 也稱vtable, 與此對應,也存在接口方法表——Interface Method Table,也稱itable)。使用虛方法表索引來代替中繼資料查找以提高性能。其原理與C++的虛函數表類似。

虛方法表中存放的是各個方法的實際入口位址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的位址入口和父類中該方法相同,都指向父類的實作入口。虛方法表一般在類加載的連接配接階段進行初始化。

繼續閱讀