虛擬機字節碼執行引擎

所謂的「虛擬機字節碼執行引擎」其實就是 JVM 根據 Class 文件中給出的字節碼指令,基於棧解釋器的一種執行機制。通俗點來講,也就是 JVM 解析字節碼指令,輸出運行結果的一個過程。接下來咱們詳細看看這部份內容。java

方法調用的本質

在描述「字節碼執行引擎」以前,咱們先從彙編層面看看基於棧幀的方法調用是怎樣的。(以 IA32 型 CPU 指令集爲例)git

IA32 的程序中使用棧幀數據結構來支持過程調用(Java 語言中稱做方法),每一個過程對應一個棧幀,過程的調用對應與棧幀的入棧和出棧。某個時刻,只有位於棧頂的棧幀可用,它表明了某個方法正在執行中的各類狀態。最頂端的棧幀用兩個指針界定,棧指針,幀指針。他們對應於棧中的地址分別存儲在寄存器 %ebp%esp 中。棧中的大體結構以下:github

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);
}
複製代碼

很簡單的一段代碼,咱們彙編生成相應的彙編代碼,省略了部分連接代碼,留下的是核心的部分:bash

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 中。ide

這時就到了核心的 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
相關文章
相關標籤/搜索