1. 程式人生 > >JVM之虛擬機器位元組碼執行引擎(八)

JVM之虛擬機器位元組碼執行引擎(八)

虛擬機器的執行引擎是自己實現的,有自己的指令集和執行引擎的結構體系,能夠執行那些不被硬體直接支援的指令集格式。(物理機執行引擎是建立在處理器、硬體、指令集和作業系統層面)。
但在不同的虛擬機器實現裡,執行引擎在執行java程式碼的時候,可能會解釋執行和編譯執行,也可兩者兼備,但外觀看起來都是一致的:輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果。

執行時棧幀結構

棧幀是隨著方法呼叫建立,隨著方法結束而銷燬,它的大小:區域性變量表、最大運算元棧等都在編譯確定,因此大小隻在虛擬機器具體分配的記憶體。
      棧幀是支援虛擬機器進行方法呼叫和方法執行的資料結構,是執行時資料區虛擬機器棧的棧元素。儲存了局部變量表、運算元棧、動態連線和方法返回地址(方法正常退出pc值,但異常退出則需要異常表去確定)等資訊。


每一個方法的呼叫開始至執行完成都對應一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。
在編譯程式碼的時候,棧中需要多大的區域性變量表,多深的運算元棧都已經完全確定並在方法表的code屬性中儲存。
1》區域性變量表
是一組變數值儲存空間,用於存放方法引數和方法內區域性變數,在code中已經確定,單位為slot。虛擬機器在方法執行的時候,是使用區域性變量表完成引數值到引數列表的傳遞過程,如果是例項方法則在區域性變量表的第0位索引的slot就是指向該方法的所屬例項物件的引用(this),而類方法就不會有。且區域性變量表中的slot是可以重複使用的,當超出作用域後就可以被其他變數使用。
雖然有時候建議不是用的大物件直接賦值null來讓GC回收,但從編碼的角度來講可以用恰當的變數作用域來來控制變數的回收時間才是最優雅的方式。還有就是如果經過JIT編譯成原生代碼後,賦值null會被優化後消除,賦值null沒有意義(從這點可以看出賦值null有些時候也是可以的,畢竟不是所得程式碼都能被JIT編譯)

類變數有準備階段或初始化階段賦值操作,而區域性變量表沒有,所以必須賦值後才能用,否則編譯報錯。
2》運算元棧
運算元棧中每一個元素都可以是任意一個型別java資料型別,32位所佔容量為2,62位所佔容量為2。
對棧進行操作,呼叫其他方法是通過運算元棧來進行引數傳遞的或者呼叫其他方法返回值放在呼叫者的運算元棧裡。
概念模型兩個棧幀是相互獨立的,但大多數虛擬機器實現裡都會做出一些優化處理,令上面的部分區域性變量表與下面的棧幀的部分運算元棧重疊,這樣進行方法呼叫時就可以共用一部分資料,無需額外的引數複製。(面向運算元棧)
3》動態連線
每一個棧幀都包含一個指向執行時常量池(Class檔案中的常量池資訊在類載入後存入此)中該棧幀所屬方法的引用(區別於區域性變量表可能會有的this指向所屬例項物件
),持有這個引用是為了支援方法呼叫過程中的動態連線。位元組碼指令需要以常量池中的符號引用作為引數,這些引用在類載入階段或第一次使用時轉為直接引用,那麼屬於靜態解析,另一部分將在執行期間轉化為直接引用,稱為動態連線。(第七章類載入中:載入 驗證 準備 (沒有解析)初始化 解除安裝這五個順序固定,解析替換地址的時候可能會在執行期間 動態連線
4》方法返回地址
方法執行後兩種退出方式:
》遇到方法返回位元組碼指令,如果有返回值則把返回值放入呼叫者的運算元棧中,稱為正常完成出口;
》沒有在本方法對這個異常進行處理,則稱為異常完成出口,這種方式不會給呼叫者返回任何值;
不過無論哪種方式退出都要返回方法被呼叫的位置,程式才能繼續執行:正常退出時,棧幀中可能會儲存的pc值可以返回地址,但異常退出,就只能用異常處理器表來確定。
方法退出等效於當前棧幀出棧:恢復上層方法的區域性變量表和運算元棧,如果有返回值則把返回值壓入運算元棧中,調整pc值指向方法呼叫指令後一條指令。
5》附加資訊
虛擬機器規範允許具體虛擬機器實現增加一些規範裡沒有描述的資訊到棧幀中,比如除錯資訊,這部分資訊取決於具體虛擬機器實現。

方法呼叫

方法呼叫並不是方法執行,此階段唯一任務就是確定被呼叫方法的版本(即呼叫哪一個方法),由於java在編譯期間沒有連線,雖然使得java有更強大的動態擴充套件能力,但也使方法呼叫變得複雜。需要在類載入期間甚至執行期間才能確定目標方法的直接引用。
1》解析
由於方法呼叫的目標方法都是常量池中的符號引用,轉為直接引用:
靜態解析;類載入解析階段就轉為直接引用前提:方法在真正執行之前只有一個可以確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可變的。即編譯期就確定下來,主要是靜態方法(與型別直接關聯)和私有方法(外部不可訪問)兩大類。其實只要能被invokestatic和invokespecial指令呼叫的方法都可以在解析階段確定呼叫版本,符合這個條件的方法有靜態方法、私有方法、例項構造器、父類方法。這部分在類載入解析階段轉為直接引用。還有一種是被final修飾的方法,雖然使用invokevritual指令來呼叫,但無法被覆蓋,所以也無需多選擇。
解析呼叫是一個靜態過程,在編譯器就確定,而分派呼叫則可能是靜態也可能是動態。
2》分派
對於多型中“過載”“重寫”在java虛擬機器中是如何實現的,即java虛擬機器如何確定正確的目標方法?
靜態分派
過載:

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 sd= new StaticDispatch();
    sd.sayHello(man);
    sd.sayHello(woman);
    //sd.sayHello((Man)man);
    //sd.sayHello((Woman)woman);
}
}
執行結果:
hello,guy!
hello,guy!
//hello gentleman!
//hello ,lady!

為什麼都會執行引數為Human型別的過載那?
Human稱為變數的靜態型別(外觀型別),Man則稱為實際型別。兩者都以發生一些變化:(實際型別變化)Human man=new Man();(靜態型別變化 外觀原來是Human)sd.sayHello((Man)man),實際型別的變化只有在執行期才可確定,編譯器在編譯的時候並不知道物件的實際型別(認為是human);靜態型別的變化僅僅發生在使用時,變數本身(man還是Human型別指向卻是Man型別)靜態型別不會改變,最終的靜態型別是在編譯期可知的(變數的型別)。
在方法接受者是物件sd前提下,使用那個版本取決於傳入的引數型別和數量。雖然引數實際型別不同,但虛擬機器(的鍋)在過載時是根據引數的外觀型別來作為依據的,所以編譯器選擇那個版本並生成位元組碼(虛擬機器標準產生這種現象,過載(引數)判斷依據就是引數外觀型別重寫(呼叫物件)就是根據實際的呼叫者)。這種依賴靜態型別定位執行方法的分派動作是靜態分派,但分派也只是找最合適的 比如‘a’ 有字元char型別優先匹配,如果沒有轉為字串,再或者int型別…(char->int->long->float->double)
注意:解析與分派這兩者之間的關係並不是二選一的排它關係,他們是在不同層次上去篩選、確定目標方法的過程。比如:靜態方法在類載入期進行解析,而選擇靜態方法過載版本也是通過靜態分派完成(編譯器可知,編譯器確定版本)
動態分派它與多型的另一個重要體現重寫有著密切的關聯。

//檢視編譯class檔案發現每個子類都會生成一個Class檔案(不管靜態非靜態) 比如DynamicDispatch$Woman.class
public class DynamicDispatch {
 static abstract class Human{
     //抽象類如果是非抽象的方法則需要寫方法體
     protected abstract void sayHello();
 }
 static class Man extends Human{

    @Override
    protected void sayHello() {
        // TODO Auto-generated method stub
        System.out.println("man say hello!");

    }

 }
 static class Woman extends Human{

    @Override
    protected void sayHello() {
        // TODO Auto-generated method stub
        System.out.println("woman say hello!");
    }

 }
 public static void main(String[] args) {
    Human man=new Man();
    Human woman=new Woman();
    man.sayHello();
    woman.sayHello();
    man =new Woman();
    man.sayHello();
}
 }
 執行結果:
 man say hello!
woman say hello!
woman say hello!

這裡寫圖片描述
從位元組碼圖中可以看出man和woman對於方法呼叫都是指向同一個符號引用,但最終的執行方法卻不一樣;主要是因為invokevirtual指令的多型查詢過程說起:
1)找到運算元棧頂的第一個蒜素所指向的物件的實際型別,記作C;
2)如果在型別C中找到與常量中的描述符和簡單名稱相符的方法,則進行訪問許可權檢驗,通過則返回這個方法的直接引用,查詢結束。如果許可權效驗不通過,則返回非法訪問異常。
3)否則,按照繼承關係從下往上對C類父類進行步驟2的搜尋和驗證。
4)如果最終也沒有找到合適的方法,丟擲java.lang.AbstractMethodError異常。
這個是根據invokevirtual指令執行解析來實現的。
單分派與多分派
方法的接收者與方法的引數統稱為方法的宗量,如果確定一個方法只根據一個宗量比如引數則是單分派,否則是多分派。
這裡寫圖片描述
從上圖中可以看出在編譯器進行靜態分派的時候,根據呼叫者和引數進行分派(對比1、2、3)首先根據呼叫者的靜態型別進行選擇物件的方法,然後綜合引數做出最終選擇(看來靜態分派並不限於過載中的方法引數)。靜態分派屬於多分派。
而在執行“son.hardChoice(new QQ())”,invokevirtual指令執行時,由於編譯期已經確定方法簽名為hardChoice(QQ),所以不會考慮引數(不管你是QQ還是其子類),這個時候可以影響虛擬機器的只有方法的呼叫者是son還是father,因此動態分派屬於單分派。
java是靜態分派多分派,動態分派單分派語言。
這裡有一點需要注意與我寫的前面多型文章聯絡這樣才能理解更深:點選
比如:靜態分派的時候,ab.show(bb);選擇呼叫物件A.show(B),但A中沒有這個方法,由於B繼承自A,所以分派成A.show(A),invokevirtual執行時動態查詢根據實際型別查詢,先查B.show(A),如果有則查詢結束,否則查父類A.show(A)指令先查詢方法,並輸出“A-A”。
虛擬機器動態分配的實現
動態分派是非常頻繁的動作,而且動態分派的方法版本選擇需要執行時在類的方法元資料中搜索合適的目標方法。基於效能考慮,在虛擬機器實現的時候,在類的方法區建一個虛方法表,通過方法表的索引來提高查詢效能。
虛方法表裡面存著各個方法的實際入口地址,如果某個方法在子類中沒有重寫,則子類的虛方法表裡面地址入口與父相同方法的入口地址一樣,都指向父類的實現入口。如果子類重寫了這個方法,子類方法表的地址將會替換為指向子類實現版本的入口地址。
java 對動態語言的支援
動態語言;關鍵特徵是型別檢查在執行期間而不是編譯期間。
java.lang.invoke包
這個包的主要目的是除在之前單純依靠符號引用來確定呼叫的目標方法方式外,提供一種新的確定目標方法的機制,稱為MethodHandle。在C/C++中,可以把函式指標作為引數進行傳遞,java一般需要物件實現Comparator介面中的compare()方法作為引數,而java也可以獲得方法的MethodHandle並作為引數傳遞。

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

public class MethodHandleTest {
    static class ClassA{
        public  void println(String s){
            System.out.println(s);
        }
    }
    public static void method(String s,MethodHandle mh) throws Throwable{
        mh.invokeExact(s);
    }
    private static MethodHandle getPrintlnMH(Object receiver) throws Throwable{
        MethodType mt=MethodType.methodType(void.class, String.class);
        //這裡呼叫的一個虛方法,按照java語言的規則,方法的第一個引數是隱式的,代表該方法的接收者,也即是this指向的物件,以前是放在引數列表中進行傳遞的,而現在提供bindto()方法來完成這件事情
        return MethodHandles.lookup().findVirtual(receiver.getClass(),"println", mt).bindTo(receiver);
    }
    public static void main(String[] args) throws Throwable{
        Object obj=System.currentTimeMillis()%2==0?System.out:new ClassA();
        getPrintlnMH(obj).invokeExact("Ha hahaha");
        method("a ou",getPrintlnMH(obj));

    }
}
結果:
Ha hahaha
a ou

實際上getPrintlnMH()模擬了invokevirtual指令的執行過程(確定目標方法),只不過它的分派邏輯並非固化在class檔案上的位元組碼上,而是通過一個具體的方法實現。這個方法的返回值(MethodHandle)可以作為一個最終呼叫方法的“引用”,以此為基礎就可以寫出類似下面的函式宣告:
void sort(List list,MethodHandle compare)
可能會產生疑問,這種通過反射不就可以解決嗎?但 他們有以下區別:
》從本質上講,Reflection和MethodHandle機制都是在模擬方法的呼叫,但Reflection是模擬java程式碼層次的方法呼叫,而MethodHandle是模擬位元組碼層次的方法呼叫。
》Reflection中的java.lang.reflect.Method物件遠比MethodHandle物件所包含的資訊多;通俗點將Reflection是重量級的,MethodHandle是輕量級的。
》MethodHandle是對位元組碼的方法指令的模擬,所以理論上虛擬機器在這方面做的優化都可以支援,反射則不行。
最關鍵一點是:Reflecttion設計的目標僅僅是為了 java語言服務的,而MethodHandle設計則是服務所有java虛擬機器上的語言,當然也包括java。
invokedynamic指令
該指令面向物件並非java語言,所以僅依靠javac編譯器沒有辦法生成帶有invokedynamic指令的位元組碼,要使用java語言來演示這個指令就需要轉換來完成(java虛擬機器可以執行)。
與MethodHandle類似,為了解決原有4條invoke*指令方法分派固化在虛擬機器之中,而如何把尋找目標方法的決定權從虛擬中轉嫁到使用者程式碼中,讓使用者有更高度自由度。只不過MethodHandle是採用上層java程式碼和API來實現,動態執行指令則是用位元組碼和class中的其他屬性、常量來完成。
每一處含有invokedynamic指令的位置都稱為動態呼叫點(Dynamic Call Site),這條指令的第一個引數不再是方法的符號引用CONSTANT_Methodref_info常量,而是CONSTANT_invokeDynamic_info常量;這個常量可以獲得三項資訊:
引導方法(Bootstrap Method)、方法型別(MethodType)和名稱。引導方法是有固定的引數,並且返回值是Call Site物件,最終呼叫要執行的方法。
invokedynamic與前面四條指令最大的區別是它的分派邏輯沒有固化在虛擬機器,而是程式設計師決定的。下面通過一個例子來看:呼叫父類的父類的方法

import static java.lang.invoke.MethodHandles.lookup;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

class Test {

class GrandFather {
    void thinking() {
        System.out.println("i am grandfather");
    }
}

class Father extends GrandFather {
    void thinking() {
        System.out.println("i am father");
    }
}

class Son extends Father {

     void thinking() {
          try {
//                MethodType mtt = MethodType.methodType(void.class);
//                MethodHandle mhh = lookup().findSpecial(GrandFather.class, 
//"thinking", mtt,this.getClass());
//                mhh.invoke(this);
            MethodType mt = MethodType.methodType(void.class);
            MethodHandle mh = lookup().findVirtual(GrandFather.class,"thinking",mt).bindTo(new GrandFather());
            mh.invokeExact();

            } catch (Throwable e) {
            }
        }
    }

    public static void main(String[] args) {
        (new Test().new Son()).thinking();
    }
}
輸出結果:
i am grandfather

書中給出的;例子有誤,發現並不能得到輸出結果,有評論說是規範改了,原來可以,後來限制不能用超類方法,所以就有別的方式獲取。
基於運算元棧指令集和直譯器