1. 程式人生 > >深入理解JVM(八)——位元組碼執行引擎

深入理解JVM(八)——位元組碼執行引擎

不用虛擬機器,執行引擎在執行Java程式碼時,會有解釋執行(通過直譯器執行)和編譯執行(通過及時編譯器產生原生代碼執行)兩種選擇。

執行時棧幀結構

棧幀用於支援虛擬機器進行方法呼叫和方法執行的資料結構。棧幀儲存了方法的區域性變量表,運算元棧,動態連線和方法返回地址等資訊。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器棧裡面 從入棧到出棧的過程。

一個執行緒中的方法呼叫鏈可能很長,很多方法都同時處於執行狀態,對於執行引擎來說,在活動的執行緒中,棧頂的棧幀才是有效的,稱為當前棧幀,與這個棧幀相關聯的方法稱為當前方法。

區域性變量表

是一組變數組儲存的空間,用於存放方法引數,方法內部定義的區域性變數。在編譯期間,就在方法的Code屬性的max_locals資料項中確定了該方法所需要分配的區域性變量表的最大容量。

區域性變量表以變數槽slot為最小單位,一般為4個位元組,64位虛擬機器使用64位的實體記憶體空間,採用對齊和補白的手段解決位數不夠的問題。

Java中佔用32位以內的資料型別為boolean,byte,char,short,int,float,reference,returnAddress 8種資料型別,reference是對物件例項的引用,一般至少通過這個引用做到兩點,從此引用中直接或間接找到物件在Java堆中的資料存放的起始地址索引,直接或間接地查詢到物件所屬資料型別在方法區中的儲存的型別資訊。

虛擬機器採用索引定位的方式使用區域性變量表,從0到最大的slot數量。在方法執行時,虛擬機器使用區域性變量表完成引數變數列表的傳遞過程,對於非static方法,slot[0]即為this引用,引數表分配完畢後,再根據方法內部定義的變數順序和作用域分配其餘的slot。為了節省棧幀空間,slot可重用,噹噹前位元組碼PC計數器的值以及超出了某個變數的作用域,那麼對應的slot可以重用。

區域性變量表中的slot是否還存在關於物件的引用,關係到GC。

區域性變數和類變數不一樣,區域性變數定義了但是沒有賦初始值是不能使用的。

運算元棧

運算元棧的最大深度也是編譯的時候寫到Code屬性的max_stacks資料項中,元素就可以是任意的Java資料型別,32位佔一個棧容量,6位佔兩個。

一個方法剛開始執行時,方法的運算元棧是空的,方法的執行過程中會有各種位元組碼指令往運算元中寫入和提取內容,如做算術運算時通過運算元棧進行,呼叫其它方法的時候通過運算元棧來進行引數的傳遞。

如果iadd指令,從佔中取兩個int型的數值,相加後將結果入棧。位元組碼指令必須與運算元棧資料型別一致,編譯時必須保證,並且類校驗階段的資料流分析也會再次驗證。

概念模型中兩個棧幀是完全獨立的,但是虛擬機器的實現都會做一些優化,令兩個棧幀出現一部分重疊。讓下面棧幀的部分運算元棧與上面棧幀的部分區域性變量表重疊,這樣方法呼叫時就可以共用一部分資料。

動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線。(下文會具體講解)

方法返回地址

一個方法開始執行後只有兩種方式退出該方法,執行引擎遇到任意一個方法返回的位元組碼指令,這個時候會有返回值傳遞給上層的呼叫者,,這種為正常完成出口。另一種是方法執行過程中遇見異常,異常沒在方法中處理,,產生athrow指令,導致方法退出,稱為異常完成出口,不會給上層呼叫返回任何值。

無論怎麼退出,退出時,都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要棧幀中保持的一些資訊,來恢復方法執行。一般方法正常退出,呼叫者的PC計數器的值可以作為返回地址,棧幀中會保留這個計數器的值。異常退出時,返回地址通過異常處理器表確定,棧幀中不儲存這部分資訊。

方法退出實際上就是當前棧幀出棧,退出時可能執行,恢復上層方法的區域性變量表和運算元棧,把返回值壓入呼叫者運算元棧,跳轉PC計數器的值以指向方法呼叫指令後面的一天指令

方法呼叫

重點介紹方法呼叫,也就是虛擬機器多型的原理

前面已經講到,一切的方法呼叫在Class檔案裡面儲存的只是符號引用,而不是方法在實際執行時記憶體佈局中的入口程式。

所有方法呼叫中的目標方法在class檔案裡面都是一個常量池中的符號引用,在類載入的解析階段,會將其中一部分符號引用轉化成直接引用。這種解析成立的前提是:方法在程式真正執行之前就有一個可確定的版本,並且這個版本在執行期間是不可變的。即編譯時就能確定。

Java中“編譯期可知,執行期不可知”編譯期可知主要包括靜態方法,私有方法兩大類,前者直接與型別關聯;後者在外部不可訪問。

與之相對應的5條位元組碼指令
invokestatic——呼叫靜態方法
invokespecial——呼叫例項構造器< init>方法,私有方法,父類方法
invokevirtual——呼叫所有的虛方法
invokeinterface——呼叫介面方法,會在執行時確定一個實現該介面的物件
invokedynamic——先在執行時動態解析出呼叫點限定符所引用的方法

解析階段能確定的只有invokestatic和invokespecial。他們在類載入的時候就會把符號引用解析為該方法的直接引用,這種方法稱為非虛方法。
與之相反的稱為虛方法(除去final方法),final方法也是使用invokevirtual指令來執行,但是它無法被覆蓋,沒有其它版本,所以不需要進行多型選擇,也就是非虛方法。

分派

Java虛擬機器如何實現過載和重寫,如何確定正確的目標方法

  • 靜態分派
public Class StaticDispatch{
    static abstract Class Human{}
    static abstract Class Man extends Human{}
    static abstract Class Woman extends Human{}
    public void sayhello(Human guy){System.out.printlln("hello,guy!")}
    public void sayhello(Man guy){System.out.printlln("hello,man!")}
    public void sayhello(Woman guy){System.out.printlln("hello,lady!")} 
    public static void main(String[] args){
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch dispatch = new StaticDispatch();
        dispatch.sayhello(man);
        dispatch.sayhello(woman);
    }
}

執行結果是
hello,guy!
hello,guy!

我們把上面的Human稱為靜態型別,,後面的Man稱為變數的實際型別。靜態型別的變化只在使用時發生,變數本身的靜態型別不會改變,即最終的靜態型別編譯器可知;但是實際型別的變化只有執行期才能確定。

過載的時候使用哪個版本完全取決於傳入引數的數量和資料型別,編譯器通過引數的靜態型別而不是實際型別作為判斷依據,因此編譯期,編譯器就更加靜態型別決定使用哪個過載的版本。

所有依賴靜態型別來定位執行版本的分派動作稱為靜態分派,典型的場景為方法過載。

但是過載的版本並不是唯一的,而是更加合適的版本
如果過載一個引數’a’,向上轉型:
byte->short->char->int>long->float->double->裝箱類->Serializable->Object->變長引數

  • 動態分派
    典型體現為重寫
public Class StaticDispatch{
    static abstract Class Human{
        protected abstract void sayhello()
    }
    static abstract Class Man extends Human{
        protected void sayhello(){
            System.out.printlln("hello,guy!")
        }
    }
    static abstract Class Woman extends Human{
        protected void sayhello(){
            System.out.printlln("hello,lady!")
        }
    }

    public static void main(String[] args){
        Human man = new Man();
        Human woman = new Woman();
        man.sayhello();
        woman.sayhello();
        man = new Woman();
        man.sayhello();
    }
}

執行結構
hello,guy!
hello,lady!
hello,lady!

通過javap可以看到位元組碼,
Human man = new Man();
Human woman = new Woman();
對應為建立man和woman的記憶體空間,呼叫Man和Woman型別的例項構造器,將兩個例項的引用放在第1,2個區域性變量表slot中。

任何將兩個物件引用壓入棧頂,這兩個物件是將要執行的sayHello()方法的所有者,稱為接受者,然後通過invokevirtual指令呼叫sayhello方法。
invokevirtual指令的執行時解析過程分為以下步驟
1.找到運算元棧頂的第一個元素所指向的物件的實際型別,記為C
2.如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行許可權校驗,通過則返回該方法的直接引用,否則異常
3.否則,按照繼承關係從下往上搜索

由於invokevirtual指令第一步就是在執行時確定接受者的實際型別,所以兩次呼叫中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言方法重寫的本質。

單分派和多分派

方法的接受者與方法的引數統稱為方法的宗量,單分派是根據一個宗量對目標方法進行選擇的,多分派是根據多個宗量對目標方法進行選擇的。

編譯階段編譯器的選擇過程,也就是靜態分派的過程,是根據多個宗量進行選擇的,靜態多分配,動態單分配。

動態分配的實現

動態分配的方法版本需要在執行時在類的方法元資料中搜索合適的目標方法,考慮到效能,大部分實現都不會如此頻繁的搜尋。最常見的手段是為類在方法區中建立一個虛方法表(Virtual Method Table,與之對應的,在invokeinterface執行的時候也會呼叫到介面方法表,Interface Method Table)。使用虛方法表索引來代替元資料查詢以提供效能。

虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那麼子類的虛方法表裡面的地址入口和父類一致,,都指向父類的實現入口。如果子類重寫了這個方法,子類的方法表中的地址會替換為指向子類實現版本的入口地址。

為了程式實現的方便,具有相同簽名的方法,在父類和子類的虛方法表中應當具有一樣的索引序號,這樣當型別變換的時候,只需要變更查詢的方法表,就可以從不同的方法表中按索引轉換出所需的入口地址。

方法表一般在類載入的連線階段進行初始化,準備了類的變數初始值之後,虛擬機器會把類的方法表也初始化完畢。

解釋執行

虛擬機器如何呼叫方法的內容已經介紹完畢,現在探討虛擬機器如何執行方法中的位元組碼指令。當然Java虛擬機器的執行引擎包括解釋執行和編譯執行,本節主要探討虛擬機器是如何解釋執行的位元組碼的。

編譯過程
程式原始碼->詞法分析->單詞流->語法分析->抽象語法樹->指令流->直譯器->解釋執行

基於棧的指令集與基於暫存器的指令集

Java編譯器輸出的指令流,基本上是一種基於棧的指令集架構,指令流中的指令大部分都是零地址指令,依賴運算元棧進行工作。

與之相對的是基於暫存器的指令集,最典型的就是x86的二地址指令集,即傳統PC機中直接支援的指令集架構。

以1+1為例子說明:
基於棧的指令集是
iconst_1
iconst_1
iadd
istore_0
兩個iconst_1指令連續將兩個常量1壓入棧後,iadd指令把棧頂的兩個值出棧,相加,然後放回棧頂,最後istore_0把棧頂的值放到區域性變量表的第0個slot中。

基於暫存器的指令集:
mov eax, 1
add eax, 1
mov指令把eax暫存器的值設為1,然後add指令再把這個值加1,結果就放在暫存器eax中

基於棧的指令集優點是可移植,但是執行速度相對慢。暫存器由硬體直接提供,程式依賴暫存器則不可避免受到硬體的約束。

下面以Java程式碼為例子進行說明

public int calc(){
    int a = 100;
    int b = 200;
    int c = 300;
    return (a+b)*c;`這裡寫程式碼片`
}

使用javap命令可以直接看位元組碼

public int calc();
Code:
Stack=2, Locals=4 , Args_size=1
0:  bipush  100
2:  istore_1
3:  sipush  100
6:  istore_2
7:  sipush  300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
}

深度為2的運算元棧,4個slot的區域性變量表,一個this引數