天天看點

深入了解Java虛拟機之虛拟機位元組碼執行引擎運作時棧幀結構方法調用

執行引擎是java虛拟機最核心的組成部分之一。

實體機的執行引擎是建立在處理器、硬體、指令集和作業系統層面上的,而虛拟機的執行引擎是由自己實作的,可以自行制定指令集與執行引擎的結構體系,并且能夠執行那些硬體不直接支援的指令集格式。

執行引擎在執行Java代碼時候可能會有解釋執行(通過解釋器執行)和編譯執行(通過即時編譯器産生本地代碼執行)兩種選擇,也可以兩者兼備,甚至可以包含幾個不同級别的編譯器執行引擎。

運作時棧幀結構

棧幀是用于支援虛拟機進行防腐調用和方法執行的資料結構,它是虛拟機運作時資料區中的虛拟機棧的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動态連接配接和方法傳回位址等資訊。

每一個棧幀都包括了局部變量表、操作數棧、動态連接配接、方法傳回位址和一些額外的附加資訊。

深入了解Java虛拟機之虛拟機位元組碼執行引擎運作時棧幀結構方法調用

在活動線程中,隻有位于棧頂的棧幀才是有效的,稱為目前棧幀,與這個棧幀相關聯的方法稱為目前方法。執行引擎運作的所有位元組碼指令隻對目前棧幀進行操作。棧幀是随着方法調用而建立,随着方法結束而銷毀。

棧幀是線程本地私有的,不可能在一個棧幀中引用另一個線程的棧幀。

局部變量表

局部變量表是一組變量值存儲空間,用于存放方法參數和方法内部定義的局部變量。在方法的Code屬性的

max_locals

資料項中确定了該方法所需要配置設定的局部變量表的最大容量。

局部變量表的容量以變量槽(Slot)為最小機關。一個局部變量可儲存一個boolean、byte、char、short、int、float、reference和returnAddress類型的資料。兩個局部變量可以儲存long、double的資料。

reference類型表示對一個對象執行個體的引用。一般來說通過這個引用做兩點:一是從此引用中直接或間接地查找到對象在Java堆中的資料存放的起始位址索引。二是從此引用中直接或間接查找到對象所屬資料類型在方法區中的存儲的類型資訊,否則無法實作Java語言規範中定義的文法限制。

returnAddress類型是為了位元組碼指令

jsr

jsr_w

ret

服務的,指向了一條位元組碼指令的位址。

虛拟機使用索引來進行定位通路,索引值從0到局部變量表最大的Slot數量。若通路32位資料類型,索引n就代表了第n個Slot,如果是64位資料類型,則會通路第n個和第n+1個,且不允許采用任何方式單獨通路其中的某一個,如果遇到這個情況,虛拟機會在類加載的校驗階段抛出異常。

虛拟機使用局部變量表完成參數值到參數變量表的傳遞過程的,如果是執行的是執行個體方法,那局部變量表的第0位Slot預設用于傳遞方法所屬對象執行個體的引用,在方法中可以通過關鍵字“this”來通路到這個隐含的參數,其餘參數按參數表順序排列,配置設定完畢後,再根據方法體内部定義的變量順序和作用域配置設定其餘的Slot。

局部變量表的Slot是可以重用的,如果目前位元組碼PC計數器的值超過了某個變量的作用域,那這個變量對應的Slot就可以交給其他變量使用。

如果一個局部變量表定義了但是并未賦初始值是不能使用的。

操作數棧

也稱為為操作棧,是一個後入先出的棧。最大深度在編譯時寫入到Code屬性的

max_stacks

資料項中。操作數棧每一個元素可以是任意的java的資料類型,32位資料類型所占容量為1,64位的資料類型所占容量為2。

在方法的執行過程中,會有各種位元組碼指令往操作數棧寫入和提取内容,就是出棧/入棧操作。

操作數棧中元素的資料類型必須與位元組碼指令的序列嚴格比對,在編譯程式代碼時候,編譯器要嚴格保證這一點,在類校驗階段的資料流分析中還要再次驗證。

在概念模型中,兩個棧幀作為虛拟機棧的元素,是完全獨立的,但是實際中,會令兩個棧幀出現一部分重疊,讓下面的棧幀部分操作數棧與上面棧幀的部分局部變量表重疊在一起,便于傳遞資料。

深入了解Java虛拟機之虛拟機位元組碼執行引擎運作時棧幀結構方法調用

動态連接配接

每個棧幀都包含一個指向運作時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法調用過程中的動态連接配接。

符号引用一部分會在類加載階段或者第一次使用的時候轉化為直接引用,這種轉化稱為靜态解析。另外一部分将在每一次運作期間轉化為直接引用,這部分稱為動态連接配接。

方法傳回位址

隻有兩種方法可以在方法運作時退出。

  • 第一種是執行引擎遇到任意一個方法傳回的位元組碼指令,這時候可能會有傳回值傳遞給上層的方法調用者,是否有傳回值和傳回值的類型根據遇到何種方法傳回指令來決定,這種退出方法的方式稱為正常完成出口。
  • 第二種方式是,在方法執行過程中遇到異常,并且這個異常沒有在方法體内得到處理,無論是java虛拟機内部産生的異常,還是代碼中使用

    athrow

    位元組碼指令産生的異常,隻要在本方法的異常表中沒有搜尋到比對的異常處理器,就會導緻方法退出,這種退出方法的方式稱為異常完成出口。

    以上兩種方式,在退出之後都需要傳回到方法被調用的位置。

方法正常退出時,調用者的PC計數器的值可以作為傳回位址,棧幀中很可能會儲存這個計數器的值。而方法異常退出時,傳回位址是要通過異常處理器表來确定的,棧幀一般不會儲存。

附加資訊

虛拟機允許增加一些規範中沒有描述的資訊到棧幀之中,這部分資訊完全取決于具體的虛拟機實作。

一般會把動态連接配接、方法傳回位址與其他附加資訊全部歸為一類,稱為棧幀資訊。

方法調用

方法調用階段的唯一任務是确定被調用方法的版本(調用哪一個方法)。一切方法調用在Class檔案裡面存儲的都隻是符号引用,而隻有在類加載期間,甚至到運作期間才能确定目标方法的直接引用。

解析

方法在程式真正執行之前有一個可确定的調用版本,并且這個方法的調用版本在運作期是不可改變的,這類方法的調用稱為解析。

符合上述“編譯器可知,運作期不可變”得方法,主要包括靜态方法和私有方法。前者與類型直接關聯,後者在外部不可被通路。是以它們适合在類加載階段進行解析。

java提供5種調用位元組碼指令方法:

  • invokestatic

    調用靜态方法
  • invokespecial

    調用執行個體構造器方法、私有方法和父類方法
  • invokevirtual

    調用所有的虛方法
  • invokeinterface

    調用接口方法,會在運作時再确定一個實習此接口的對象
  • invokedynamic

    在運作時動态解析出調用點限定符所引用的方法,然後再執行該方法。

隻要能被

invokestatic

invokespecial

指令調用的方法,都可以在解析階段中确定唯一的版本,符合這個條件的有靜态方法、私有方法、執行個體構造器、父類方法4類,它們在類加載時就會把符号引用解析為該方法的直接引用。這些方法可以稱為非虛方法。

雖然final方法是使用

invokevirtual

指令來調用。但是因為被final修飾的方法無法被重載,沒有其他版本,是以在虛拟機規範中說明final修飾的方法為非虛方法。

解析調用是個靜态過程,在編譯器就完全确定,在類裝載的解析階段就會把涉及的符号引用全部轉變為可确定的直接引用。

分派

靜态分派

public class StaticDispatch {

    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman 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(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!
           

Human man = new Man();

Human稱為變量的靜态類型,Man則稱為變量的實際類型。

靜态類型的變化僅僅在使用時發生,變量本身的靜态類型不會被改變,并且最終的靜态類型是在編譯期可知的;而實際類型變化的結果在運作期才可确定,編譯器在編譯程式的時候并不知道一個對象的實際類型是什麼。

虛拟機在重載時通過參數的靜态類型而不是實際類型作為判定依據的。并且靜态類型是編譯器可知的,是以,在編譯階段,Javac編譯器會根據參數的靜态類型決定使用哪個重載版本。

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

編譯器雖然能确定出方法的重載版本,但在很多情況下這個重載版本隻能确定一個“更加合适的”。因為字面量不需要定義,是以字面量沒有顯式的靜态類型,而靜态類型隻有通過語言上規則去了解和推斷。

動态分派

動态分派和重寫有着密切的關聯。

invokevirtual指令的運作時解析過程大緻分為:

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

以上為java語言中重寫的本質,在運作期間根據實際類型确定方法執行版本的分派過程稱為動态分派。

單分派和多分派

方法的接受者與方法的參數統稱為方法的宗量。

根據分派基于多少種宗量,可以将分派劃分為單分派和多分派兩種。單分派是基于一個宗量對目标方法進行選擇,多分派則是根據多于一個宗量對目标方法進行選擇。

靜态分派中,影響虛拟機選擇因素有方法名和參數,是以是多分派。

動态分派中,影響虛拟機選擇因素隻有接受者的實際類型,是以是單分派。

虛拟機動态分派的實作

動态分派是非常頻繁的動作,且動态分派的方法版本選擇過程需要運作時在類的方法中繼資料中搜尋合适的目标方法,是以基于性能的考慮,最常用的“穩定優化”手段就是在為類在方法區中建立一個虛方法表,使用虛方法表索引來代替中繼資料查找以提高性能。

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());
    }
}
           

代碼所對應的方法表

深入了解Java虛拟機之虛拟機位元組碼執行引擎運作時棧幀結構方法調用

虛方法表中存放着各個方法的實際入口位址,如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的位址入口和父類相同方法的位址入口一緻,指向父類入口。如果子類重寫了方法,則指向子類的入口位址。

具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引序号,這樣當類型變換時,僅需要變更查找的方法表,就可以從不同的虛方法表中按索引轉換出所需的入口位址。

方法表一般在類加載的連接配接階段進行初始化,準備了類的變量初始值後,虛拟機會把該類的方法表也初始化完畢。

虛拟機在條件允許下,還會使用内聯緩存和基于“類型繼承關系分析技”技術的守護内聯兩種非穩定的“激進優化”手段來獲得更高的性能。

參考來自《深入了解Java虛拟機》