虛擬機器位元組碼執行引擎

YangAM發表於2018-03-29

所謂的「虛擬機器位元組碼執行引擎」其實就是 JVM 根據 Class 檔案中給出的位元組碼指令,基於棧直譯器的一種執行機制。通俗點來說,也就是 JVM 解析位元組碼指令,輸出執行結果的一個過程。接下來我們詳細看看這部分內容。

方法呼叫的本質

在描述「位元組碼執行引擎」之前,我們先從彙編層面看看基於棧幀的方法呼叫是怎樣的。(以 IA32 型 CPU 指令集為例)

IA32 的程式中使用棧幀資料結構來支援過程呼叫(Java 語言中稱作方法),每個過程對應一個棧幀,過程的呼叫對應與棧幀的入棧和出棧。某個時刻,只有位於棧頂的棧幀可用,它代表了某個方法正在執行中的各種狀態。最頂端的棧幀用兩個指標界定,棧指標,幀指標。他們對應於棧中的地址分別儲存在暫存器 %ebp%esp 中。棧中的大致結構如下:

image

棧指標始終指向棧頂元素,控制著棧中元素的出入棧,幀指標指向的是當前棧幀的底部,注意是當前棧幀,不是整個棧的底部。

下面我們看看一段 C 程式碼:

#include<stdio.h>
void sayHello(int age)
{
    int x = 32;
    int y = 2323;
    age = x + y;
}

void main()
{
    int age = 22;
    sayHello(age);
}
複製程式碼

很簡單的一段程式碼,我們彙編生成相應的彙編程式碼,省略了部分連結程式碼,留下的是核心的部分:

main:
	pushl	%ebp
	movl	%esp, %ebp
	subl	$20, %esp
	movl	$22, -4(%ebp)
	movl	-4(%ebp), %eax
	movl	%eax, (%esp)
	call	sayHello
	leave
	ret
	
sayHello:
	pushl	%ebp
	movl	%esp, %ebp
	subl	$16, %esp
	movl	$32, -4(%ebp)
	movl	$2323, -8(%ebp)
	movl	-8(%ebp), %eax
	movl	-4(%ebp), %edx
	addl	%edx, %eax
	movl	%eax, -12(%ebp)
	leave
	ret
複製程式碼

先看 main 函式的彙編程式碼,main 函式裡的前兩個彙編指令和 sayHello 中的前兩條指令是一樣的,我們在留到後者裡介紹。

subl 指令將暫存器 %esp 中的地址減去 20,即棧指標向上擴充套件了 20 個位元組(棧是倒著生長的),也就是為當前棧幀分配了 20 個位元組大小。接著,movl 將值 20 寫入地址 -4(%ebp),這個地址其實就是相對暫存器 %ebp 幀指標位置之上的四個位元組處。假如 %ebp 的值為:0x14,那麼 20 就被儲存到地址 0x10 的棧地址中。

接著一條 movl 指令將引數 age 的值取出來存入暫存器 %eax 中。

這時就到了核心的 call 方法了,計算機中有程式計數器(PC)來指向下一條指令的位置,而常常我們的程式會呼叫到其他方法裡,那麼呼叫結束後又該如何恢復呼叫前的狀態並繼續執行呢?

這裡的解決辦法是,call 指令的第一步就是將返回地址壓棧,然後跳向 sayHell 方法中執行,這裡我們看不到它壓棧的過程,被整合為一條指令了。

然後跳向了 sayHello 方法的第一條指令開始執行,pushl 將暫存器 %ebp 中的地址壓棧,這時候的 %ebp 是上一個棧幀的幀指標地址,這個操作其實是一個儲存的動作。然後,movl 指令將幀指標指向棧指標的位置,也就是棧頂位置,繼而將棧指標向上擴充套件 16 個位元組。

接著,將數值 32 和 2323 分別寫入不同的棧地址中,這個地址相對於幀指標的地址,是可以計算出來的。

後面的操作是將 x 和 y 分別寫入暫存器 %eax 和 %edx,然後 add 指令做加法運算並存入暫存器 %eax 中。接著將結果壓棧。

leave 指令等效於以下兩條指令之和:

movl %ebp %esp
popl %ebp
複製程式碼

什麼意思呢?

把棧指標退回到幀指標的位置,也就是當前棧幀的底部,接著彈棧,這樣的話整個 sayHello 所佔用的棧幀就已經無法引用了,相當於釋放了當前棧幀。

ret 指令用於恢復呼叫前的狀態,繼續執行 main 方法。

整個 IA32 的方法呼叫基本如上,對於 64 位的 x86-64 來說,增加了 16 個暫存器,優先使用暫存器進行引數的計算與傳遞,效率提高了。但是與這個基於棧的儲存方式來說,劣勢之處在於「可移植性差」,不同的機器的暫存器使用肯定是有所差別的。所以我們的 Java 毋庸置疑使用的是棧。

執行時棧幀結構

在 Java 中,一個棧幀對應一個方法呼叫,方法中需涉及到的區域性變數、運算元,返回地址等都存放在棧幀中的。每個方法對應的棧幀大小在編譯後基本已經確定了,方法中需要多大的區域性變數表,多深的運算元棧等資訊早以被寫入方法的 Code 屬性中了。所以執行期,方法的棧幀大小早已固定,直接計算並分配記憶體即可。

區域性變數表

區域性變數表用來存放方法執行時用到的各種變數,以及方法引數。虛擬機器規範中指明,區域性變數表的容量用變數槽(slot)為最小單位,卻沒有指明一個 slot 的實際空間大小,只是說,每個 slot 應當能夠存放任意一個 boolean,byte,char,short,int,float,reference 等。

按照我的理解,一個 slot 相當於一個黑盒子,具體佔幾個位元組適情況而定,但是這個黑盒子明確可以儲存一個任意型別的變數。

區域性變數表不同於運算元棧,它採用索引機制訪問元素,而不同於運算元棧的出入棧方式。例如:

public void sayHello(String name){
        int x = 23;
        int y = 43;
        x++;
        x = y - 2;
        long z = 234;
        x = (int)z;
        String str = new String("hello wrold ");
    }
複製程式碼

我們反編譯看看它的區域性變數表:

image

可以看到,區域性變數表第一項是名為 this 的一個類引用,它指向堆中當前物件的引用。接著就是我們的方法引數,區域性變數 x,y,z 和 str。

這其實也間接說明了,我們的每個例項方法都預設傳入了一個引數 this,指向當前類的例項引用。

運算元棧

運算元棧也稱作操作棧,它不像區域性變數表採用的索引機制訪問其中元素,而是標準的棧操作,入棧出棧,先入後出。運算元棧在方法執行之初為空,隨著方法的一步一步執行,運算元棧中將不停的發生入棧出棧操作,直至方法執行結束。

運算元棧是方法執行過程中很重要的一個部分,方法執行過程中各個中間結果都需要藉助運算元棧進行儲存。

返回地址

一個方法在呼叫另一個方法結束之後,需要返回撥用處繼續執行後續的方法體。那麼呼叫其他方法的位置點就叫做「返回地址」,我們需要通過一定的手段保證,CPU 執行其他方法之後還能返回原來呼叫處,進而繼續呼叫者的方法體。

正如我們一開始介紹的彙編程式碼一樣,這個返回地址往往會被提前壓入呼叫者的棧幀中,當方法呼叫結束時,取出棧頂元素即可得到後續方法體執行入口。

方法呼叫

方法呼叫算是本篇的一個核心內容了,它解決了虛擬機器對目標呼叫方法的確定問題,因為往往一條虛擬機器指令要求呼叫某個方法,但是該方法可能會有過載,重寫等問題,那麼虛擬機器又該如何確定呼叫哪個方法呢?這就是本階段要處理的唯一任務。

首先我們要談談這個解析過程,從上篇文章中可以知道,當一個類初次載入的時候,會在解析階段完成常量池中符號引用到直接引用的替換。這其中就包括方法的符號引用翻譯到直接引用的過程,但這隻針對部分方法,有些方法只有在執行時才能確定的,就不會被解析。我們稱在類載入階段的解析過程為「靜態解析」。

那麼哪些方法是被靜態解析了,哪些方法需要動態解析呢?

比如下面這段程式碼:

Object obj = new String("hello");
obj.equals("world");
複製程式碼

Object 類中有一個 equals 方法,String 類中也有一個 equals 方法,上述程式顯然呼叫的是 String 的 equals 方法。那麼如果我們載入 Object 類的時候將 equals 符號引用直接指向了本身的 equals 方法的直接引用,那麼上述的 obj 永遠呼叫的都是 Object 的 equals 方法。那我們的多型就永遠實現不了。

只有那些,「編譯期可知,執行時不變」的方法才可以在類載入的時候將其進行靜態解析,這些方法主要有:private 修飾的私有方法,類靜態方法,類例項構造器,父類方法

其餘的所有方法統稱為「虛方法」,類載入的解析階段不會被解析。這些方法的呼叫不存在問題,虛擬機器直接根據直接引用即可找到方法的入口,但是「非虛方法」就不同了,虛擬機器需要用一定的策略才能定位到實際的方法,下面我們一起來看看。

靜態分派

首先我們看一段程式碼:

public class Father {
}
public class Son extends Father {
}
public class Daughter extends Father {
}
複製程式碼
public class Hello {
    public void sayHello(Father father){
        System.out.println("hello , i am the father");
    }
    public void sayHello(Daughter daughter){
        System.out.println("hello i am the daughter");
    }
    public void sayHello(Son son){
        System.out.println("hello i am the son");
    }
}
複製程式碼
public static void main(String[] args){
    Father son = new Son();
    Father daughter = new Daughter();
    Hello hello = new Hello();
    hello.sayHello(son);
    hello.sayHello(daughter);
}
複製程式碼

輸出結果如下:

hello , i am the father

hello , i am the father

不知道你答對了沒有?這是一道很常見的面試題,考的就是你對方法過載的理解以及方法分派邏輯懂不懂。下面我們來分析一下:

首先需要介紹兩個概念,「靜態型別」和「實際型別」。靜態型別指的是包裝在一個變數最外層的型別,例如上述 Father 就是所謂的靜態型別,而 Son 或是 Daughter 則是實際型別。

我們的編譯器在生成位元組碼指令的時候會根據變數的靜態型別選擇呼叫合適的方法。就我們上述的例子而言:

image

這兩個方法就是我們 main 函式中呼叫的兩次 sayHello 方法,但是你會發現傳入的引數型別是相同的,Father,也就是呼叫的方法是相同的,都是這個方法:

(LStaticDispathch/Father;)V

也就是

public void sayHello(Father father){}

所有依賴靜態型別來定位方法執行版本的分派動作稱作「靜態分派」,而方法過載是靜態分派的一個典型體現。但需要注意的是,靜態分派不管你實際型別是什麼,它只根據你的靜態型別進行方法呼叫。

動態分派

public class Father {
    public void sayHello(){
        System.out.println("hello world ---- father");
    }
}
public class Son extends Father {
    @Override
    public void sayHello(){
        System.out.println("hello world ---- son");
    }
}
複製程式碼
public static void main(String[] args){
    Father son = new Son();
    son.sayHello();
}
複製程式碼

輸出結果:

hello world ---- son

顯然,最終呼叫了子類的 sayHello 方法,我們看生成的位元組碼指令呼叫情況:

image

image

看到沒?編譯器為我們生成的方法呼叫指令,選擇呼叫的是靜態型別的對應方法,但是為什麼最終的結果卻呼叫了是實際型別的對應方法呢?

當我們將要呼叫某個型別例項的具體方法時,會首先將當前例項壓入運算元棧,然後我們的 invokevirtual 指令需要完成以下幾個步驟才能實現對一個方法的呼叫:

  • 彈出運算元棧頂部元素,判斷其實際型別,記做 C
  • 在型別 C 中查詢需要呼叫方法的簡單名稱和描述符相同的方法,如果有則返回該方法的直接引用
  • 否則,向 C 的父類再做搜尋,有即返回方法的直接引用
  • 否則,丟擲異常 java.lang.AbstractMethodError 異常

所以,我們此處的示例呼叫的是子類 Son 的 sayHello 方法就不言而喻了。

至於虛擬機器為什麼能這麼準確高效的搜尋某個類中的指定方法,各個虛擬機器的實現各有不同,但最常見的是使用「虛方法表」,這個概念也比較簡單,就是為每個型別都維護一張方法表,該表中記錄了當前型別的所有方法的描述資訊。於是虛擬機器檢索方法的時候,只需要從方法表中進行搜尋即可,當前型別的方法表中沒有就去父類的方法表中進行搜尋。

動態型別特性的支援

動態型別語言的一個關鍵特徵就是,型別檢查發生在執行時。也就是說,編譯期間編譯器是不會管你這個變數是什麼型別,呼叫的方法是否存在的。例如:

Object obj = new String("hello-world");
obj.split("-");
複製程式碼

Java 中,兩行程式碼是不能通過編譯器的,原因就是,編譯器檢查變數 obj 的靜態型別是 Object,而 Object 類中並沒有 subString 這個方法,故而報錯。

而如果是動態型別語言的話,這段程式碼就是沒問題的。

靜態語言會在編譯期檢查變數型別,並提供嚴格的檢查,而動態語言在執行期檢查變數實際型別,給了程式更大的靈活性。各有優劣,靜態語言的優勢在於安全,缺點在於缺乏靈活性,動態語言則是相反的。

JDK1.7 提供了兩種方式來支援 Java 的動態特性,invokedynamic 指令和 java.lang.invoke 包。這兩者的實現方式是類似的,我們只介紹後者的基本內容。

//該方法是我自定義的,並非 invoke 包中的
public static MethodHandle getSubStringMethod(Object obj) throws NoSuchMethodException, IllegalAccessException {
    //定義了一個方法模板,規定了待搜尋的方法的返回值和引數型別
    MethodType methodType = MethodType.methodType(String[].class,String.class);
    //查詢符合指定方法簡單名稱和模板資訊的方法
    return lookup().findVirtual(obj.getClass(),"split",methodType).bindTo(obj);
}
複製程式碼
public static void main(String[] args){
    Object obj = new String("hello-world");
    //定位方法,並傳入引數執行方法
    String[] strs = (String[]) getSubStringMethod(obj).invokeExact("-");
    System.out.println(strs[0]);
}
複製程式碼

輸出結果:

hello

你看,雖然我們 obj 的靜態型別是 Object,但是通過這種方式,我就是能夠越過編譯器的型別檢查,直接在執行期執行我指定的方法。

具體如何實現的我就不帶大家看了,比較複雜,以後有機會單獨寫一篇文章學習一下。反正通過這種方式,我們可以不用管一個變數的靜態型別是什麼,只要它有我想要調的方法,我們就可以在執行期直接呼叫。

總結一下,HotSpot 虛擬機器基於運算元棧進行方法的解釋執行,所有運算的中間結果以及方法引數等等,基本都伴隨著出入棧的操作取出或儲存。這種機制最大的優勢在於,可移植性強。不同於基於暫存器的方法執行機制,對底層硬體依賴過度,無法很輕易的跨平臺,但是劣勢也很明顯,就是同樣的操作需要相對更多的指令才能完成。


文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。

image

相關文章