本文轉載自JVM雜談之JIT
導語
JIT技術是JVM中最重要的核心模塊之一。我的課程里本來沒有計划這一篇,但因為不斷有朋友問起,Java到底是怎么運行的?既然Hotspot是C++寫的,那Java是不是可以說運行在C++之上呢?為了澄清這些概念,我才想起來了加了這樣一篇文章,算做番外篇吧。
Just In Time
Just in time編譯,也叫做運行時編譯,不同於 C / C++ 語言直接被翻譯成機器指令,javac把java的源文件翻譯成了class文件,而class文件中全都是Java字節碼。那么,JVM在加載了這些class文件以后,針對這些字節碼,逐條取出,逐條執行,這種方法就是解釋執行。
還有一種,就是把這些Java字節碼重新編譯優化,生成機器碼,讓CPU直接執行。這樣編出來的代碼效率會更高。通常,我們不必把所有的Java方法都編譯成機器碼,只需要把調用最頻繁,占據CPU時間最長的方法找出來將其編譯成機器碼。這種調用最頻繁的Java方法就是我們常說的熱點方法(Hotspot,說不定這個虛擬機的名字就是從這里來的)。
這種在運行時按需編譯的方式就是Just In Time。
主要技術點
其實JIT的主要技術點,從大的框架上來說,非常簡單,就是申請一塊既有寫權限又有執行權限的內存,然后把你要編譯的Java方法,翻譯成機器碼,寫入到這塊內存里。當再需要調用原來的Java方法時,就轉向調用這塊內存。
我們看一個例子:
#include<stdio.h>
int inc(int a) {
return a + 1;
}
int main() {
printf("%d\n", inc(3));
return 0;
}
上面這個例子很簡單,就是把3加1,然后打印出來,我們通過以下命令,查看一下它的機器碼:
# gcc -o inc inc.c
# objdump -d inc
然后在這一堆輸出中,可以找到 inc 方法最終被翻譯成了這樣的機器碼:
40052d: 55 push %rbp
40052e: 48 89 e5 mov %rsp,%rbp
400531: 89 7d fc mov %edi,-0x4(%rbp)
400534: 8b 45 fc mov -0x4(%rbp),%eax
400537: 83 c0 01 add $0x1,%eax
40053a: 5d pop %rbp
40053b: c3 retq
我來解釋一下(讀者需要一定的x86匯編語言的知識)。
第一句,保存上一個棧幀的基址,並把當前的棧指針賦給棧基址寄存器,這是進入一個函數的常規操作。我們不去管它。
第三句,把edi存到棧上。在x64處理器上,前6個參數都是使用寄存器傳參的。第一個參數會使用rdi,第二個參數使用 rsi,等等。所以 edi 里存的其實就是第一個參數,也就是整數 3,為什么使用rdi的低32位,也就是 edi 呢?因為我們的入參 a 是 int 型啊。大家可以換成 long 型看看效果。
第四句,把上一步存到棧上的那個整數再存進 eax 中。
第五句往后,把 eax 加上 1, 然后就退棧,返回。按照x64的規定(ABI),返回值通過eax傳遞。
我們看到了,其實第三句,第四句好像根本沒有存在的必要,gcc 默認情況下,生成的機器碼有點傻,它總要把入參放到棧上,但其實,我們是可以直接把參數從 rdi 中放入到 rax 中的。不滿意。那我們可以自己改一下,讓它更精簡一點。怎么做呢?答案就是運行時修改 inc 的邏輯。
#include<stdio.h>
#include<memory.h>
#include<sys/mman.h>
typedef int (* inc_func)(int a);
int main() {
char code[] = {
0x55, // push rbp
0x48, 0x89, 0xe5, // mov rsp, rbp
0x89, 0xf8, // mov edi, eax
0x83, 0xc0, 0x01, // add $1, eax
0x5d, // pop rbp
0xc3 // ret
};
void * temp = mmap(NULL, sizeof(code), PROT_WRITE | PROT_EXEC,
MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
memcpy(temp, code, sizeof(code));
inc_func p_inc = (inc_func)temp;
printf("%d\n", p_inc(7));
return 0;
}
在這個例子中,我們使用了 mmap 來申請了一塊有寫權限和執行權限的內存,然后把我們手寫的機器碼拷進去,然后使用一個函數指針指向這塊內存,並且調用它。通過這種方式我們就可以執行這一段手寫的機器碼了。
運行一下看看:
# gcc -o inc inc.c
# ./inc
8
再回想一下這個過程。我們通過手寫機器碼把原來的 inc 函數代替掉了。在新的例子中,我們是使用程序中定義的數據來重新造了一個 inc 函數。這種在運行的過程創建新的函數的方式,就是JIT的核心操作。
解釋器,C1和C2
在Hotspot中,解釋器是為每一個字節碼生成一小段機器碼,在執行Java方法的過程中,每次取一條指令,然后就去執行這一個指令所對應的那一段機器碼。256條指令,就組成了一個表,在這個表里,每一條指令都對應一段機器碼,當執行到某一條指令時,就從這個表里去查這段機器碼,並且通過 jmp 指令去執行這段機器碼就行了。
這種方式被稱為模板解釋器。
模板解釋器生成的代碼有很多冗余,就像我們上面的第一個例子那樣。為了生成更精簡的機器碼,我們可以引入編譯器優化手段,例如全局值編碼,死代碼消除,標量展開,公共子表達式消除,常量傳播等等。這樣生成出來的機器碼會更加優化。
但是,生成機器碼的質量越高,所需要的時間也就越長。JIT線程也是要擠占Java 應用線程的資源的。所以C1是一個折衷,編譯時間既不會太長,生成的機器碼的指令也不是最優化的,但肯定比解釋器的效率要高很多。
如果一個Java方法調用得足夠頻繁,那就更值得花大力氣去為它生成更優質的機器碼,這時就會觸發C2編譯,c2是一個運行得更慢,但卻能生成更高效代碼的編譯器。
由此,我們看到,其實Java的運行,幾乎全部都依賴運行時生成的機器碼上。所以,對於文章開頭的那個問題“Java是運行在C++上的嗎?”,大家應該都有自己的答案了。這個問題無法簡單地回答是或者不是,正確答案就是Java的運行依賴模板解釋器和JIT編譯器。
多說一點優化
我們這節課所舉的例子中,可以做更多的優化,例如,既然我進到inc函數以后,完全沒有使用棧,那其實,我就不要再為它開辟棧幀了。所以可以把push rbp, pop rbp的邏輯都去掉。
進一步優化成這樣:
char code[] = {
0x89, 0xf8, // mov edi, eax
0x83, 0xc0, 0x01, // add $1, eax
0xc3 // ret
};
可以看到,指令更加精簡了。我們重新編譯運行,還是能成功打印出8。
根據這個問題:為什么 lea 會被用來計算?
我們還可以寫出更優化的代碼來:
char code[] = {
0x8d, 0x47, 0x01, // lea 0x1(rdi), rax
0xc3 // ret
};
如果開啟 gcc 的優化編譯,我們也可以得到這樣的代碼,例如,還是針對這個方法:
int inc(int a) {
return a + 1;
}
使用 -O2 優化:
# gcc -o inc inc.c -O2
# objdump -d inc
就可以看到,inc 的機器碼變成這樣了:
00000000004005f0 <inc>:
4005f0: 8d 47 01 lea 0x1(%rdi),%eax
4005f3: c3 retq
這和我們手寫的優化的機器碼是完全一樣的了。
實際上,C1和C2所要做的和gcc的優化編譯是一樣的,就是使用特定的方法生成更高效的機器碼。但是從原理上來說,運行時生成機器碼這個技術,大家都是相通的。
最后,補充一句,iOS禁掉了JIT編譯,所用的手段就是無法申請一塊同時具有寫權限和執行權限的內存。那么,JIT的核心基石,運行時生成可執行的機器碼就無法存在了。