上篇日誌總結CPU調用函數時的棧內存變化過程,用的C程序解釋成彙編來描述,目的是想說明,JVM在執行Java程序時,函數調用的過程和C程序函數調用的過程是相同的,C作爲靜態編譯型的語言,在程序執行先需要編譯成CPU能直接執行的二進制碼,JVM執行Java程序時也需要先將其解釋成字節碼,或者說字節碼指令集更準確,通常指令集是計算機硬件纔有的東西,在開發語言上包裝一套指令集,好處是可以統一規範,這樣“統一的接口”讓開發者用更加接近人類語言來調用機器指令,想想如果讓你用匯編來寫程序,movl、pop、inc、shl左移右移等,程序可讀性沒那麼高,效率也沒那麼高。
JVM字節碼指令
字節碼指令作爲中間語言,作用是幫助Java語言實現一些如棧操作,入棧出棧,傳參讀參,讀寫局部變化和函數調用等,例如最簡單的控制指令,for循環、foreach循環、do…while循環、if…else和switch等。運算指令集有算術、邏輯、比較和位運算。數據交換指令集,用來操作棧內存、Java堆等,使用數據交換指令來實現數據在這些內存區域裏面的交換,或者說傳遞,函數調用的指令集也在數據交換指令集中。
因爲JVM本身就是用C和C++共同編寫的解釋性虛擬機,所以在執行Java程序時,最終是JVM交由C語言來運行,也就是說,JVM是一邊將字節碼指令翻譯成C程序,一邊執行,通過C來調用執行機器指令。這就是JVM中模板解釋器的實現思想,爲每一個機器指令編寫一段實現對應功能的彙編代碼,在JVM初始化時,就會將彙編代碼翻譯成機器指令,加載到內存中,當JVM執行某一條Java字節碼指令時,就可以從內存中直接執行對應的彙編代碼,直接跳到該指令的內存地址就可以調用執行。
函數指針直接觸發機器指令
按照上面說的思路,JVM的模板解釋器爲每一個機器指令編寫一段實現對應功能的彙編代碼,在運行某一字節碼指令時,可以直接執行對應的機器指令,具體的實現方式是怎樣的,來看一個例子:
#include <stdio.h>
#include <stdlib.h>
const unsigned char run[] =
{
0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x40, 0x53, 0x56, 0x57,
0x8d, 0x7d, 0xc0, 0xb9, 0x10, 0x00, 0x00, 0x00, 0xb8,
0xcc, 0xcc, 0xcc, 0xcc, 0xf3, 0xab,
0x8b, 0x45, 0x08, 0x03, 0x45, 0x0c,
0x5f, 0x5e, 0x5b, 0x8b, 0xe5, 0x5d, 0xc3
};
int main(int argc, char const* argv[])
{
int a = 4;
int b = 5;
int (*add)(int, int); // 定義函數指針
add = (void*) run; // 函數指針add指向run機器碼
int result = add(a, b);
printf("%d + %d = %d", a, b, result);
return 0;
}
首先定義一個字符數組run,裏面是一個函數的十六進制表示方式,這些字符組成機器指令,作用是對傳入的兩個數a和b進行求和,並返回結果。接着往下,main()函數裏,還記得C直接操作機器指令的方式嗎,就是用函數指針,通過函數指針變量(一個指針),存放某一段機器指令,在C程序編譯階段,C函數指針直接指向了某一段機器指令的首地址,實現直接調用該機器指令。
所以在main()函數裏,定義了一個函數指針add,下一行存放了run字符數組的首地址,最後通過add(a, b)來調用,程序執行到int result = add(a, b);時,就會直接將run數組裏的一片連續內存區代碼拿出來執行:
兩種觸發方式
上面的例子,通過函數指針直接觸發機器指令,方式是先定義一個函數指針,函數指針就是一個指針變量,和其他普通變量如int,float,char等一樣,存放的是一個值,指針變量存放的就是首地址,聲明和調用正如上面的例子:
int (*add)(int, int); // 聲明
arr = (void*) run; //存放某一片地址區域
int i = arr(a, b); // 調用
還有一種方式,就是先聲明其是一種類型,有點像面向對象中的類,首先通過typedef定義一種類型,一種函數指針類型,例如:
typedef (*addType)(int, int);
該語句聲明瞭一種函數指針類型addType,是用戶自定義的一種數據類型,然後就可以通過該類型是聲明一個變量來用:
addType add = (void*) run;
int i = arr(a, b);
無論是上面哪種聲明,在調用時也有兩種方式觸發機器指令,第一張就是上面都用到的,直接int i = arr(a, b);看似最簡潔明瞭,但是最好還是使用第二種方式:int i = (*add)(a, b);因爲這樣調用可以讓別人一看就知道你使用了函數指針,而不是一個普通函數的顯式調用。
call_stub函數指針
JVM中實現函數指針調用機器指令,用的是call_stub,它也是一個函數指針,函數原型如下:
static CallStub call_stub()
{
return (CallStub)(castable_address(_call_stub_entry));
}
函數的調用結果最終會被類型轉換成CallStub,CallStub是一個自定義類型,函數指針類型,結構如下:
typedef void (*CallStub) {
address link,
intptr_t* result,
BasicType result_type,
methodOopDesc* method,
address entry_point,
intptr_t* parameters,
int size_of_parameters,
TRAPS
};
可以看到一共有8個參數,link表示的是連接器,result是函數返回值的地址,result_type顧名思義就是函數的返回類型,method表示Java方法對象,entry_point這個參數很重要,表示的是JVM調用Java方法的事先定義好的入口,前面說到模板解釋器,在JVM初始化時會將一些方法調用的“入口”代碼編譯成機器指令,加載到內存中,在JVM調用方法時,需要先調用這些入口指令,例如Java程序的主函數必須通過call_stub函數指針來執行。parameters指的是Java方法的參數集合,下一個size_of_parameters自然就是參數的數量了。
castable_address()
CallStub結構搞清楚後,回到call_stub的調用語句看:
return (CallStub)(castable_address(_call_stub_entry));
裏面的參數是castable_address,也是一個函數,結構是這樣的:
inline address_word castable_address(address_x)
{
return address_word(x);
}
返回類型是address_word,顧名思義是一個地址類型,自定義的地址類型,很容易就能查到它經過了哪些包裝:
typedef uintptr_t address_word;
typedef unsigned int uintptr_t;
可以看到,address_word的最終原型是無符號整型類型unsigned int。最後,就只剩下_call_stub_entry這個參數了它的原封類型也是unsigned int,這些很容易查到它的封裝:
static address _call_stub_entry;
到這裏把call_stub函數指針裏的三個類型都搞明白了,JVM通過call_stub函數指針調用目標函數,call_stub函數相當於一個接口,裏面又調用了castable_address()函數,傳入原封類型是unsigned int的參數_address_stub_entry,標識的就是一個函數的地址。