[深入理解Java虛擬機器]第八章 位元組碼執行引擎-方法呼叫

Coding-lover發表於2015-10-26

方法呼叫並不等同於方法執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還不涉及方法內部的具體執行過程。在程式執行時,進行方法呼叫是最普遍、最頻繁的操作,但前面已經講過,Class檔案的編譯過程中不包含傳統編譯中的連線步驟,一 切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址(相當於之前說的直接引用)。這個特性給Java帶來了更強大的動態擴充套件能力,但也使得Java方法呼叫過程變得相對複雜起來,需要在類載入期間,甚至到執行期間才能確定目標方法的直接引用。

解析

繼續前面關於方法呼叫的話題,所有方法呼叫中的目標方法在Class檔案裡面都是一個常量池中的符號引用,在類載入的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程式真正執行之前就有一個確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。換句話說,呼叫目標在程式程式碼寫好、編譯器進行編譯時就必須確定下來。這類方法的呼叫稱為解析(Resolution)。

在Java語言中符合“編譯器可知,執行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與型別直接關聯,後者在外部不可被訪問,這兩種方法各個的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類載入階段進行解析。

與之相對應的是,在Java虛擬機器裡面提供了5條方法呼叫位元組碼指令,分別如下。

  • invokestatic :呼叫靜態方法。
  • invokespecial :呼叫例項構造器<init>方法、私有方法和父類方法。
  • invokevirtual :呼叫所有的虛方法。
  • invokeinterface :呼叫介面方法,會在執行時再確定一個實現此介面的物件。
  • invokedynamic :先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法 ,在此之前的4條呼叫指令,分派邏輯是固化在Java虛擬機器內部的,而invokedynamic指令的分派邏輯是由使用者所設定的引導方法決定的。

只要能被invokestatic和invokespecial指令呼叫的方法,都可以在解析階段中確定唯一的呼叫版本,符合這個條件的有靜態方法、私有方法、例項構造器、父類方法4類 ,它們在類載入的時候就會把符號引用解析為該方法的直接引用。這些方法可以稱為非虛方法,與之相反 ,其他方法稱為虛方法(除去final方法 ,後文會提到)。程式碼清單8-5演示了一個最常見的解析呼叫的例子,此樣例中,靜態方法sayHello() 只可能屬於型別StaticResolution , 沒有任何手段可以覆蓋或隱藏這個方法。

程式碼清單8 - 5 方法靜態解析演示

/**
 * 方法靜態解析演示
 * 
 * @author zzm
 */
public class StaticResolution {

    public static void sayHello() {
        System.out.println("hello world");
    }

    public static void main(String[] args) {
        StaticResolution.sayHello();
    }

}

使用javap命令檢視這段程式的位元組碼,會發現的確是通過invokestatic命令來呼叫sayHello()方法的。

Java中的非虛方法除了使用invokestatic、invokespecial呼叫的方法之外還有一種,就是被final修飾的方法。雖然final方法是使用invokevirtual指令來呼叫的,但是由於它無法被覆蓋, 沒有其他版本,所以也無須對方法接收者進行多型選擇,又或者說多型選擇的結果肯定是唯一的。在Java語言規範中明確說明了final方法是一種非虛方法。

解析呼叫一定是個靜態的過程,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變為可確定的直接引用,不會延遲到執行期再去完成。而分派(Dispatch)呼叫則可能是靜態的也可能是動態的,根據分派依據的宗量數可分為單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派4種分派組合情況,下面我們再看看虛擬機器中的方法分派是如何進行的。

分派

眾所周知,Java是一門物件導向的程式語言,因為Java具備物件導向的3個基本特徵:繼承、封裝和多型。本節講解的分派呼叫過程將會揭示多型性特徵的一些最基本的體現, 如“過載”和“重寫”在Java虛擬機器之中是如何實現的,這裡的實現當然不是語法上該如何寫, 我們關心的依然是虛擬機器如何確定正確的目標方法。

1.靜態分派

在開始講解靜態分派前 ,筆者準備了一段經常出現在面試題中的程式程式碼,讀者不妨先看一遍,想一下程式的輸出結果是什麼。後面我們的話題將圍繞這個類的方法來過載(Overload)程式碼,以分析虛擬機器和編譯器確定方法版本的過程。方法靜態分派如程式碼清單8-6所示。

程式碼清單8 - 6 方法靜態分派演示

package org.fenixsoft.polymorphic;

/**
 * 方法靜態分派演示
 * @author zzm
 */
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!

程式碼清單8-6中的程式碼實際上是在考驗閱讀者對過載的理解程度,相信對Java程式設計稍有經驗的程式設計師看完程式後都能得出正確的執行結果,但為什麼會選擇執行引數型別為Human的過載呢?在解決這個問題之前,我們先按如下程式碼定義兩個重要的概念。

Human man=new Man();

我們把上面程式碼中的“Human”稱為變數的靜態型別( Static Type ) , 或者叫做的外觀型別 ( Apparent Type ) , 後面的“Man”則稱為變數的實際型別( Actual Type ), 靜態型別和實際型別在程式中都可以發生一些變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的;而實際型別變化的結果在執行期才可確定,編譯器在編譯程式的時候並不知道一個物件的實際型別是什麼。例如下面的程式碼:

// 實際型別變化
Human man=new Man(); 
man=new Woman();
// 靜態型別變化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

解釋了這兩個概念,再回到程式碼清單8-6的樣例程式碼中。main()裡面的兩次sayHello() 方法呼叫,在方法接收者已經確定是物件“sr”的前提下,使用哪個過載版本,就完全取決於傳入引數的數量和資料型別。程式碼中刻意地定義了兩個靜態型別相同但實際型別不同的變數,但虛擬機器(準確地說是編譯器)在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。並且靜態型別是編譯期可知的,因此 ,在編譯階段,Javac編譯器會根據引數的靜態型別決定使用哪個過載版本,所以選擇了sayHello(Human) 作為呼叫目標, 並把這個方法的符號引用寫到main() 方法裡的兩條invokevirtual指令的中 。

所有依賴靜態型別來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法過載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機器來執行的。另外 ,編譯器雖然能確定出方法的過載版本,但在很多情況下這個過載版本並不 是“唯一的” ,往往只能確定一個“更加合適的”版本。這種模糊的結論在由0和1構成的計算機世界中算是比較“稀罕” 的事情 ,產生這種模糊結論的主要原因是字面量不需要定義,所以字面量沒有顯式的靜態型別,它的靜態型別只能通過語言上的規則去理解和推斷。程式碼清單8- 7演示了何為“更加合適的”版本。

程式碼清單8 - 7 過載方法匹配優先順序

package org.fenixsoft.polymorphic;

public class Overload {

    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }

    public static void sayHello(int arg) {
        System.out.println("hello int");
    }

    public static void sayHello(long arg) {
        System.out.println("hello long");
    }

    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }

    public static void sayHello(char arg) {
        System.out.println("hello char");
    }

    public static void sayHello(char... arg) {
        System.out.println("hello char ...");
    }

    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}

上面的程式碼執行後會輸出:

hello char

這很好理解,‘a’是一個char型別的資料,自然會尋找引數型別為char的過載方法,如果註釋掉sayHello(char arg) 方法,那輸出會變為:

hello int

這時發生了一次自動型別轉換,’a’除了可以代表一個字串,還可以代表數字97 (字元,a,的Unicode數值為十進位制數字97 ) , 因此引數型別為int的過載也是合適的。我們繼續註釋掉sayHello(int arg)方法,那輸出會變為:

hello long

這時發生了兩次自動型別轉換,’a’轉型為整數97之後 ,進一步轉型為長整數97L ,匹配了引數型別為long的過載。筆者在程式碼中沒有寫其他的型別如float、double等的過載,不過實際上自動轉型還能繼續發生多次,按照char->int-> long-> float-> double的順序轉型進行匹配。但不會匹配到byte和short型別的過載,因為char到byte或short的轉型是不安全的。我們繼續註釋掉sayHello(long arg)方法,那輸會變為:

hello Character

這時發生了一次自動裝箱,’a’被包裝為它的封裝型別java.lang.Character ,所以匹配到了引數型別為Character的過載,繼續註釋掉sayHello(Character arg) 方法,那輸出會變為:

hello Serializable

這個輸出可能會讓人感覺摸不著頭腦,一個字元或數字與序列化有什麼關係?出現hello Serializable,是因為java.lang.Serializable是java.lang.Character類實現的一個介面,當自動裝箱之後發現還是找不到裝箱類,但是找到了裝箱類實現了的介面型別,所以緊接著又發生一次自動轉型。char可以轉型成int,但是Character是絕對不會轉型為Integer的 ,它只能安全地轉型為它實現的介面或父類。Character還實現了另外一個介面java.lang.Comparable<Character> , 如果同時出現兩個引數分別為Serializable和Comparable<Character>的過載方法,那它們在此時的優先順序是一樣的。編譯器無法確定要自動轉型為哪種型別,會提示型別模糊,拒絕編譯。程式必須在呼叫時顯式地指定字面量的靜態型別,如 : sayHello((Comparable<Character>)’a’) , 才能編譯通過。下面繼續註釋掉sayHello(Serializable arg)方法 ,輸出會變為:

hello Object

這時是char裝箱後轉型為父類了,如果有多個父類,那將在繼承關係中從下往上開始搜尋 ,越接近上層的優先順序越低。即使方法呼叫傳入的引數值為null時 ,這個規則仍然適用。 我們把sayHello(Object arg) 也註釋掉,輸出將會變為:

hello char ...

7個過載方法已經被註釋得只剩一個了,可見變長引數的過載優先順序是最低的,這時候字元’a’被當做了一個陣列元素。筆者使用的是char型別的變長引數,讀者在驗證時還可以選擇int型別、Character型別、Object型別等的變長引數過載來把上面的過程重新演示一遍。但要注意的是,有一些在單個引數中能成立的自動轉型,如char轉型為int ,在變長引數中是不成立的。

程式碼清單8-7演示了編譯期間選擇靜態分派目標的過程,這個過程也是Java語言實現方法過載的本質。演示所用的這段程式屬於很極端的例子,除了用做面試題為難求職者以外,在 實際工作中幾乎不可能有實際用途。筆者拿來做演示僅僅是用於講解過載時目標方法選擇的過程 ,大部分情況下進行這樣極端的過載都可算是真正的“關於茴香豆的茴有幾種寫法的研究”。無論對過載的認識有多麼深刻,一個合格的程式設計師都不應該在實際應用中寫出如此極端的過載程式碼。

另外還有一點讀者可能比較容易混淆:筆者講述的解析與分派這兩者之間的關係並不是二選一的排他關係,它們是在不同層次上去篩選、確定目標方法的過程。例如,前面說過, 靜態方法會在類載入期就進行解析,而靜態方法顯然也是可以擁有過載版本的,選擇過載版本的過程也是通過靜態分派完成的。

2.動態分派

瞭解了靜態分派,我們接下來看一下動態分派的過程,它和多型性的另外一個重要體現——-重寫(Override)有著很密切的關聯。我們還是用前面的Man和Woman一起sayHello的例子來講解動態分派,請看程式碼清單8-8中所示的程式碼。

程式碼清單8 - 8 方法動態分派演示

package org.fenixsoft.polymorphic;

/**
 * 方法動態分派演示
 * @author zzm
 */
public class DynamicDispatch {

    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            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

這個執行結果相信不會出乎任何人的意料,對於習慣了物件導向思維的Java程式設計師會覺得這是完全理所當然的。現在的問題還是和前面的一樣,虛擬機器是如何知道要呼叫哪個方法的?

顯然這裡不可能再根據靜態型別來決定,因為靜態型別同樣都是Human的兩個變數man和woman在呼叫sayHello()方法時執行了不同的行為,並且變數man在兩次呼叫中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變數的實際型別不同,Java虛擬機器是如何根據實際型別來分派方法執行版本的呢?我們使用javap命令輸出這段程式碼的位元組碼,嘗試從中尋找答案,輸出結果如程式碼清單8-9所示。

程式碼清單8-9 main() 方法的位元組碼


0 〜15行的位元組碼是準備動作,作用是建立man和woman的記憶體空間、呼叫Man和Woman 型別的例項構造器,將這兩個例項的引用存放在第1、2個區域性變數表Slot之中 ,這個動作也就對應了程式碼中的這兩句:

Human man=new Man(); 
Human woman=new Woman();

接下來的16〜21句是關鍵部分,16、20兩句分別把剛剛建立的兩個物件的引用壓到棧頂 ,這兩個物件是將要執行的sayHello()方法的所有者,稱為接收者( Receiver ) ; 17和21句是方法呼叫指令,這兩條呼叫指令單從位元組碼角度來看,無論是指令(都是invokevirtual) 還是引數(都是常量池中第22項的常量,註釋顯示了這個常量是Human.sayHello()的符號引用)完全一樣的,但是這兩句指令最終執行的目標方法並不相同。原因就需要從invokevirtual指令的多型查詢過程開始說起,invokevirtual指令的執行時解析過程大致分為以下幾個步驟:

  • 1 ) 找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
  • 2 ) 如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗 ,如果通過則返回這個方法的直接引用,查詢過程結束;如果不通過,則返回 java.lang.IllegalAccessError異常。
  • 3 ) 否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜尋和驗證過程。
  • 4 ) 如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。

由於invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。我們把這種在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。

3.單分派與多分派

方法的接收者與方法的引數統稱為方法的宗量,這個定義最早應該來源於《.丨ava與模 式》一書。根據分派基於多少種宗量,可以將分派劃分為單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。

單分派和多分派的定義讀起來拗口,從字面上看也比較抽象,不過對照著例項看就不難理解了。程式碼清單8-10中列舉了一個Father和Son—起來做出“一個艱難的決定”的例子。

程式碼清單8 - 10 單分派和多分派

/**
 * 單分派、多分派演示
* @author zzm
 */
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());
    }
}

執行結果:

father choose 360 
son choose qq

在main函式中呼叫了兩次hardChoice() 方法 ,這兩次hardChoice() 方法的選擇結果在程式輸出中已經顯示得很清楚了。

我們來看看編譯階段編譯器的選擇過程,也就是靜態分派的過程。這時選擇目標方法的依據有兩點: 一是靜態型別是Father還是Son,二是方法引數是QQ還是360。這次選擇結果的最終產物是產生了兩條invokevirtual指令 ,兩條指令的引數分別為常量池中指向 Father.hardChoice ( 360 ) 及Father.hardChoice ( QQ ) 方法的符號引用。因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派型別。

再看看執行階段虛擬機器的選擇,也就是動態分派的過程。在執行“son.hardChoice ( new QQ ( ) ) ”這句程式碼時,更準確地說,是在執行這句程式碼所對應的invokevirtual指令時,由於編譯期已經決定目標方法的簽名必須為hardChoice ( QQ ) , 虛擬機器此時不會關心傳遞過來的引數“QQ”到底是“騰訊QQ”還是“奇瑞QQ” ,因為這時引數的靜態型別、實際型別都對方法的選擇不會構成任何影響,唯一可以影響虛擬機器選擇的因素只有此方法的接受者的實際型別是Father還是Son。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派型別。

根據上述論證的結果,我們可以總結一句:今天(直至還未釋出的Java1.8 )的Java語言是一門靜態多分派、動態單分派的語言。強調“今天的Java語言”是因為這個結論未必會恆久不變 ,C#在3.0及之前的版本與Java—樣是動態單分派語言,但在C#4.0中引入了dynamic型別後 ,就可以很方便地實現動態多分派。

按照目前Java語言的發展趨勢,它並沒有直接變為動態語言的跡象,而是通過內建動態語言(如JavaScript)執行引擎的方式來滿足動態性的需求。但是Java虛擬機器層面上則不是如此 ,在JDK 1.7中實現的JSR-292裡面就已經開始提供對動態語言的支援了, JDK 1.7中新增的invokedymmic指令也成為了最複雜的一條方法呼叫的位元組碼指令,稍後筆者將專門講解這個JDK 1.7的新特性。

4.虛擬機器動態分派的實現

前面介紹的分派過程,作為對虛擬機器概念模型的解析基本上已經足夠了,它已經解決了虛擬機器在分派中“會做什麼”這個問題。但是虛擬機器“具體是如何做到的”,可能各種虛擬機器的實現都會有些差別。

由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在類的方法後設資料中搜尋合適的目標方法,因此在虛擬機器的實際實現中基於效能的考慮,大部分實現都不會真正地進行如此頻繁的搜尋。面對這種情況,最常用的“穩定優化”手段就是為類在方法區中建立一個虛方法表(Vritual Method Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到介面方法表——Inteface Method Table,簡稱itable ) ,被用虛方法表索引來代替後設資料查詢以提高效能。我們先看看程式碼清單8-10所對應的虛方法表結構示例 ,如圖8-3所示。

虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。圖8-3中 ,Son重寫了來自Father的全部方法,因此Son的方法表沒有指向Father型別資料的箭頭。但是Son和Father都沒有重寫來自Object的方法 ,所以它們的方法表中所有從Object繼承來的方法都指向了 Object的資料型別。

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

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

上文中筆者說方法表是分派呼叫的“穩定優化”手段 ,虛擬機器除了使用方法表之外,在條件允許的情況下,還會使用內聯快取( Inline Cache )和基於“型別繼承關係分析” ( Class Hierarchy Analysis,CHA ) 技術的守護內聯( Guarded Mining ) 兩種非穩定的“激進優化”手段來獲得更高的效能,關於這兩種優化技術的原理和運作過程,讀者可以參考本書第11章中的相關內容。

動態型別語言支援

Java虛擬機器的位元組碼指令集的數量從Sun公司的第一款Java虛擬機器問世至JDK 7來臨之前的十餘年時間裡,一直沒有發生任何變化。隨著JDK 7的釋出,位元組碼指令集終於迎來了第一位新成員—— invokedynamic指令。這條新增加的指令是JDK 7實現“動態型別語言” (Dynamically Typed Language ) 支援而進行的改進之一,也是為JDK 8可以順利實現Lambda表示式做技術準備。在本節中,我們將詳細講解JDK 7這項新特性出現的前因後果和它的深遠意義。

1.動態型別語言

在介紹Java虛擬機器的動態型別語言支援之前,我們要先弄明白動態型別語言是什麼?它與Java語言、Java虛擬機器有什麼關係?瞭解JDK 1.7提供動態型別語言支援的技術背景,對理解這個語言特性是很有必要的。

什麼是動態型別語言? 動態型別語言的關鍵特徵是它的型別檢查的主體過程是在執行期而不是編譯期,滿足這個特徵的語言有很多,常用的包括:APL、Clojure、Erlang、 Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。相對的 ,在編譯期就進行型別檢查過程的語言(如C++和Java等 )就是最常用的靜態型別語言。

覺得上面定義過於概念化?那我們不妨通過兩個例子以最淺顯的方式來說明什麼是“在編譯期/執行期進行”和什麼是“型別檢查”。首先看下面這段簡單的Java程式碼 ,它是否能正常編譯和執行?

public static void main (String[]args ){ 
    int[][][] array=new int[1][0][-1];
}

這段程式碼能夠正常編譯,但執行的時候會報NegativeArraySizeException異常。在Java虛擬機器規範中明確規定了NegativeArraySizeException是執行時異常 ,通俗一點來說,執行時異常就是隻要程式碼不執行到這一行就不會有問題。與執行時異常相對應的是連線時異常, 例如很常見的NoClassDefFoundError便屬於連線時異常,即使會導致連線時異常的程式碼放在一條無法執行到的分支路徑上,類載入時(Java的連線過程不在編譯階段,而在類載入階段)也照樣會丟擲異常。

不過,在C語言中,含義相同的程式碼會在編譯期報錯:

int main (void ) {
    int i[1][0][-1] ;//GCC拒絕編譯,報'size of array is negative'
    return 0;
}

由此看來,一門語言的哪一種檢查行為要在執行期進行,哪一種檢查要在編譯期進行並沒有必然的因果邏輯關係,關鍵是語言規範中人為規定的。再舉一個例子來解釋“型別檢查” ,例如下面這一句非常簡單的程式碼:

obj.println("hello world");

雖然每個人都能看懂這行程式碼要做什麼,但對於計算機來說,這一行程式碼“沒頭沒尾”是無法執行的,它需要一個具體的上下文才有討論的意義。

現在假設這行程式碼是在Java語言中,並且變數obj的靜態型別為java.io.PrintStream ,那變數obj的實際型別就必須是PrintStream的子類(實現了PrintStream介面的類)才是合法的。否則 ,哪怕obj屬於一個確實有用println(String)方法,但與PrintStream介面沒有繼承關係,程式碼依然不可能執行— 因為型別檢查不合法。

但是相同的程式碼在ECMAScript (JavaScript)中情況則不一樣,無論obj具體是何種型別 ,只要這種型別的定義中確實包含有println ( String ) 方 法 ,那方法呼叫使可成功。

這種差別產生的原因是Java語言在編譯期間已將pnntln( String )方法完整的符號引用(本例中為一個CONSTANT_InterfaceMethodref_info常量)生成出來,作為方法呼叫指令的引數儲存到Class檔案中,例如下面這段程式碼:

invokevirtual#4 ;//Method java/io/PrintStream.println:(Ljava/lang/String ;)V

這個符號引用包含了此方法定義在哪個具體型別之中、方法的名字以及引數順序、引數型別和方法返回值等資訊,通過這個符號引用,虛擬機器可以翻譯出這個方法的直接引用。而在ECMAScript等動態型別語言中,變數obj本身是沒有型別的,變數obj的值才具有型別,編譯時最多隻能確定方法名稱、引數、返回植這些資訊,而不會去確定古法所在的具體型別(即方法接收者不固定)。“變數無型別而變數值才有型別”這個特點也是動態型別語言的一個重要特徵。

瞭解了動態和靜態型別語言的區別後,也許讀者的下一個問題就是動態、靜態型別語言兩者誰更好,或者誰更加先進?這種比較不會有確切答案,因為它們都有自己的優點,選擇哪種語言是需要經過權衡的。靜態型別語言在編譯期確定型別,最顯著的好處是編譯器可以提供嚴謹的型別檢查,這樣與型別相關的問題能在編碼的時候就及時發現,利於穩定性及程式碼達到更大規模。而動態型別語言在執行期確定型別,這可以為開發人員提供更大的靈活性 ,某些在靜態型別語言中需用大量“臃腫”程式碼來實現的功能,由動態型別語言來實現可能 會更加清晰和簡潔,清晰和簡潔通常也就意味著開發效率的提升。

2.JDK1.7與動態型別

回到本節的主題,來看看Java語言、虛擬機器與動態型別語言之間有什麼關係。Java虛擬機器毫無疑問是Java語言的執行平臺,但它的使命並不僅限於此,早在1997年出版的《Java虛擬機器規範》中就規劃了這樣一個願景:“在未來,我們會對Java虛擬機器進行適當的擴充套件,以便更好地支援其他語言執行於Java虛擬機器之上”。而目前確實已經有許多動態型別語言執行於Java虛擬機器之上了,如Clojure、Groovy、Jython和JRuby等 ,能夠在同一個虛擬機器上可以達到靜態型別語言的嚴謹性與動態型別語言的靈活性,這是一件很美妙的事情。

但遺憾的是,Java虛擬機器層面對動態型別語言的支援一直都有所欠缺,主要表現在方法呼叫方面:JDK 1.7以前的位元組碼指令集中,4條方法呼叫指令(invokevirtual、 invokespecial、invokestatic、 invokeinterface ) 的第一個引數都是被呼叫的方法的符號引用( CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已經提到過 ,方法的符號引用在編譯時產生,而動態型別語言只有在執行時才能確定接收者型別。這樣,在Java虛擬機器上實現的動態型別語言就不得不使用其他方式(如編譯時留個佔位符型別 ,執行時動態生成位元組碼實現具體型別到佔位符型別的適配)來實現 ,這樣勢必讓動態型別語言實現的複雜度增加,也可能帶來額外的效能或者記憶體開銷。儘管可以利用一些辦法(如 Call Site Caching )讓這些開銷儘量變小,但這種底層問題終歸是應當在虛擬機器層次上去解決才最合適,因此在Java虛擬機器層面上提供動態型別的直接支援就成為了Java平臺的發展趨勢之一 ,這就是JDK 1.7 ( JSR-292 ) 中invokedynamic指令以及java.lang.invoke包出現的技術背景。

3 .java.lang.invoke包

JDK1 .7實現了JSR-292,新加入的java.lang.invoke包就是JSR-292的一個重要組成部分 , 這個包的主要目的是在之前單純依靠符號引用來確定呼叫的目標方法這種方式以外,提供一種新的動態確定目標方法的機制,稱為MethodHandle。這種表達方式也許不太好懂?那不妨把MethodHandle與C/C++中的Function Pointer,或者C#裡面的Delegate類比一下。舉個例子, 如果我們要實現一個帶謂詞的排序函式,在C/C++中常用的做法是把謂詞定義為函式,用函式指標把謂詞傳遞到排序方法,如下 :

void sort(int list[],const int size,int(*compare)(int,int))

但Java語言做不到這一點,即沒有辦法單獨地把一個函式作為引數進行傳遞。普遍的做法是設計一個帶有compare()方法的Comparator介面 ,以實現了這個介面的物件作為引數, 例如Collections.sort() 就是這樣定義的:

void sort (List list,Comparator c)

不過,在擁有Method Handle之後,Java語言也可以擁有類似於函式指標或者委託的方法別名的工具了。程式碼清單8-11演示了MethodHandle的基本用途,無論obj是何種型別(臨時定義的ClassA抑或是實現PrintStream介面的實現類System.out) ,都可以正確地呼叫到println()方法。

程式碼清單8-11 MethodHandle演示

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

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

/**
 * JSR 292 MethodHandle基礎用法演示
 * @author zzm
 */
public class MethodHandleTest {

    static class ClassA {
        public void println(String s) {
            System.out.println(s);
        }
    }

    public static void main(String[] args) throws Throwable {
        Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
        // 無論obj最終是哪個實現類,下面這句都能正確呼叫到println方法。
        getPrintlnMH(obj).invokeExact("icyfenix");
    }

    private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
        // MethodType:代表“方法型別”,包含了方法的返回值(methodType()的第一個引數)和具體引數(methodType()第二個及以後的引數)。
        MethodType mt = MethodType.methodType(void.class, String.class);
        // lookup()方法來自於MethodHandles.lookup,這句的作用是在指定類中查詢符合給定的方法名稱、方法型別,並且符合呼叫許可權的方法控制程式碼。
        // 因為這裡呼叫的是一個虛方法,按照Java語言的規則,方法第一個引數是隱式的,代表該方法的接收者,也即是this指向的物件,這個引數以前是放在引數列表中進行傳遞,現在提供了bindTo()方法來完成這件事情。
        return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
    }
}

實際上,方法getPrintlnMH()中模擬了invokevirtual指令的執行過程,只不過它的分派邏輯並非固化在Class檔案的位元組碼上,而是通過一個具體方法來實現。而這個方法本身的返回值(MethodHandle物件),可以視為對最終呼叫方法的一個“引用”。以此為基礎,有了MethodHandle就可以寫出類似於下面這樣的函式宣告:

void sort (List list,MethodHandle compare)

從上面的例子可以看出,使用MethodHandle並沒有什麼困難,不過看完它的用法之後, 讀者大概就會產生疑問,相同的事情,用反射不是早就可以實現了嗎?

確實 ,僅站在Java語言的角度來看,MethodHandle的使用方法和效果與Reflection有眾多相似之處,不過,它們還是有以下這些區別:

從本質上講,Reflection和MethodHandle機制都是在模擬方法呼叫,但Reflection是在模擬Java程式碼層次的方法呼叫,而MethodHandle是在模擬位元組碼層次的方法呼叫。在 MethodHandles.lookup中的3個方法——findStatic ( ) 、 fmdVirtual ( ) 、 fmdSpecial ( ) 正是為了對應於invokestatic、 invokevirtual 、invokeinterface和invokespecial這幾條位元組碼指令的執行許可權校驗行為,而這些底層細節在使用Reflection API時是不需要關心的。

Reflection中的java.lang.reflect.Method物件遠比MethodHandle機制中的 java.lang.invoke.MethodHandle物件所包含的資訊多。前者是方法在Java一端的全面映像,包含了方法的簽名、描述符以及方法屬性表中各種屬性的Java端表示方式,還包含執行許可權等的執行期資訊。而後者僅僅包含與執行該方法相關的資訊。用通俗的話來講,Reflection是重量級 ,而MethodHandle是輕量級。

由於MethodHandle是對位元組碼的方法指令呼叫的模擬,所以理論上虛擬機器在這方面做的各種優化(如方法內聯),在MethodHandle上也應當可以採用類似思路去支援(但目前實現還不完善)。而通過反射去呼叫方法則不行。

MethodHandle與Reflection除了上面列舉的區別外,最關鍵的一點還在於去掉前面討論施加的前提“僅站在Java語言的角度來看” : Reflection API的設計目標是隻為Java語言服務的, 而MethodHandle則設計成可服務於所有Java虛擬機器之上的語言,其中也包括Java語言。

4.invokedynamic指令

本節一開始就提到了JDK 1.7為了更好地支援動態型別語言,引入了第5條方法呼叫的位元組碼指令invokedynamic,之後一直沒有再提到它,甚至把程式碼清單8-11中使用MethodHandle的示例程式碼反編譯後也不會看見invokedynamic的身影,它的應用之處在哪裡呢?

在某種程度上,invokedynamic指令與MethodHandle機制的作用是一樣的,都是為了解決原有4條“invoke*”指令方法分派規則固化在虛擬機器之中的問題,把如何查詢目標方法的決定權從虛擬機器轉嫁到具體使用者程式碼之中,讓使用者(包含其他語言的設計者)有更高的自由度。 而且 ,它們兩者的思路也是可類比的,可以把它們想象成為了達成同一個目的,一個採用上層Java程式碼和API來實現,另一個用位元組碼和Class中其他屬性、常量來完成。因此,如果理解了前面的MethodHandle例 子 ,那麼理解invokedynamic指令也並不困難。

每一處含有invokedynamic指令的位置都稱做“動態呼叫點” ( Dynamic Call Site ) , 這條指令的第一個引數不再是代表方法符號引用的CONSTANT_Methodref_info常量 ,而變為JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,從這個新常量中可以得到3項資訊:引導
方法(Bootstrap Method,此方法存放在新增的BootstrapMethods屬性中)、方法型別 ( MethodType ) 和名稱。引導方法是有固定的引數,並且返回A是java.langinvoke.CallSite物件 ,這個代表真正要執行的目標方法呼叫。根據CONSTANT_InvokeDynamic_info常量中提供的資訊,虛擬機器可以找到並且執行引導方法,從而獲得一個CallSite物件,最終呼叫要執行的目標方法。我們還是舉一個實際的例子來解釋這個過程,如程式碼清單8-12所示。

程式碼清單8-12 invokedynamic指令演示

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

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

public class InvokeDynamicTest {

    public static void main(String[] args) throws Throwable {
        INDY_BootstrapMethod().invokeExact("icyfenix");
    }

    public static void testMethod(String s) {
        System.out.println("hello String:" + s);
    }

    public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
        return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
    }

    private static MethodType MT_BootstrapMethod() {
        return MethodType
                .fromMethodDescriptorString(
                        "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",
                        null);
    }

    private static MethodHandle MH_BootstrapMethod() throws Throwable {
        return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
    }

    private static MethodHandle INDY_BootstrapMethod() throws Throwable {
        CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(lookup(), "testMethod",
                MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
        return cs.dynamicInvoker();
    }
}

這段程式碼與前面MethodHandleTest的作用基本上是一樣的,雖然筆者沒有加以註釋,但是閱讀起來應當不困難。本書前面提到過,由於invokedynamic指令所面向的使用者並非Java語言 ,而是其他Java虛擬機器之上的動態語言,因此僅依靠Java語言的編譯器Javac沒有辦法生成帶有invokedynamic指令的位元組碼(曾經有一個java.dyn.InvokeDymmic的語法糖可以實現, 但後來被取消了),所以要使用Java語言來演示invokedynamic指令只能用一些變通的辦法。John Rose (Da Vinci Machine Project的Leader)編寫了一個把程式的位元組碼轉換為使用 invokedynamic的簡單工具INDY來完成這件事情,我們要使用這個工具來產生最終要的位元組碼 ,因此這個示例程式碼中的方法名稱不能隨意改動,更不能把幾個方法合併到一起寫,因為它們是要被INDY工具讀取的。

把上面程式碼編譯、再使用INDY轉換後重新生成的位元組碼如程式碼清單8-13所示 (結果使用javap輸出 ,因版面原因,精簡了許多無關的內容)。


從main()方法的位元組碼可見,原本的方法呼叫指令已經替換為invokedynamic,它的引數為第123項常量(第二個值為0的引數在HotSpot中用不到,與invokeinterface指令那個值為0的引數一樣都是佔位的)。

2 :invokedynamic#123 ,0//InvokeDynamic#0 :testMethod :(Ljava/lang/String ; )V

從常量池中可見,第123項常量顯示“#123=InvokeDynamic#0 : #121”說明它是一項 CONSTANT_InvokeDynamic_info型別常量,常量值中前面的“#0”代表引導方法取BootstrapMethods屬性表的第0項 (javap沒備列出屬性表的具體內容,不過示例中僅有一個引導方法,即BootstrapMethod() ) , 而後面的“#121”代表引用第121項型別為 CONSTANT_NameAndType_info的常量,從這個常量中可以獲取方法名稱和描述符,即後面輸出的“testMethod : ( Ljava/lang/String ; ) V’。

再看一下BootstrapMethod() ,這個方法Java原始碼中沒有,是INDY產生的,但是它的位元組碼很容易讀懂,所奏邏輯就是呼叫MethodHandles $Lookup的findStatic ( )方 法 ,產生testMethod ( ) 方法的MethodHandle,然後用它建立一個ConstantCallSite物件。最後,這個物件返回給invokedynamic指令實現對testMethod ( ) 方法的呼叫,invokedynamic指令的呼叫過程到此就宣告完成了。

5.掌控方法分派規則

invokedynamic指令與前面4條“invoke*”指令的最大差別就是它的分派邏輯不是由虛擬機器決定的 ,而是由程式設計師決定。在介紹Java虛擬機器動態語言支援的最後一個小結中,筆者通過一個簡單例子(如程式碼清單8-14所 示 ),幫助讀者理解程式設計師在可以掌控方法分派規則之後 ,能做什麼以前無法做到的事情。

程式碼清單8 - 14 方法呼叫問題

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() {
       // 請讀者在這裡填入適當的程式碼(不能修改其他地方的程式碼)
       // 實現呼叫祖父類的thinking()方法,列印"i am grandfather"
   }
}

在Java程式中 ,可以通過“super”關鍵字很方便地呼叫到父類中的方法,但如果要訪問祖類的方法呢?讀者在閱讀本書下面提供的解決方案之前,不妨自己思考一下,在JDK 1.7之 前有沒有辦法解決這個問題。

在JDK 1.7之前,使用純粹的Java語言很難處理這個問題(直接生成位元組碼就很簡單,如使用ASM等位元組碼工具),原因是在Son類的thinking() 方法中無法獲取一個實際型別是GrandFather的物件引用,而invokevirtual指令的分派邏輯就是按照方法接收者的實際型別進行分配,這個邏輯是固化在虛擬機器中的,程式設計師無法改變。在JDK 1.7中,可以使用程式碼清單8- 15中的程式來解決這個問題。

程式碼清單8-15 使用MethodHandle來解決相關問題

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 mt = MethodType.methodType(void.class);
                MethodHandle mh = lookup().findSpecial(GrandFather.class, 
"thinking", mt, getClass());
                mh.invoke(this);
            } catch (Throwable e) {
            }
        }
    }

    public static void main(String[] args) {
        (new Test().new Son()).thinking();
    }
}

執行結果:

 i'm father

相關文章