虛擬機字節(jié)碼執(zhí)行引擎
所謂的「虛擬機字節(jié)碼執(zhí)行引擎」其實就是 JVM 根據 Class 文件中給出的字節(jié)碼指令,基于棧解釋器的一種執(zhí)行機制。通俗點來說,也就是 JVM 解析字節(jié)碼指令,輸出運行結果的一個過程。接下來我們詳細看看這部分內容。
方法調用的本質
在描述「字節(jié)碼執(zhí)行引擎」之前,我們先從匯編層面看看基于棧幀的方法調用是怎樣的。(以 IA32 型 CPU 指令集為例)
IA32 的程序中使用棧幀數據結構來支持過程調用(Java 語言中稱作方法),每個過程對應一個棧幀,過程的調用對應與棧幀的入棧和出棧。某個時刻,只有位于棧頂的棧幀可用,它代表了某個方法正在執(zhí)行中的各種狀態(tài)。最頂端的棧幀用兩個指針界定,棧指針,幀指針。他們對應于棧中的地址分別存儲在寄存器 %ebp 和 %esp 中。棧中的大致結構如下:
棧指針始終指向棧頂元素,控制著棧中元素的出入棧,幀指針指向的是當前棧幀的底部,注意是當前棧幀,不是整個棧的底部。
下面我們看看一段 C 代碼:
- #include<stdio.h>
- void sayHello(int age)
- {
- int x = 32;
- int y = 2323;
- age = x + y;
- }
- void main()
- {
- int age = 22;
- sayHello(age);
- }
很簡單的一段代碼,我們匯編生成相應的匯編代碼,省略了部分鏈接代碼,留下的是核心的部分:
- 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 個字節(jié)(棧是倒著生長的),也就是為當前棧幀分配了 20 個字節(jié)大小。接著,movl 將值 20 寫入地址 -4(%ebp),這個地址其實就是相對寄存器 %ebp 幀指針位置之上的四個字節(jié)處。假如 %ebp 的值為:0x14,那么 20 就被存儲到地址 0x10 的棧地址中。
接著一條 movl 指令將參數 age 的值取出來存入寄存器 %eax 中。
這時就到了核心的 call 方法了,計算機中有程序計數器(PC)來指向下一條指令的位置,而常常我們的程序會調用到其他方法里,那么調用結束后又該如何恢復調用前的狀態(tài)并繼續(xù)執(zhí)行呢?
這里的解決辦法是,call 指令的***步就是將返回地址壓棧,然后跳向 sayHell 方法中執(zhí)行,這里我們看不到它壓棧的過程,被集成為一條指令了。
然后跳向了 sayHello 方法的***條指令開始執(zhí)行,pushl 將寄存器 %ebp 中的地址壓棧,這時候的 %ebp 是上一個棧幀的幀指針地址,這個操作其實是一個保存的動作。然后,movl 指令將幀指針指向棧指針的位置,也就是棧頂位置,繼而將棧指針向上擴展 16 個字節(jié)。
接著,將數值 32 和 2323 分別寫入不同的棧地址中,這個地址相對于幀指針的地址,是可以計算出來的。
后面的操作是將 x 和 y 分別寫入寄存器 %eax 和 %edx,然后 add 指令做加法運算并存入寄存器 %eax 中。接著將結果壓棧。
leave 指令等效于以下兩條指令之和:
- movl %ebp %esp
- popl %ebp
什么意思呢?
把棧指針退回到幀指針的位置,也就是當前棧幀的底部,接著彈棧,這樣的話整個 sayHello 所占用的棧幀就已經無法引用了,相當于釋放了當前棧幀。
ret 指令用于恢復調用前的狀態(tài),繼續(xù)執(zhí)行 main 方法。
整個 IA32 的方法調用基本如上,對于 64 位的 x86-64 來說,增加了 16 個寄存器,優(yōu)先使用寄存器進行參數的計算與傳遞,效率提高了。但是與這個基于棧的存儲方式來說,劣勢之處在于「可移植性差」,不同的機器的寄存器使用肯定是有所差別的。所以我們的 Java 毋庸置疑使用的是棧。
運行時棧幀結構
在 Java 中,一個棧幀對應一個方法調用,方法中需涉及到的局部變量、操作數,返回地址等都存放在棧幀中的。每個方法對應的棧幀大小在編譯后基本已經確定了,方法中需要多大的局部變量表,多深的操作數棧等信息早以被寫入方法的 Code 屬性中了。所以運行期,方法的棧幀大小早已固定,直接計算并分配內存即可。
局部變量表
局部變量表用來存放方法運行時用到的各種變量,以及方法參數。虛擬機規(guī)范中指明,局部變量表的容量用變量槽(slot)為最小單位,卻沒有指明一個 slot 的實際空間大小,只是說,每個 slot 應當能夠存放任意一個 boolean,byte,char,short,int,float,reference 等。
按照我的理解,一個 slot 相當于一個黑盒子,具體占幾個字節(jié)適情況而定,但是這個黑盒子明確可以保存一個任意類型的變量。
局部變量表不同于操作數棧,它采用索引機制訪問元素,而不同于操作數棧的出入棧方式。例如:
- 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 ");
- }
我們反編譯看看它的局部變量表:
可以看到,局部變量表***項是名為 this 的一個類引用,它指向堆中當前對象的引用。接著就是我們的方法參數,局部變量 x,y,z 和 str。
這其實也間接說明了,我們的每個實例方法都默認傳入了一個參數 this,指向當前類的實例引用。
操作數棧
操作數棧也稱作操作棧,它不像局部變量表采用的索引機制訪問其中元素,而是標準的棧操作,入棧出棧,先入后出。操作數棧在方法執(zhí)行之初為空,隨著方法的一步一步運行,操作數棧中將不停的發(fā)生入棧出棧操作,直至方法執(zhí)行結束。
操作數棧是方法執(zhí)行過程中很重要的一個部分,方法執(zhí)行過程中各個中間結果都需要借助操作數棧進行存儲。
返回地址
一個方法在調用另一個方法結束之后,需要返回調用處繼續(xù)執(zhí)行后續(xù)的方法體。那么調用其他方法的位置點就叫做「返回地址」,我們需要通過一定的手段保證,CPU 執(zhí)行其他方法之后還能返回原來調用處,進而繼續(xù)調用者的方法體。
正如我們一開始介紹的匯編代碼一樣,這個返回地址往往會被提前壓入調用者的棧幀中,當方法調用結束時,取出棧頂元素即可得到后續(xù)方法體執(zhí)行入口。
方法調用
方法調用算是本篇的一個核心內容了,它解決了虛擬機對目標調用方法的確定問題,因為往往一條虛擬機指令要求調用某個方法,但是該方法可能會有重載,重寫等問題,那么虛擬機又該如何確定調用哪個方法呢?這就是本階段要處理的唯一任務。
首先我們要談談這個解析過程,從上篇文章中可以知道,當一個類初次加載的時候,會在解析階段完成常量池中符號引用到直接引用的替換。這其中就包括方法的符號引用翻譯到直接引用的過程,但這只針對部分方法,有些方法只有在運行時才能確定的,就不會被解析。我們稱在類加載階段的解析過程為「靜態(tài)解析」。
那么哪些方法是被靜態(tài)解析了,哪些方法需要動態(tài)解析呢?
比如下面這段代碼:
- Object obj = new String("hello");
- obj.equals("world");
Object 類中有一個 equals 方法,String 類中也有一個 equals 方法,上述程序顯然調用的是 String 的 equals 方法。那么如果我們加載 Object 類的時候將 equals 符號引用直接指向了本身的 equals 方法的直接引用,那么上述的 obj 永遠調用的都是 Object 的 equals 方法。那我們的多態(tài)就永遠實現不了。
只有那些,「編譯期可知,運行時不變」的方法才可以在類加載的時候將其進行靜態(tài)解析,這些方法主要有:private 修飾的私有方法,類靜態(tài)方法,類實例構造器,父類方法。
其余的所有方法統(tǒng)稱為「虛方法」,類加載的解析階段不會被解析。這些方法的調用不存在問題,虛擬機直接根據直接引用即可找到方法的入口,但是「非虛方法」就不同了,虛擬機需要用一定的策略才能定位到實際的方法,下面我們一起來看看。
靜態(tài)分派
首先我們看一段代碼:
- 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
不知道你答對了沒有?這是一道很常見的面試題,考的就是你對方法重載的理解以及方法分派邏輯懂不懂。下面我們來分析一下:
首先需要介紹兩個概念,「靜態(tài)類型」和「實際類型」。靜態(tài)類型指的是包裝在一個變量最外層的類型,例如上述 Father 就是所謂的靜態(tài)類型,而 Son 或是 Daughter 則是實際類型。
我們的編譯器在生成字節(jié)碼指令的時候會根據變量的靜態(tài)類型選擇調用合適的方法。就我們上述的例子而言:
這兩個方法就是我們 main 函數中調用的兩次 sayHello 方法,但是你會發(fā)現傳入的參數類型是相同的,Father,也就是調用的方法是相同的,都是這個方法:
- (LStaticDispathch/Father;)V
也就是
- public void sayHello(Father father){}
所有依賴靜態(tài)類型來定位方法執(zhí)行版本的分派動作稱作「靜態(tài)分派」,而方法重載是靜態(tài)分派的一個典型體現。但需要注意的是,靜態(tài)分派不管你實際類型是什么,它只根據你的靜態(tài)類型進行方法調用。
動態(tài)分派
- 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 方法,我們看生成的字節(jié)碼指令調用情況:
看到沒?編譯器為我們生成的方法調用指令,選擇調用的是靜態(tài)類型的對應方法,但是為什么最終的結果卻調用了是實際類型的對應方法呢?
當我們將要調用某個類型實例的具體方法時,會首先將當前實例壓入操作數棧,然后我們的 invokevirtual 指令需要完成以下幾個步驟才能實現對一個方法的調用:
- 彈出操作數棧頂部元素,判斷其實際類型,記做 C
- 在類型 C 中查找需要調用方法的簡單名稱和描述符相同的方法,如果有則返回該方法的直接引用
- 否則,向 C 的父類再做搜索,有即返回方法的直接引用
- 否則,拋出異常 java.lang.AbstractMethodError 異常
所以,我們此處的示例調用的是子類 Son 的 sayHello 方法就不言而喻了。
至于虛擬機為什么能這么準確高效的搜索某個類中的指定方法,各個虛擬機的實現各有不同,但最常見的是使用「虛方法表」,這個概念也比較簡單,就是為每個類型都維護一張方法表,該表中記錄了當前類型的所有方法的描述信息。于是虛擬機檢索方法的時候,只需要從方法表中進行搜索即可,當前類型的方法表中沒有就去父類的方法表中進行搜索。
動態(tài)類型特性的支持
動態(tài)類型語言的一個關鍵特征就是,類型檢查發(fā)生在運行時。也就是說,編譯期間編譯器是不會管你這個變量是什么類型,調用的方法是否存在的。例如:
- Object obj = new String("hello-world");
- obj.split("-");
Java 中,兩行代碼是不能通過編譯器的,原因就是,編譯器檢查變量 obj 的靜態(tài)類型是 Object,而 Object 類中并沒有 subString 這個方法,故而報錯。
而如果是動態(tài)類型語言的話,這段代碼就是沒問題的。
靜態(tài)語言會在編譯期檢查變量類型,并提供嚴格的檢查,而動態(tài)語言在運行期檢查變量實際類型,給了程序更大的靈活性。各有優(yōu)劣,靜態(tài)語言的優(yōu)勢在于安全,缺點在于缺乏靈活性,動態(tài)語言則是相反的。
JDK1.7 提供了兩種方式來支持 Java 的動態(tài)特性,invokedynamic 指令和 java.lang.invoke 包。這兩者的實現方式是類似的,我們只介紹后者的基本內容。
- //該方法是我自定義的,并非 invoke 包中的
- public static MethodHandle getSubStringMethod(Object obj) throws NoSuchMethodException, IllegalAccessException {
- //定義了一個方法模板,規(guī)定了待搜索的方法的返回值和參數類型
- 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");
- //定位方法,并傳入參數執(zhí)行方法
- String[] strs = (String[]) getSubStringMethod(obj).invokeExact("-");
- System.out.println(strs[0]);
- }
輸出結果:
- hello
你看,雖然我們 obj 的靜態(tài)類型是 Object,但是通過這種方式,我就是能夠越過編譯器的類型檢查,直接在運行期執(zhí)行我指定的方法。
具體如何實現的我就不帶大家看了,比較復雜,以后有機會單獨寫一篇文章學習一下。反正通過這種方式,我們可以不用管一個變量的靜態(tài)類型是什么,只要它有我想要調的方法,我們就可以在運行期直接調用。
總結一下,HotSpot 虛擬機基于操作數棧進行方法的解釋執(zhí)行,所有運算的中間結果以及方法參數等等,基本都伴隨著出入棧的操作取出或存儲。這種機制***的優(yōu)勢在于,可移植性強。不同于基于寄存器的方法執(zhí)行機制,對底層硬件依賴過度,無法很輕易的跨平臺,但是劣勢也很明顯,就是同樣的操作需要相對更多的指令才能完成。