Java虛擬機的字節碼指令集的數量從Sun公司的第一款Java虛擬機問世至JDK 7來臨之前的十余年時間里,一直沒有發生任何變化。隨着JDK 7的發布,字節碼指令集終於迎來了第一位新成員——invokedynamic指令。這條新增加的指令是JDK 7實現“動態類型語言”(Dynamically Typed Language)支持而進行的改進之一,也是為JDK 8可以順利實現Lambda表達式做技術准備。
動態類型語言
動態類型語言是什么?它與Java語言、Java虛擬機有什么關系?
什么是動態類型語言?注意,動態類型語言與動態語言、弱類型語言並不是一個概念,需要區別對待。
動態類型語言的關鍵特征是它的類型檢查的主體過程是在運行期而不是編譯期,滿足這個特征的語言有很多,常用的包括: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的連接過程不在編譯階段,而在類加載階段)也照樣會拋出異常。
例如下面這一句非常簡單的代碼:----類型檢查
obj.println("hello world");
雖然每個人都能看懂這行代碼要做什么,但對於計算機來說,這一行代碼“沒頭沒尾”是無法執行的,它需要一個具體的上下文才有討論的意義。
現在假設這行代碼是在Java語言中,並且變量obj的靜態類型為java.io.PrintStream,那變量obj的實際類型就必須是PrintStream的子類(實現了PrintStream接口的類)才是合法的。否則,哪怕obj屬於一個確實有用println(String)方法,但與PrintStream接口沒有繼承關系,代碼依然不可能運行——因為類型檢查不合法。
但是相同的代碼在ECMAScript(JavaScript)中情況則不一樣,無論obj具體是何種類型,只要這種類型的定義中確實包含有println(String)方法,那方法調用便可成功。
這種差別產生的原因是Java語言在編譯期間已將println(String)方法完整的符號引用(本例中為一個CONSTANT_InterfaceMethodref_info常量)生成出來,作為方法調用指令的參數存儲到Class文件中,例如下面這段代碼:
invokevirtual#4;//Method java/io/PrintStream.println:(Ljava/lang/String;)V
這個符號引用包含了此方法定義在哪個具體類型之中、方法的名字以及參數順序、參數類型和方法返回值等信息,通過這個符號引用,虛擬機可以翻譯出這個方法的直接引用。而在ECMAScript等動態類型語言中,變量obj本身是沒有類型的,變量obj的值才具有類型,編譯時最多只能確定方法名稱、參數、返回值這些信息,而不會去確定方法所在的具體類型(即方法接收者不固定)。“變量無類型而變量值才有類型”這個特點也是動態類型語言的一個重要特征。
JDK 1.7與動態類型
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包出現的技術背景。
java.lang.invoke包
JDK 1.7實現了JSR-292,新加入的java.lang.invoke包。這個包的主要目的是在之前單純依靠符號引用來確定調用的目標方法這種方式以外,提供一種新的動態確定目標方法的機制,稱為MethodHandle。
MethodHandle的基本用途,無論obj是何種類型(臨時定義的ClassA抑或是實現PrintStream接口的實現類System.out),都可以正確地調用到println()方法。
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; 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對象),可以視為對最終調用方法的一個“引用”。
相同的事情,用反射不是早就可以實現了嗎?
僅站在Java語言的角度來看,MethodHandle的使用方法和效果與Reflection有眾多相似之處,不過,它們還是有以下這些區別:
- 從本質上講,Reflection和MethodHandle機制都是在模擬方法調用,但Reflection是在模擬Java代碼層次的方法調用,而MethodHandle是在模擬字節碼層次的方法調用。在MethodHandles.lookup中的3個方法——findStatic()、findVirtual()、findSpecial()正是為了對應於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語言。
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)和名稱。引導方法是有固定的參數,並且返回值是java.lang.invoke.CallSite對象,這個代表真正要執行的目標方法調用。根據CONSTANT_InvokeDynamic_info常量中提供的信息,虛擬機可以找到並且執行引導方法,從而獲得一個CallSite對象,最終調用要執行的目標方法。
舉一個實際的例子來解釋這個過程,如下所示。
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(); } }
看一下BootstrapMethod(),所有邏輯就是調用MethodHandles$Lookup的findStatic()方法,產生testMethod()方法的MethodHandle,然后用它創建一個ConstantCallSite對象。最后,這個對象返回給invokedynamic指令實現對testMethod()方法的調用,invokedynamic指令的調用過程到此就宣告完成了。
掌控方法分派規則
invokedynamic指令與前面4條“invoke*”指令的最大差別就是它的分派邏輯不是由虛擬機決定的,而是由程序員決定。
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之前,使用純粹的Java語言很難處理這個問題(直接生成字節碼就很簡單,如使用ASM等字節碼工具),原因是在Son類的thinking()方法中無法獲取一個實際類型是GrandFather的對象引用,而invokevirtual指令的分派邏輯就是按照方法接收者的實際類型進行分派,這個邏輯是固化在虛擬機中的,程序員無法改變。在JDK 1.7中,可以使用MethodHandle來解決相關問題。
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; public 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().findVirtual(GrandFather.class,"thinking",mt).bindTo(new Test().new GrandFather()); mh.invokeExact(); }catch(Throwable e){ } } } public static void main(String[] args){ (new Test().new Son()).thinking(); } }